Fixes https://github.com/PostgREST/postgrest/issues/1452. The single row restriction can be lifted because the PUT will consider only the first object of the array.
321 lines
18 KiB
Haskell
321 lines
18 KiB
Haskell
module Feature.UpsertSpec where
|
|
|
|
import Network.Wai (Application)
|
|
|
|
import Network.HTTP.Types
|
|
import Test.Hspec
|
|
import Test.Hspec.Wai
|
|
import Test.Hspec.Wai.JSON
|
|
import Text.Heredoc
|
|
|
|
import Protolude hiding (get, put)
|
|
import SpecHelper
|
|
|
|
spec :: SpecWith ((), Application)
|
|
spec =
|
|
describe "UPSERT" $ do
|
|
context "with POST" $ do
|
|
context "when Prefer: resolution=merge-duplicates is specified" $ do
|
|
it "INSERTs and UPDATEs rows on pk conflict" $
|
|
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json| [
|
|
{ "name": "Javascript", "rank": 6 },
|
|
{ "name": "Java", "rank": 2 },
|
|
{ "name": "C", "rank": 1 }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "name": "Javascript", "rank": 6 },
|
|
{ "name": "Java", "rank": 2 },
|
|
{ "name": "C", "rank": 1 }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "INSERTs and UPDATEs row on composite pk conflict" $
|
|
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json| [
|
|
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
|
|
{ "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
|
|
{ "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "succeeds when the payload has no elements" $
|
|
request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json|[]|] `shouldRespondWith`
|
|
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "INSERTs and UPDATEs rows on single unique key conflict" $
|
|
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json| [
|
|
{ "unique_key": 1, "value": "B" },
|
|
{ "unique_key": 2, "value": "C" }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "unique_key": 1, "value": "B" },
|
|
{ "unique_key": 2, "value": "C" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
|
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json| [
|
|
{ "key1": 1, "key2": 1, "value": "B" },
|
|
{ "key1": 1, "key2": 2, "value": "C" }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "key1": 1, "key2": 1, "value": "B" },
|
|
{ "key1": 1, "key2": 2, "value": "C" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
context "when Prefer: resolution=ignore-duplicates is specified" $ do
|
|
it "INSERTs and ignores rows on pk conflict" $
|
|
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json|[
|
|
{ "name": "PHP", "rank": 9 },
|
|
{ "name": "Python", "rank": 10 }
|
|
]|] `shouldRespondWith` [json|[
|
|
{ "name": "PHP", "rank": 9 }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "INSERTs and ignores rows on composite pk conflict" $
|
|
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json|[
|
|
{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
|
|
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
|
|
]|] `shouldRespondWith` [json|[
|
|
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "INSERTs and ignores rows on single unique key conflict" $
|
|
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json| [
|
|
{ "unique_key": 1, "value": "B" },
|
|
{ "unique_key": 2, "value": "C" },
|
|
{ "unique_key": 3, "value": "D" }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "unique_key": 3, "value": "D" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
|
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json| [
|
|
{ "key1": 1, "key2": 1, "value": "B" },
|
|
{ "key1": 1, "key2": 2, "value": "C" },
|
|
{ "key1": 1, "key2": 3, "value": "D" }
|
|
]|] `shouldRespondWith` [json| [
|
|
{ "key1": 1, "key2": 3, "value": "D" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "succeeds if the table has only PK cols and no other cols" $ do
|
|
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
|
|
`shouldRespondWith`
|
|
[json|[ { "id": 3} ]|]
|
|
{ matchStatus = 201 ,
|
|
matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
|
|
matchContentTypeJson] }
|
|
|
|
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
|
|
`shouldRespondWith`
|
|
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
|
|
{ matchStatus = 201 ,
|
|
matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
|
|
matchContentTypeJson] }
|
|
|
|
it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
|
|
request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json|[ { "a": "1", "b": "0" } ]|]
|
|
`shouldRespondWith`
|
|
[json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "succeeds if not a single resource is created" $ do
|
|
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
|
|
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
|
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
|
|
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
context "with PUT" $ do
|
|
context "Restrictions" $ do
|
|
it "fails if Range is specified" $
|
|
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
|
|
[str| [ { "name": "Javascript", "rank": 1 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
|
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "fails if limit is specified" $
|
|
put "/tiobe_pls?name=eq.Javascript&limit=1"
|
|
[str| [ { "name": "Javascript", "rank": 1 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
|
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "fails if offset is specified" $
|
|
put "/tiobe_pls?name=eq.Javascript&offset=1"
|
|
[str| [ { "name": "Javascript", "rank": 1 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
|
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "rejects every other filter than pk cols eq's" $ do
|
|
put "/tiobe_pls?rank=eq.19"
|
|
[str| [ { "name": "Go", "rank": 19 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
put "/tiobe_pls?id=not.eq.Java"
|
|
[str| [ { "name": "Go", "rank": 19 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
put "/tiobe_pls?id=in.(Go)"
|
|
[str| [ { "name": "Go", "rank": 19 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
put "/tiobe_pls?and=(id.eq.Go)"
|
|
[str| [ { "name": "Go", "rank": 19 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "fails if not all composite key cols are specified as eq filters" $ do
|
|
put "/employees?first_name=eq.Susan"
|
|
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
put "/employees?last_name=eq.Heidt"
|
|
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "fails if the uri primary key doesn't match the payload primary key" $ do
|
|
put "/tiobe_pls?name=eq.MATLAB" [str| [ { "name": "Perl", "rank": 17 } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
|
|
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
|
put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
|
|
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
|
|
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "fails if the table has no PK" $
|
|
put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|]
|
|
`shouldRespondWith`
|
|
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
|
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
|
|
|
context "Inserting row" $ do
|
|
it "succeeds on table with single pk col" $ do
|
|
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` "[]"
|
|
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 204
|
|
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "succeeds on table with composite pk" $ do
|
|
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
`shouldRespondWith` "[]"
|
|
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
`shouldRespondWith` 204
|
|
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
`shouldRespondWith`
|
|
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
{ matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "succeeds if the table has only PK cols and no other cols" $ do
|
|
get "/only_pk?id=eq.10" `shouldRespondWith` "[]"
|
|
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
|
|
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
|
|
context "Updating row" $ do
|
|
it "succeeds on table with single pk col" $ do
|
|
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json|[ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 13 } ]|] `shouldRespondWith` 204
|
|
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 13 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "succeeds if the payload has more than one row, but it only puts the first element" $
|
|
request methodPut "/tiobe_pls?name=eq.Go"
|
|
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
|
|
[str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]
|
|
`shouldRespondWith`
|
|
[json|{ "name": "Go", "rank": 19 }|]
|
|
{ matchStatus = 200 , matchHeaders = [matchContentTypeSingular] }
|
|
|
|
it "succeeds on table with composite pk" $ do
|
|
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
`shouldRespondWith`
|
|
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
|
{ matchHeaders = [matchContentTypeJson] }
|
|
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
|
|
`shouldRespondWith` 204
|
|
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
|
`shouldRespondWith`
|
|
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
|
|
{ matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "succeeds if the table has only PK cols and no other cols" $ do
|
|
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
|
|
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
|
|
|
|
it "works with return=representation and vnd.pgrst.object+json" $
|
|
request methodPut "/tiobe_pls?name=eq.Ruby"
|
|
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
|
|
[str| [ { "name": "Ruby", "rank": 11 } ]|]
|
|
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
|
|
|
|
context "with a camel case pk column" $ do
|
|
it "works with POST and merge-duplicates/ignore-duplicates headers" $ do
|
|
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
|
[json| [
|
|
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
|
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
|
]|] `shouldRespondWith` [json|[
|
|
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
|
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
|
]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
|
}
|
|
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
|
[json| [
|
|
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
|
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
|
]|] `shouldRespondWith` [json|[]|]
|
|
{ matchStatus = 201
|
|
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
|
}
|
|
|
|
it "works with PUT" $ do
|
|
put "/UnitTest?idUnitTest=eq.1" [str| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204
|
|
get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
|
|
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] { matchHeaders = [matchContentTypeJson] }
|