test: Split spec tests into multiple subfolders

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
This commit is contained in:
Wolfgang Walther
2022-01-28 19:26:33 +01:00
committed by Wolfgang Walther
parent 8a722e2cfe
commit 927ff6f1e5
32 changed files with 126 additions and 124 deletions
+276
View File
@@ -0,0 +1,276 @@
module Feature.Query.AndOrParamsSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion =
describe "and/or params used for complex boolean logic" $ do
context "used with GET" $ do
context "or param" $ do
it "can do simple logic" $
get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
it "can negate simple logic" $
get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can be combined with traditional filters" $
get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
context "embedded levels" $ do
it "can do logic on the second level" $
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
[json|[
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
]|] { matchHeaders = [matchContentTypeJson] }
it "can do logic on the third level" $
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))"
`shouldRespondWith`
[json|[
{"id": 1, "child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]},
{ "id": 2, "grandchild_entities": []},
{ "id": 4, "grandchild_entities": []},
{ "id": 5, "grandchild_entities": []}
]},
{"id": 2, "child_entities": [
{ "id": 3, "grandchild_entities": []},
{ "id": 6, "grandchild_entities": []}
]},
{"id": 3, "child_entities": []},
{"id": 4, "child_entities": []}
]|]
context "and/or params combined" $ do
it "can be nested inside the same expression" $
get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
it "can be negated while nested" $
get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can be combined unnested" $
get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
context "operators inside and/or" $ do
it "can handle eq and neq" $
get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle lt and gt" $
get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle lte and gte" $
get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle like and ilike" $
get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle in" $
get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle is" $
get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle fts" $ do
get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
[json|[
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
]|] { matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $
it "can handle wfts (websearch_to_tsquery)" $
get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
`shouldRespondWith`
[json|[
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
]|]
{ matchHeaders = [matchContentTypeJson] }
it "can handle cs and cd" $
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle range operators" $ do
get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle array operators" $ do
get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
[json|[]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
context "operators with not" $ do
it "eq, cs, like can be negated" $
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
it "in, is, fts can be negated" $
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
it "lt, gte, cd can be negated" $
get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
it "gt, lte, ilike can be negated" $
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
context "and/or params with quotes" $ do
it "eq can have quotes" $
get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
it "like and ilike can have quotes" $
get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
it "in can have quotes" $
get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "allows whitespace" $
get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
context "multiple and/or conditions" $ do
it "cannot have zero conditions" $
get "/entities?or=()" `shouldRespondWith`
[json|{
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
"message": "\"failed to parse logic tree (())\" (line 1, column 4)"
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
it "can have a single condition" $ do
get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
it "can have three conditions" $ do
get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`
[json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }
get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
it "can have four conditions combining and/or" $ do
get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`
[json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }
get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
context "used with POST" $
it "includes related data with filters" $
request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
[("Prefer", "return=representation")]
[json|[
{"id":7,"name":"entity 4","parent_id":1},
{"id":8,"name":"entity 5","parent_id":2},
{"id":9,"name":"entity 6","parent_id":3}
]|]
`shouldRespondWith`
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
{ matchStatus = 201 }
context "used with PATCH" $
it "succeeds when using and/or params" $
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
[("Prefer", "return=representation")]
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
{ matchHeaders = [matchContentTypeJson] }
context "used with DELETE" $
it "succeeds when using and/or params" $
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
[("Prefer", "return=representation")]
""
`shouldRespondWith`
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
it "can query columns that begin with and/or reserved words" $
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
it "fails when using IN without () and provides meaningful error message" $
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
[json|{
"details": "unexpected \"1\" expecting \"(\"",
"message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
it "fails on malformed query params and provides meaningful error message" $ do
get "/entities?or=)(" `shouldRespondWith`
[json|{
"details": "unexpected \")\" expecting \"(\"",
"message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
[json|{
"details": "unexpected \"d\" expecting \"(\"",
"message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
[json|{
"details": "unexpected \"x\" expecting logic operator (and, or)",
"message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+117
View File
@@ -0,0 +1,117 @@
module Feature.Query.DeleteSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Deleting" $ do
context "existing record" $ do
it "succeeds with 204 and deletion count" $
request methodDelete "/items?id=eq.1"
[]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
it "returns the deleted item and count if requested" $
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
`shouldRespondWith` [json|[{"id":2}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/1"]
}
it "ignores ?select= when return not set or return=minimal" $ do
request methodDelete "/items?id=eq.3&select=id"
[]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
request methodDelete "/items?id=eq.3&select=id"
[("Prefer", "return=minimal")]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
it "returns the deleted item and shapes the response" $
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [json|[{"id":2,"name":"Two"}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "can rename and cast the selected columns" $
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [json|[{"ciId":"3","ciName":"Three"}]|]
it "can embed (parent) entities" $
request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
`shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
context "known route, no records matched" $
it "includes [] body if return=rep" $
request methodDelete "/items?id=eq.101"
[("Prefer", "return=representation")] ""
`shouldRespondWith` "[]"
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
context "totally unknown route" $
it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
context "table with limited privileges" $ do
it "fails deleting the row when return=representation and selecting all the columns" $
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
`shouldRespondWith` 401
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
[json| { "password": "passxyz" } |]
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "suceeds deleting the row with no explicit select when using return=minimal" $
request methodDelete "/app_users?id=eq.2"
[("Prefer", "return=minimal")]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "suceeds deleting the row with no explicit select by default" $
request methodDelete "/app_users?id=eq.3"
[]
mempty
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
@@ -0,0 +1,440 @@
module Feature.Query.EmbedDisambiguationSpec where
import Network.Wai (Application)
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "resource embedding disambiguation" $ do
context "ambiguous requests that give 300 Multiple Choices" $ do
it "errs when there's a table and view that point to the same fk" $
get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
[json|
{
"details": [
{
"cardinality": "many-to-one",
"relationship": "message_sender_fkey[sender][id]",
"embedding": "message with person"
},
{
"cardinality": "many-to-one",
"relationship": "message_sender_fkey[sender][id]",
"embedding": "message with person_detail"
}
],
"hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",
"message": "Could not embed because more than one relationship was found for 'message' and 'sender'"
}
|]
{ matchStatus = 300
, matchHeaders = [matchContentTypeJson]
}
it "errs when there are o2m and m2m cardinalities to the target table" $
get "/sites?select=*,big_projects(*)" `shouldRespondWith`
[json|
{
"details": [
{
"cardinality": "many-to-one",
"relationship": "main_project[main_project_id][big_project_id]",
"embedding": "sites with big_projects"
},
{
"cardinality": "many-to-many",
"relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
"embedding": "sites with big_projects"
},
{
"cardinality": "many-to-many",
"relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
"embedding": "sites with big_projects"
}
],
"hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",
"message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"
}
|]
{ matchStatus = 300
, matchHeaders = [matchContentTypeJson]
}
it "errs on an ambiguous embed that has a circular reference" $
get "/agents?select=*,departments(*)" `shouldRespondWith`
[json|
{
"details": [
{
"cardinality": "many-to-one",
"relationship": "agents_department_id_fkey[department_id][id]",
"embedding": "agents with departments"
},
{
"cardinality": "one-to-many",
"relationship": "departments_head_id_fkey[id][head_id]",
"embedding": "agents with departments"
}
],
"hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",
"message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"
}
|]
{ matchStatus = 300
, matchHeaders = [matchContentTypeJson]
}
it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
-- We have 4 possibilities for doing the junction JOIN here.
-- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
-- If the need arises this capability can be added later without causing a breaking change
get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
[json|
{
"details": [
{
"cardinality": "many-to-many",
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",
"embedding": "whatev_sites with whatev_projects"
},
{
"cardinality": "many-to-many",
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",
"embedding": "whatev_sites with whatev_projects"
},
{
"cardinality": "many-to-many",
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",
"embedding": "whatev_sites with whatev_projects"
},
{
"cardinality": "many-to-many",
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",
"embedding": "whatev_sites with whatev_projects"
}
],
"hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",
"message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"
}
|]
{ matchStatus = 300
, matchHeaders = [matchContentTypeJson]
}
context "disambiguating requests with embed hints" $ do
context "using FK to specify the relationship" $ do
it "can embed by FK name" $
get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed by FK name and select the FK column at the same time" $
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed parent with view!fk and grandparent by using fk" $
get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
[json|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
it "can embed by using a composite FK name" $
get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
[json| [
{
"day": "2019-12-02",
"fst_shift": {
"car_id": "CAR-349",
"schedule": {
"name": "morning"
}
},
"snd_shift": {
"camera_id": "CAM-123",
"schedule": {
"name": "night"
}
},
"unit_id": 1
}
] |]
{ matchHeaders = [matchContentTypeJson] }
it "embeds by using two fks pointing to the same table" $
get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
[json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "fails if the fk is not known" $
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
[json|{
"hint":"Verify that 'message' and 'person' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
"message":"Could not find a relationship between 'message' and 'person' in the schema cache"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
it "can request a parent with fk" $
get "/comments?select=content,user(name)" `shouldRespondWith`
[json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "can request two parents with fks" $
get "/articleStars?select=createdAt,article(id),user(name)&limit=1"
`shouldRespondWith`
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
it "can specify a view!fk" $
get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can specify a table!fk hint and request children 2 levels" $
get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
[json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
{ matchHeaders = [matchContentTypeJson] }
context "using the column name of the FK to specify the relationship" $ do
it "can embed by column" $
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed by column and select the column at the same time, if aliased" $
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed parent by using view!column and grandparent by using the column" $
get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
[json|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
it "can specify table!column" $
get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "will embed using a column that has uppercase chars" $
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "embeds by using two columns pointing to the same table" $
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
{ matchHeaders = [matchContentTypeJson] }
context "using the junction to disambiguate the request" $
it "can specify the junction of an m2m relationship" $ do
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
[json|
[
{
"big_projects": [
{
"name": "big project 1"
}
],
"main_project_id": 1,
"name": "site 1",
"site_id": 1
},
{
"big_projects": [
{
"name": "big project 1"
},
{
"name": "big project 2"
}
],
"main_project_id": null,
"name": "site 2",
"site_id": 2
}
]
|]
get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
[json|
[
{
"big_projects": [
{
"name": "big project 1"
}
],
"main_project_id": 1,
"name": "site 1",
"site_id": 1
},
{
"big_projects": [],
"main_project_id": null,
"name": "site 2",
"site_id": 2
}
]
|]
{ matchHeaders = [matchContentTypeJson] }
context "using a FK column and a FK to specify the relationship" $
it "embeds by using a column and a fk pointing to the same table" $
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
{ matchHeaders = [matchContentTypeJson] }
context "tables with self reference foreign keys" $ do
context "one self reference foreign key" $ do
it "embeds parents recursively" $
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
[json|[
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
]|]
{ matchHeaders = [matchContentTypeJson] }
it "embeds children recursively" $
get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
[json|[{
"id": "1", "name": "Parental Unit", "children": [
{ "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },
{ "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }
]
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds parent and then embeds children" $
get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
[json|[{
"id": "2", "name": "Kid One", "parent": {
"id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
}
}]|] { matchHeaders = [matchContentTypeJson] }
context "two self reference foreign keys" $ do
it "embeds parents" $
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
[json|[{
"id": 3, "name": "Acme",
"referee": {
"id": 1,
"name": "Referee Org"
},
"auditor": {
"id": 2,
"name": "Auditor Org"
}
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds children" $ do
get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
[json|[{
"id": 1, "name": "Referee Org",
"refereeds": [
{
"id": 3,
"name": "Acme"
},
{
"id": 4,
"name": "Umbrella"
}
]
}]|] { matchHeaders = [matchContentTypeJson] }
get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
[json|[{
"id": 2, "name": "Auditor Org",
"auditees": [
{
"id": 3,
"name": "Acme"
},
{
"id": 4,
"name": "Umbrella"
}
]
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds other relations(manager) besides the self reference" $ do
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
[json|[{
"name":"Cyberdyne",
"manager":{"name":"Cyberdyne Manager"},
"referee":{
"name":"Acme",
"manager":{"name":"Acme Manager"},
"auditor":{
"name":"Auditor Org",
"manager":{"name":"Auditor Manager"}}},
"auditor":{
"name":"Umbrella",
"manager":{"name":"Umbrella Manager"},
"referee":{
"name":"Referee Org",
"manager":{"name":"Referee Manager"}}}
}]|] { matchHeaders = [matchContentTypeJson] }
get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
[json|[{
"name":"Auditor Org",
"manager":{"name":"Auditor Manager"},
"auditees":[
{"name":"Acme",
"manager":{"name":"Acme Manager"},
"refereeds":[
{"name":"Cyberdyne",
"manager":{"name":"Cyberdyne Manager"}},
{"name":"Oscorp",
"manager":{"name":"Oscorp Manager"}}]},
{"name":"Umbrella",
"manager":{"name":"Umbrella Manager"},
"refereeds":[]}]
}]|] { matchHeaders = [matchContentTypeJson] }
context "m2m embed when there's a junction in an internal schema" $ do
-- https://github.com/PostgREST/postgrest/issues/1736
it "works with no ambiguity when there's an exposed view of the junction" $ do
get "/screens?select=labels(name)" `shouldRespondWith`
[json|[{"labels":[{"name":"fruit"}]}, {"labels":[{"name":"vehicles"}]}, {"labels":[{"name":"vehicles"}, {"name":"fruit"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/actors?select=*,films(*)" `shouldRespondWith`
[json|[ {"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]},
{"id":2,"name":"mary","films":[{"id":2001,"title":"odyssée de l'espace"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "doesn't work if the junction is only internal" $
get "/end_1?select=end_2(*)" `shouldRespondWith`
[json|{
"hint":"Verify that 'end_1' and 'end_2' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
"message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
it "shouldn't try to embed if the private junction has an exposed homonym" $
-- ensures the "invalid reference to FROM-clause entry for table "rollen" error doesn't happen.
-- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
get "/schauspieler?select=filme(*)" `shouldRespondWith`
[json|{
"hint":"Verify that 'schauspieler' and 'filme' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
"message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
@@ -0,0 +1,356 @@
module Feature.Query.EmbedInnerJoinSpec where
import Network.HTTP.Types
import Network.Wai (Application)
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Embedding with an inner join" $ do
context "many-to-one relationships" $ do
it "ignores null embeddings while the default left join doesn't" $ do
get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
{"id":5,"clients":null}]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/4" ]
}
it "filters source tables when the embedded table is filtered" $ do
get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}},
{"id":2,"clients":{"id":1}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":3,"clients":{"id":2}},
{"id":4,"clients":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
[json|[]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
[json|[
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/4" ]
}
it "only affects the source table rows if his direct embedding is an inner join" $ do
get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":1,"projects":null},
{"id":2,"projects":null},
{"id":3,"projects":null},
{"id":4,"projects":null},
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-7/8" ]
}
it "works with views" $ do
get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
[json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
context "one-to-many relationships" $ do
it "ignores empty array embeddings while the default left join doesn't" $ do
get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
[json|[
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
{"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
[json| [
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
{"id":2,"child_entities":[{"id":3}, {"id":6}]},
{"id":3,"child_entities":[]},
{"id":4,"child_entities":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "filters source tables when the embedded table is filtered" $ do
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
[json|[{"id":1,"child_entities":[{"id":1}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
[json|[{"id":2,"child_entities":[{"id":3}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
[json|[]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
`shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
it "only affects the source table rows if his direct embedding is an inner join" $ do
get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] },
{ "id": 2, "grandchild_entities": [] },
{ "id": 4, "grandchild_entities": [] },
{ "id": 5, "grandchild_entities": [] } ]
},
{
"id": 2,
"child_entities": [
{ "id": 3, "grandchild_entities": [] },
{ "id": 6, "grandchild_entities": [] } ]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "works with views" $ do
get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
[json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
context "many-to-many relationships" $ do
it "ignores empty array embeddings while the default left join doesn't" $ do
get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
[json| [
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
{"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
[json| [
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
{"id":2,"suppliers":[{"id":1}, {"id":3}]},
{"id":3,"suppliers":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "filters source tables when the embedded table is filtered" $ do
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
[json| [{"id":1,"suppliers":[{"id":2}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
[json| [{"id":2,"suppliers":[{"id":3}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
`shouldRespondWith`
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
`shouldRespondWith`
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
it "only affects the source table rows if his direct embedding is an inner join" $ do
get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
[json|[
{"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
{"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "works with views" $ do
get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
[json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
[json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
it "works with m2o and m2m relationships combined" $ do
get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
[json| [
{"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
{"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
{"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
{"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/4" ]
}
it "works with rpc" $ do
get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
[json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "works when using hints" $ do
get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
[json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
[json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "works with many one-to-many relationships" $ do
-- https://github.com/PostgREST/postgrest/issues/1977
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`
[json|[
{"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},
{"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},
{"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}
]|]
{ matchHeaders = [matchContentTypeJson] }
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`
[json|[
{"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}
]|]
{ matchHeaders = [matchContentTypeJson] }
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`
[json|[
{"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}
]|]
{ matchHeaders = [matchContentTypeJson] }
request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-2/3" ]
}
@@ -0,0 +1,30 @@
module Feature.Query.HtmlRawOutputSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Text.Heredoc
import Protolude hiding (get)
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types is set to \"text/html\"" $
it "can get raw output with Accept: text/html" $
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html"]
}
+609
View File
@@ -0,0 +1,609 @@
module Feature.Query.InsertSpec where
import Data.List (lookup)
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders))
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai.Matcher (bodyEquals)
import Network.HTTP.Types
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc
import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
pgVersion112, pgVersion130)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do
describe "Posting new record" $ do
context "disparate json types" $ do
it "accepts disparate json types" $ do
post "/menagerie"
[json| {
"integer": 13, "double": 3.14159, "varchar": "testing!"
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
, "enum": "foo"
} |] `shouldRespondWith` ""
{ matchStatus = 201
-- should not have content type set when body is empty
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "filters columns in result using &select" $
request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
[json| [{
"integer": 14, "double": 3.14159, "varchar": "testing!"
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
, "enum": "foo"
}] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeJson]
}
it "ignores &select when return not set or using return=minimal" $ do
request methodPost "/menagerie?select=integer,varchar"
[]
[json| [{
"integer": 15, "double": 3.14159, "varchar": "testing!",
"boolean": false, "date": "1900-01-01", "money": "$3.99",
"enum": "foo"
}] |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
request methodPost "/menagerie?select=integer,varchar"
[("Prefer", "return=minimal")]
[json| [{
"integer": 16, "double": 3.14159, "varchar": "testing!",
"boolean": false, "date": "1900-01-01", "money": "$3.99",
"enum": "foo"
}] |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
context "non uniform json array" $ do
it "rejects json array that isn't exclusivily composed of objects" $
post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
it "rejects json array that has objects with different keys" $
post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "requesting full representation" $ do
it "includes related data after insert" $
request methodPost "/projects?select=id,name,clients(id,name)"
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
[json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson
, "Location" <:> "/projects?id=eq.6"
, "Content-Range" <:> "*/1" ]
}
it "can rename and cast the selected columns" $
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
[("Prefer", "return=representation")]
[json|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
[json|[{"pId":"7","pName":"New Project","cId":"2"}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson
, "Location" <:> "/projects?id=eq.7"
, "Content-Range" <:> "*/*" ]
}
it "should not throw and return location header when selecting without PK" $
request methodPost "/projects?select=name,client_id" [("Prefer", "return=representation")]
[json|{"id":10,"name":"New Project","client_id":2}|] `shouldRespondWith`
[json|[{"name":"New Project","client_id":2}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson
, "Location" <:> "/projects?id=eq.10"
, "Content-Range" <:> "*/*" ]
}
context "requesting headers only representation" $ do
it "should not throw and return location header when selecting without PK" $
request methodPost "/projects?select=name,client_id"
[("Prefer", "return=headers-only")]
[json|{"id":11,"name":"New Project","client_id":2}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/projects?id=eq.11"
, "Content-Range" <:> "*/*" ]
}
when (actualPgVersion >= pgVersion110) $
it "should not throw and return location header for partitioned tables when selecting without PK" $
request methodPost "/car_models"
[("Prefer", "return=headers-only")]
[json|{"name":"Enzo","year":2021}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
, "Content-Range" <:> "*/*" ]
}
context "requesting no representation" $
it "should not throw and return no location header when selecting without PK" $
request methodPost "/projects?select=name,client_id"
[]
[json|{"id":12,"name":"New Project","client_id":2}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ]
}
context "from an html form" $
it "accepts disparate json types" $ do
request methodPost "/menagerie"
[("Content-Type", "application/x-www-form-urlencoded")]
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
"boolean=false&date=1900-01-01&money=$3.99&enum=foo")
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType ]
}
context "with no pk supplied" $ do
context "into a table with auto-incrementing pk" $
it "succeeds with 201 and location header" $ do
-- reset pk sequence first to make test repeatable
request methodPost "/rpc/reset_sequence"
[("Prefer", "tx=commit")]
[json|{"name": "auto_incrementing_pk_id_seq", "value": 2}|]
`shouldRespondWith`
[json|""|]
request methodPost "/auto_incrementing_pk"
[("Prefer", "return=headers-only")]
[json| { "non_nullable_string":"not null"} |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/auto_incrementing_pk?id=eq.2" ]
}
context "into a table with simple pk" $
it "fails with 400 and error" $
post "/simple_pk" [json| { "extra":"foo"} |]
`shouldRespondWith`
(if actualPgVersion >= pgVersion130 then
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" of relation \"simple_pk\" violates not-null constraint"}|]
else
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
)
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "into a table with no pk" $ do
it "succeeds with 201 but no location header" $ do
post "/no_pk"
[json| { "a":"foo", "b":"bar" } |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ]
}
it "returns full details of inserted record if asked" $ do
request methodPost "/no_pk"
[("Prefer", "return=representation")]
[json| { "a":"bar", "b":"baz" } |]
`shouldRespondWith`
[json| [{ "a":"bar", "b":"baz" }] |]
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hLocation]
}
it "returns empty array when no items inserted, and return=rep" $ do
request methodPost "/no_pk"
[("Prefer", "return=representation")]
[json| [] |]
`shouldRespondWith`
[json| [] |]
{ matchStatus = 201 }
it "can post nulls" $ do
request methodPost "/no_pk"
[("Prefer", "return=representation")]
[json| { "a":null, "b":"foo" } |]
`shouldRespondWith`
[json| [{ "a":null, "b":"foo" }] |]
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hLocation]
}
context "with compound pk supplied" $
it "builds response location header appropriately" $ do
request methodPost "/compound_pk"
[("Prefer", "return=representation")]
[json| { "k1":12, "k2":"Rock & R+ll" } |]
`shouldRespondWith`
[json|[ { "k1":12, "k2":"Rock & R+ll", "extra": null } ]|]
{ matchStatus = 201
, matchHeaders = [ "Location" <:> "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll" ]
}
context "with bulk insert" $
it "returns 201 but no location header" $ do
let bulkData = [json| [ {"k1":21, "k2":"hello world"}
, {"k1":22, "k2":"bye for now"}]
|]
request methodPost "/compound_pk"
[]
bulkData
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ]
}
context "with invalid json payload" $
it "fails with 400 and error" $
post "/simple_pk" "}{ x = 2"
`shouldRespondWith`
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with no payload" $
it "fails with 400 and error" $
post "/simple_pk" ""
`shouldRespondWith`
[json|{"message":"Error in $: not enough input"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with valid json payload" $
it "succeeds and returns 201 created" $
post "/simple_pk"
[json| { "k":"k1", "extra":"e1" } |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
context "attempting to insert a row with the same primary key" $
it "fails returning a 409 Conflict" $
post "/simple_pk"
[json| { "k":"xyyx", "extra":"e1" } |]
`shouldRespondWith`
[json|{"hint":null,"details":"Key (k)=(xyyx) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"simple_pk_pkey\""}|]
{ matchStatus = 409 }
context "attempting to insert a row with conflicting unique constraint" $
it "fails returning a 409 Conflict" $
post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
context "jsonb" $ do
it "serializes nested object" $ do
let inserted = [json| { "data": { "foo":"bar" } } |]
request methodPost "/json_table"
[("Prefer", "return=representation")]
inserted
`shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]
{ matchStatus = 201
}
it "serializes nested array" $ do
let inserted = [json| { "data": [1,2,3] } |]
request methodPost "/json_table"
[("Prefer", "return=representation")]
inserted
`shouldRespondWith` [json|[{"data":[1,2,3]}]|]
{ matchStatus = 201
}
context "empty objects" $ do
it "successfully inserts a row with all-default columns" $ do
post "/items"
[json|{}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
post "/items" "[{}]" `shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = []
}
it "successfully inserts two rows with all-default columns" $
post "/items"
[json|[{}, {}]|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "successfully inserts a row with all-default columns with prefer=rep" $ do
-- reset pk sequence first to make test repeatable
request methodPost "/rpc/reset_sequence"
[("Prefer", "tx=commit")]
[json|{"name": "items2_id_seq", "value": 20}|]
`shouldRespondWith`
[json|""|]
request methodPost "/items2"
[("Prefer", "return=representation")]
[json|{}|]
`shouldRespondWith`
[json|[{ id: 20 }]|]
{ matchStatus = 201 }
it "successfully inserts a row with all-default columns with prefer=rep and &select=" $ do
-- reset pk sequence first to make test repeatable
request methodPost "/rpc/reset_sequence"
[("Prefer", "tx=commit")]
[json|{"name": "items3_id_seq", "value": 20}|]
`shouldRespondWith`
[json|""|]
request methodPost "/items3?select=id"
[("Prefer", "return=representation")]
[json|{}|]
`shouldRespondWith` [json|[{ id: 20 }]|]
{ matchStatus = 201 }
context "POST with ?columns parameter" $ do
it "ignores json keys not included in ?columns" $ do
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
[json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
[json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]
{ matchStatus = 201
, matchHeaders = [] }
request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]
[json| [
{"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},
{"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},
{"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`
[json|[
{"id": 201, "body": "yyy"},
{"id": 202, "body": "zzz"},
{"id": 203, "body": "aaa"} ]|]
{ matchStatus = 201
, matchHeaders = [] }
-- TODO parse columns error message needs to be improved
it "disallows blank ?columns" $
post "/articles?columns="
[json|[
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
{ matchStatus = 400
, matchHeaders = []
}
it "disallows array elements that are not json objects" $
post "/articles?columns=id,body"
[json|[
{"id": 204, "body": "yyy"},
333,
"asdf",
{"id": 205, "body": "zzz"}]|] `shouldRespondWith`
[json|{
"code": "22023",
"details": null,
"hint": null,
"message": "argument of json_populate_recordset must be an array of objects"}|]
{ matchStatus = 400
, matchHeaders = []
}
describe "CSV insert" $ do
context "disparate csv types" $
it "succeeds with multipart response" $ do
pendingWith "Decide on what to do with CSV insert"
let inserted = [str|integer,double,varchar,boolean,date,money,enum
|13,3.14159,testing!,false,1900-01-01,$3.99,foo
|12,0.1,a string,true,1929-10-01,12,bar
|]
request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
`shouldRespondWith` ResponseMatcher
{ matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
, matchBody = bodyEquals inserted
}
context "requesting full representation" $ do
it "returns full details of inserted record" $
request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
"a,b\nbar,baz"
`shouldRespondWith` "a,b\nbar,baz"
{ matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
}
it "can post nulls" $
request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
"a,b\nNULL,foo"
`shouldRespondWith` "a,b\n,foo"
{ matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
}
it "only returns the requested column header with its associated data" $
request methodPost "/projects?select=id"
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
`shouldRespondWith` "id\n8\n9"
{ matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
"Content-Range" <:> "*/*"]
}
context "with wrong number of columns" $
it "fails for too few" $
request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
`shouldRespondWith`
[json|{"message":"All lines must have same number of fields"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with unicode values" $
it "succeeds and returns usable location header" $ do
p <- request methodPost "/simple_pk2?select=extra,k"
[("Prefer", "tx=commit"), ("Prefer", "return=representation")]
[json| { "k":"圍棋", "extra":"" } |]
pure p `shouldRespondWith`
[json|[ { "k":"圍棋", "extra":"" } ]|]
{ matchStatus = 201 }
let Just location = lookup hLocation $ simpleHeaders p
get location
`shouldRespondWith`
[json|[ { "k":"圍棋", "extra":"" } ]|]
request methodDelete location
[("Prefer", "tx=commit")]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
describe "Row level permission" $
it "set user_id when inserting rows" $ do
request methodPost "/authors_only"
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]
[json| { "secret": "nyancat" } |]
`shouldRespondWith`
[json|[{"owner":"jdoe","secret":"nyancat"}]|]
{ matchStatus = 201 }
request methodPost "/authors_only"
-- jwt token for jroe
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]
[json| { "secret": "lolcat", "owner": "hacker" } |]
`shouldRespondWith`
[json|[{"owner":"jroe","secret":"lolcat"}]|]
{ matchStatus = 201 }
context "tables with self reference foreign keys" $ do
it "embeds parent after insert" $
request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
[("Prefer", "return=representation")]
[json|{"id":6, "name":"wot", "p_web_id":4}|]
`shouldRespondWith`
[json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
}
context "table with limited privileges" $ do
it "succeeds inserting if correct select is applied" $
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":2,"user_id":1}]|]
{ matchStatus = 201
, matchHeaders = []
}
it "fails inserting if more columns are selected" $
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
it "fails inserting if select is not specified" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
it "can insert in a table with no select and return=minimal" $ do
request methodPost "/insertonly"
[("Prefer", "return=minimal")]
[json| { "v":"some value" } |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType]
}
describe "Inserting into VIEWs" $ do
context "requesting no representation" $
it "succeeds with 201" $
post "/compound_pk_view"
[json|{"k1":1,"k2":"test","extra":2}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ]
}
context "requesting header only representation" $ do
it "returns a location header" $
request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
[json|{"k1":1,"k2":"test","extra":2}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
, "Content-Range" <:> "*/*" ]
}
it "should not throw and return location header when a PK is null" $
request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
[json|{"id":1}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
, "Content-Range" <:> "*/*" ]
}
+253
View File
@@ -0,0 +1,253 @@
module Feature.Query.JsonOperatorSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Config.PgVersion (PgVersion, pgVersion112,
pgVersion121)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield one level with casting" $
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer"
`shouldRespondWith` (
if actualPgVersion >= pgVersion121 then
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
else
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
)
{ matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
[json| [{"bar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
[json| [{"myBar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "obtains a json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
get "/json_arr?select=data->>c->1"
`shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
else
[json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
)
{ matchStatus = 404 , matchHeaders = [] }
get "/json_arr?select=data->>c->b"
`shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
else
[json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
)
{ matchStatus = 404 , matchHeaders = [] }
context "with array index" $ do
it "can get array of ints and alias/cast it" $ do
get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":1}, {"data":4}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get nested array of ints" $ do
get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":null}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array of objects" $ do
get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":null}, {"a":"3"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array in object keys" $ do
get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
[json| [{"d":null}, {"d":8}] |]
{ matchHeaders = [matchContentTypeJson] }
it "only treats well formed numbers as indexes" $
get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
[json| [{"xy-6":3}] |]
{ matchHeaders = [matchContentTypeJson] }
context "finishing json path with single arrow ->" $ do
it "works when finishing with a key" $ do
get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works when finishing with an index" $ do
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
[json| [{"d":[4,5,6,7,8]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "filtering response" $ do
it "can filter by properties inside json column" $ do
get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using not" $
get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using ->>" $
get "/json_table?data->>id=eq.1" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can be filtered with and/or" $
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "can filter by array indexes" $ do
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter jsonb" $ do
get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
[json| [{"id":4,"data":{"e": 1}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
[json| [{"id":1,"data":{"a": {"b": 2}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
[json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
[json| [{"id":3,"data":[{"d": "test"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "ordering response" $ do
it "orders by a json column property asc" $
get "/json_table?order=data->>id.asc" `shouldRespondWith`
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "orders by a json column with two level property nulls first" $
get "/json_table?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
context "Patching record, in a nonempty table" $
it "can set a json column to escaped value" $ do
request methodPatch "/json_table?data->>id=eq.3"
[("Prefer", "return=representation")]
[json| { "data": { "id":" \"escaped" } } |]
`shouldRespondWith`
[json| [{ "data": { "id":" \"escaped" } }] |]
context "json array negative index" $ do
it "can select with negative indexes" $ do
get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":3}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter with negative indexes" $ do
get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "should fail on badly formed negatives" $ do
get "/json_arr?select=data->>-78xy" `shouldRespondWith`
[json|
{"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
"message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>--34" `shouldRespondWith`
[json|
{"details": "unexpected \"-\" expecting digit",
"message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
[json|
{"details":"unexpected \"x\" expecting digit",
"message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
@@ -0,0 +1,331 @@
module Feature.Query.MultipleSchemaSpec where
import Control.Lens ((^?))
import Data.Aeson.Lens
import Data.Aeson.QQ
import Network.HTTP.Types
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "multiple schemas in single instance" $ do
context "Reading tables on different schemas" $ do
it "succeeds in reading table from default schema v1 if no schema is selected via header" $
request methodGet "/parents" [] "" `shouldRespondWith`
[json|[
{"id":1,"name":"parent v1-1"},
{"id":2,"name":"parent v1-2"}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
}
it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
[json|[
{"id":1,"name":"parent v1-1"},
{"id":2,"name":"parent v1-2"}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
}
it "succeeds in reading table from schema v2" $
request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
[json|[
{"id":3,"name":"parent v2-3"},
{"id":4,"name":"parent v2-4"}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
it "succeeds in reading another_table from schema v2" $
request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
[json|[
{"id":5,"another_value":"value 5"},
{"id":6,"another_value":"value 6"}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
it "doesn't find another_table in schema v1" $
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
it "fails trying to read table from unkown schema" $
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2"}|]
{
matchStatus = 406
}
context "Inserting tables on different schemas" $ do
it "succeeds inserting on default schema and returning it" $
request methodPost "/children"
[("Prefer", "return=representation")]
[json|{"id": 0, "name": "child v1-1", "parent_id": 1}|]
`shouldRespondWith`
[json|[{"id": 0, "name": "child v1-1", "parent_id": 1}]|]
{
matchStatus = 201
, matchHeaders = ["Content-Profile" <:> "v1"]
}
it "succeeds inserting on the v1 schema and returning its parent" $
request methodPost "/children?select=id,parent(*)"
[("Prefer", "return=representation"), ("Content-Profile", "v1")]
[json|{"id": 0, "name": "child v1-2", "parent_id": 2}|]
`shouldRespondWith`
[json|[{"id": 0, "parent": {"id": 2, "name": "parent v1-2"}}]|]
{
matchStatus = 201
, matchHeaders = ["Content-Profile" <:> "v1"]
}
it "succeeds inserting on the v2 schema and returning its parent" $
request methodPost "/children?select=id,parent(*)"
[("Prefer", "return=representation"), ("Content-Profile", "v2")]
[json|{"id": 0, "name": "child v2-3", "parent_id": 3}|]
`shouldRespondWith`
[json|[{"id": 0, "parent": {"id": 3, "name": "parent v2-3"}}]|]
{
matchStatus = 201
, matchHeaders = ["Content-Profile" <:> "v2"]
}
it "fails when inserting on an unknown schema" $
request methodPost "/children" [("Content-Profile", "unknown")]
[json|{"name": "child 4", "parent_id": 4}|]
`shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2"}|]
{
matchStatus = 406
}
context "calling procs on different schemas" $ do
it "succeeds in calling the default schema proc" $
request methodGet "/rpc/get_parents_below?id=6" [] ""
`shouldRespondWith`
[json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
}
it "succeeds in calling the v1 schema proc and embedding" $
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""
`shouldRespondWith`
[json| [
{"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},
{"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
}
it "succeeds in calling the v2 schema proc and embedding" $
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""
`shouldRespondWith`
[json| [
{"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},
{"id":4,"name":"parent v2-4","children":[]}] |]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $
request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]
[json|{"id": "6"}|]
`shouldRespondWith`
[json| [
{"id":3,"name":"parent v2-3"},
{"id":4,"name":"parent v2-4"}]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
context "Modifying tables on different schemas" $ do
it "succeeds in patching on the v1 schema and returning its parent" $
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
[json|{"name": "child v1-1 updated"}|]
`shouldRespondWith`
[json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
}
it "succeeds in patching on the v2 schema and returning its parent" $
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
[json|{"name": "child v2-1 updated"}|]
`shouldRespondWith`
[json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
it "succeeds on deleting on the v2 schema" $ do
request methodDelete "/children?id=eq.1"
[("Content-Profile", "v2"), ("Prefer", "return=representation")]
""
`shouldRespondWith`
[json|[{"id": 1, "name": "child v2-3", "parent_id": 3}]|]
{ matchHeaders = ["Content-Profile" <:> "v2"] }
it "succeeds on PUT on the v2 schema" $
request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
`shouldRespondWith`
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
context "OpenAPI output" $ do
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
req <- request methodGet "/" [] ""
liftIO $ do
simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
let def = simpleBody req ^? key "definitions" . key "parents"
def `shouldBe` Just
[aesonQQ|
{
"type" : "object",
"properties" : {
"id" : {
"description" : "Note:\nThis is a Primary Key.<pk/>",
"format" : "integer",
"type" : "integer"
},
"name" : {
"format" : "text",
"type" : "string"
}
},
"required" : [
"id"
]
}
|]
it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
liftIO $ do
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
let def = simpleBody r ^? key "definitions" . key "parents"
def `shouldBe` Just
[aesonQQ|
{
"type" : "object",
"properties" : {
"id" : {
"description" : "Note:\nThis is a Primary Key.<pk/>",
"format" : "integer",
"type" : "integer"
},
"name" : {
"format" : "text",
"type" : "string"
}
},
"required" : [
"id"
]
}
|]
it "succeeds in reading table definition from schema v2" $ do
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
liftIO $ do
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
let def = simpleBody r ^? key "definitions" . key "parents"
def `shouldBe` Just
[aesonQQ|
{
"type" : "object",
"properties" : {
"id" : {
"description" : "Note:\nThis is a Primary Key.<pk/>",
"format" : "integer",
"type" : "integer"
},
"name" : {
"format" : "text",
"type" : "string"
}
},
"required" : [
"id"
]
}
|]
it "succeeds in reading another_table definition from schema v2" $ do
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
liftIO $ do
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
let def = simpleBody r ^? key "definitions" . key "another_table"
def `shouldBe` Just
[aesonQQ|
{
"type" : "object",
"properties" : {
"id" : {
"description" : "Note:\nThis is a Primary Key.<pk/>",
"format" : "integer",
"type" : "integer"
},
"another_value" : {
"format" : "text",
"type" : "string"
}
},
"required" : [
"id"
]
}
|]
it "doesn't find another_table definition in schema v1" $ do
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
liftIO $ do
let def = simpleBody r ^? key "definitions" . key "another_table"
def `shouldBe` Nothing
it "fails trying to read definitions from unkown schema" $
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2"}|]
{
matchStatus = 406
}
@@ -0,0 +1,17 @@
module Feature.Query.NonexistentSchemaSpec where
import Network.Wai (Application)
import Test.Hspec
import Test.Hspec.Wai
import Protolude hiding (get)
spec :: SpecWith ((), Application)
spec =
describe "Non existent api schema" $ do
it "succeeds when requesting root path" $
get "/" `shouldRespondWith` 200
it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
get "/nonexistent_table" `shouldRespondWith` 404
@@ -0,0 +1,79 @@
module Feature.Query.QueryLimitedSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Requesting many items with server limits(max-rows) enabled" $ do
it "restricts results" $
get "/items?order=id"
`shouldRespondWith`
[json| [{"id":1},{"id":2}] |]
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
it "respects additional client limiting" $ do
request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 0 0)
""
`shouldRespondWith`
[json| [{"id":1}] |]
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
it "works on all levels" $
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
`shouldRespondWith`
[json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
it "succeeds in getting parent embeds despite the limit, see #647" $
get "/tasks?select=id,project:projects(id)&id=gt.5"
`shouldRespondWith`
[json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
it "can offset the parent embed, being consistent with the other embed types" $
get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
`shouldRespondWith`
[json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
context "count=estimated" $ do
it "uses the query planner guess when query rows > maxRows" $
request methodHead "/getallprojects_view"
[("Prefer", "count=estimated")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2019" ]
}
it "gives exact count when query rows <= maxRows" $
request methodHead "/getallprojects_view?id=lt.3"
[("Prefer", "count=estimated")]
""
`shouldRespondWith`
""
{ matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "only uses the query planner guess if it's indeed greater than the exact count" $
request methodHead "/get_projects_above_view"
[("Prefer", "count=estimated")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/3" ]
}
File diff suppressed because it is too large Load Diff
+362
View File
@@ -0,0 +1,362 @@
module Feature.Query.RangeSpec where
import qualified Data.ByteString.Lazy as BL
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
defaultRange :: BL.ByteString
defaultRange = [json| { "min": 0, "max": 15 } |]
emptyRange :: BL.ByteString
emptyRange = [json| { "min": 2, "max": 2 } |]
spec :: SpecWith ((), Application)
spec = do
describe "POST /rpc/getitemrange" $ do
context "without range headers" $ do
context "with response under server size limit" $
it "returns whole range with status 200" $
post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
context "when I don't want the count" $ do
it "returns range Content-Range with */* for empty range" $
request methodPost "/rpc/getitemrange" [] emptyRange
`shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
it "returns range Content-Range with range/*" $
post "/rpc/getitemrange?order=id"
defaultRange
`shouldRespondWith`
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
context "with range headers" $ do
context "of acceptable range" $ do
it "succeeds with partial content" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-1/*"
simpleStatus r `shouldBe` ok200
it "understands open-ended ranges" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFrom 0) defaultRange
`shouldRespondWith` 200
it "returns an empty body when there are no results" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
`shouldRespondWith` "[]"
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "allows one-item requests" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-0/*"
simpleStatus r `shouldBe` ok200
it "handles ranges beyond collection length via truncation" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "10-14/*"
simpleStatus r `shouldBe` ok200
context "of invalid range" $ do
it "fails with 416 for offside range" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
`shouldRespondWith` 416
it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
`shouldRespondWith` "[]"
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
`shouldRespondWith` "[]"
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
describe "GET /items" $ do
context "without range headers" $ do
context "with response under server size limit" $
it "returns whole range with status 200" $
get "/items" `shouldRespondWith` 200
context "when I don't want the count" $ do
it "returns range Content-Range with /*" $
request methodGet "/menagerie"
[("Prefer", "count=none")] ""
`shouldRespondWith`
[json|[]|]
{ matchHeaders = ["Content-Range" <:> "*/*"] }
it "returns range Content-Range with range/*" $
request methodGet "/items?order=id"
[("Prefer", "count=none")] ""
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
it "returns range Content-Range with range/* even using other filters" $
request methodGet "/items?id=eq.1&order=id"
[("Prefer", "count=none")] ""
`shouldRespondWith` [json| [{"id":1}] |]
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
context "with limit/offset parameters" $ do
it "no parameters return everything" $
get "/items?select=id&order=id.asc"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-14/*"]
}
it "top level limit with parameter" $
get "/items?select=id&order=id.asc&limit=3"
`shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-2/*"]
}
it "headers override get parameters" $
request methodGet "/items?select=id&order=id.asc&limit=3"
(rangeHdrs $ ByteRangeFromTo 0 1) ""
`shouldRespondWith` [json|[{"id":1},{"id":2}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"]
}
it "limit works on all levels" $
get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
`shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/*"]
}
it "limit and offset works on first level" $ do
get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "2-4/*"]
}
request methodHead "/items?select=id&order=id.asc&limit=3&offset=2"
[]
mempty
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "2-4/*" ]
}
it "succeeds if offset equals 0 as a no-op" $
get "/items?select=id&offset=0&order=id"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
it "succeeds if offset is negative as a no-op" $
get "/items?select=id&offset=-4&order=id"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
it "fails if limit equals 0" $
get "/items?select=id&limit=0"
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
{ matchStatus = 416
, matchHeaders = [matchContentTypeJson]
}
it "fails if limit is negative" $
get "/items?select=id&limit=-1"
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
{ matchStatus = 416
, matchHeaders = [matchContentTypeJson]
}
context "when count=planned" $ do
it "obtains a filtered range" $ do
request methodGet "/items?select=id&id=gt.8"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
[json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-6/8"]
}
request methodGet "/child_entities?select=id&id=gt.3"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
[json|[{"id":4}, {"id":5}, {"id":6}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-2/4"]
}
request methodGet "/getallprojects_view?select=id&id=lt.3"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
[json|[{"id":1}, {"id":2}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/673"]
}
it "obtains the full range" $ do
request methodHead "/items"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-14/15" ]
}
request methodHead "/child_entities"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-5/6" ]
}
request methodHead "/getallprojects_view"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-4/2019" ]
}
it "ignores limit/offset on the planned count" $ do
request methodHead "/items?limit=2&offset=3"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "3-4/15" ]
}
request methodHead "/child_entities?limit=2"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/6" ]
}
request methodHead "/getallprojects_view?limit=2"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2019" ]
}
it "works with two levels" $
request methodHead "/child_entities?select=*,entities(*)"
[("Prefer", "count=planned")]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-5/6" ]
}
context "with range headers" $ do
context "of acceptable range" $ do
it "succeeds with partial content" $ do
r <- request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 0 1) ""
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-1/*"
simpleStatus r `shouldBe` ok200
it "understands open-ended ranges" $
request methodGet "/items"
(rangeHdrs $ ByteRangeFrom 0) ""
`shouldRespondWith` 200
it "returns an empty body when there are no results" $
request methodGet "/menagerie"
(rangeHdrs $ ByteRangeFromTo 0 1) ""
`shouldRespondWith` "[]"
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "allows one-item requests" $ do
r <- request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 0 0) ""
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-0/*"
simpleStatus r `shouldBe` ok200
it "handles ranges beyond collection length via truncation" $ do
r <- request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 10 100) ""
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "10-14/*"
simpleStatus r `shouldBe` ok200
context "of invalid range" $ do
it "fails with 416 for offside range" $
request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 1 0) ""
`shouldRespondWith` 416
it "refuses a range with nonzero start when there are no items" $
request methodGet "/menagerie"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
`shouldRespondWith` "[]"
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodGet "/items"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
`shouldRespondWith` "[]"
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
@@ -0,0 +1,33 @@
module Feature.Query.RawOutputTypesSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types config variable is missing or left empty" $ do
let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
it "responds json to a GET request with Firefox Accept headers" $
request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
`shouldRespondWith` [json| [{"id":1}] |]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request with Chrome Accept headers" $
request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
`shouldRespondWith` [json| [{"id":1}] |]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request to RPC with Firefox Accept headers" $
request methodGet "/rpc/get_projects_below?id=3" firefoxAcceptHdrs ""
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request to RPC with Chrome Accept headers" $
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
module Feature.Query.SingularSpec where
import Network.Wai (Application)
import Network.Wai.Test (SResponse (..))
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Requesting singular json object" $ do
let singular = ("Accept", "application/vnd.pgrst.object+json")
context "with GET request" $ do
it "fails for zero rows" $
request methodGet "/items?id=gt.0&id=lt.0" [singular] ""
`shouldRespondWith` 406
it "will select an existing object" $ do
request methodGet "/items?id=eq.5" [singular] ""
`shouldRespondWith`
[json|{"id":5}|]
{ matchHeaders = [matchContentTypeSingular] }
-- also test without the +json suffix
request methodGet "/items?id=eq.5"
[("Accept", "application/vnd.pgrst.object")] ""
`shouldRespondWith`
[json|{"id":5}|]
{ matchHeaders = [matchContentTypeSingular] }
it "can combine multiple prefer values" $
request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
`shouldRespondWith`
[json|{"id":5}|]
{ matchHeaders = [matchContentTypeSingular] }
it "can shape plurality singular object routes" $
request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
`shouldRespondWith`
[json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
{ matchHeaders = [matchContentTypeSingular] }
context "when updating rows" $ do
it "works for one row with return=rep" $ do
request methodPatch "/addresses?id=eq.1"
[("Prefer", "return=representation"), singular]
[json| { address: "B Street" } |]
`shouldRespondWith`
[json|{"id":1,"address":"B Street"}|]
{ matchHeaders = [matchContentTypeSingular] }
it "works for one row with return=minimal" $
request methodPatch "/addresses?id=eq.1"
[("Prefer", "return=minimal"), singular]
[json| { address: "C Street" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "raises an error for multiple rows" $ do
request methodPatch "/addresses"
[("Prefer", "tx=commit"), singular]
[json| { address: "zzz" } |]
`shouldRespondWith`
[json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should not be updated, either
get "/addresses?id=eq.1"
`shouldRespondWith`
[json|[{"id":1,"address":"address 1"}]|]
it "raises an error for multiple rows with return=rep" $ do
request methodPatch "/addresses"
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
[json| { address: "zzz" } |]
`shouldRespondWith`
[json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should not be updated, either
get "/addresses?id=eq.1"
`shouldRespondWith`
[json|[{"id":1,"address":"address 1"}]|]
it "raises an error for zero rows" $
request methodPatch "/items?id=gt.0&id=lt.0"
[singular] [json|{"id":1}|]
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
it "raises an error for zero rows with return=rep" $
request methodPatch "/items?id=gt.0&id=lt.0"
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
context "when creating rows" $ do
it "works for one row with return=rep" $ do
request methodPost "/addresses"
[("Prefer", "return=representation"), singular]
[json| [ { id: 102, address: "xxx" } ] |]
`shouldRespondWith`
[json|{"id":102,"address":"xxx"}|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeSingular]
}
it "works for one row with return=minimal" $ do
request methodPost "/addresses"
[("Prefer", "return=minimal"), singular]
[json| [ { id: 103, address: "xxx" } ] |]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
it "raises an error when attempting to create multiple entities" $ do
request methodPost "/addresses"
[("Prefer", "tx=commit"), singular]
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
`shouldRespondWith`
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should not exist, either
get "/addresses?id=eq.200"
`shouldRespondWith`
"[]"
it "raises an error when attempting to create multiple entities with return=rep" $ do
request methodPost "/addresses"
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
[json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
`shouldRespondWith`
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should not exist, either
get "/addresses?id=eq.202"
`shouldRespondWith`
"[]"
it "raises an error regardless of return=minimal" $ do
request methodPost "/addresses"
[("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]
[json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
`shouldRespondWith`
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should not exist, either
get "/addresses?id=eq.204"
`shouldRespondWith`
"[]"
it "raises an error when creating zero entities" $
request methodPost "/addresses"
[singular]
[json| [ ] |]
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
it "raises an error when creating zero entities with return=rep" $
request methodPost "/addresses"
[("Prefer", "return=representation"), singular]
[json| [ ] |]
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
context "when deleting rows" $ do
it "works for one row with return=rep" $ do
p <- request methodDelete
"/items?id=eq.11"
[("Prefer", "return=representation"), singular] ""
liftIO $ simpleBody p `shouldBe` [json|{"id":11}|]
it "works for one row with return=minimal" $ do
p <- request methodDelete
"/items?id=eq.12"
[("Prefer", "return=minimal"), singular] ""
liftIO $ simpleBody p `shouldBe` ""
it "raises an error when attempting to delete multiple entities" $ do
request methodDelete "/items?id=gt.0&id=lt.6"
[("Prefer", "tx=commit"), singular]
""
`shouldRespondWith`
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should still exist
get "/items?id=gt.0&id=lt.6&order=id"
`shouldRespondWith`
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-4/*"]
}
it "raises an error when attempting to delete multiple entities with return=rep" $ do
request methodDelete "/items?id=gt.5&id=lt.11"
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular] ""
`shouldRespondWith`
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- the rows should still exist
get "/items?id=gt.5&id=lt.11"
`shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-4/*"]
}
it "raises an error when deleting zero entities" $
request methodDelete "/items?id=lt.0"
[singular] ""
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
it "raises an error when deleting zero entities with return=rep" $
request methodDelete "/items?id=lt.0"
[("Prefer", "return=representation"), singular] ""
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
context "when calling a stored proc" $ do
it "fails for zero rows" $
request methodPost "/rpc/getproject"
[singular] [json|{ "id": 9999999}|]
`shouldRespondWith`
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
-- this one may be controversial, should vnd.pgrst.object include
-- the likes of 2 and "hello?"
it "succeeds for scalar result" $
request methodPost "/rpc/sayhello"
[singular] [json|{ "name": "world"}|]
`shouldRespondWith` 200
it "returns a single object for json proc" $
request methodPost "/rpc/getproject"
[singular] [json|{ "id": 1}|]
`shouldRespondWith`
[json|{"id":1,"name":"Windows 7","client_id":1}|]
{ matchHeaders = [matchContentTypeSingular] }
it "fails for multiple rows" $
request methodPost "/rpc/getallprojects"
[singular] "{}"
`shouldRespondWith`
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [matchContentTypeSingular]
}
it "fails for multiple rows with rolled back changes" $ do
post "/rpc/getproject?select=id,name"
[json| {"id": 1} |]
`shouldRespondWith`
[json|[{"id":1,"name":"Windows 7"}]|]
request methodPost "/rpc/setprojects"
[("Prefer", "tx=commit"), singular]
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
`shouldRespondWith`
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = [ matchContentTypeSingular
, "Preference-Applied" <:> "tx=commit" ]
}
-- should rollback function
post "/rpc/getproject?select=id,name"
[json| {"id": 1} |]
`shouldRespondWith`
[json|[{"id":1,"name":"Windows 7"}]|]
+38
View File
@@ -0,0 +1,38 @@
module Feature.Query.UnicodeSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Reading and writing to unicode schema and table names" $
it "Can read and write values" $ do
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
`shouldRespondWith` "[]"
request methodPost "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
[("Prefer", "tx=commit"), ("Prefer", "return=representation")]
[json| { "هویت": 1 } |]
`shouldRespondWith`
[json| [{ "هویت": 1 }] |]
{ matchStatus = 201 }
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
`shouldRespondWith`
[json| [{ "هویت": 1 }] |]
request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
[("Prefer", "tx=commit")]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
+388
View File
@@ -0,0 +1,388 @@
module Feature.Query.UpdateSpec where
import Network.Wai (Application)
import Test.Hspec hiding (pendingWith)
import Network.HTTP.Types
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec = do
describe "Patching record" $ do
context "to unknown uri" $
it "indicates no table found by returning 404" $
request methodPatch "/fake" []
[json| { "real": false } |]
`shouldRespondWith` 404
context "on an empty table" $
it "indicates no records found to update by returning 404" $
request methodPatch "/empty_table" []
[json| { "extra":20 } |]
`shouldRespondWith`
""
{ matchStatus = 404,
matchHeaders = [matchHeaderAbsent hContentType]
}
context "with invalid json payload" $
it "fails with 400 and error" $
request methodPatch "/simple_pk" [] "}{ x = 2"
`shouldRespondWith`
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
{ matchStatus = 400,
matchHeaders = [matchContentTypeJson]
}
context "with no payload" $
it "fails with 400 and error" $
request methodPatch "/items" [] ""
`shouldRespondWith`
[json|{"message":"Error in $: not enough input"}|]
{ matchStatus = 400,
matchHeaders = [matchContentTypeJson]
}
context "in a nonempty table" $ do
it "can update a single item" $ do
patch "/items?id=eq.2"
[json| { "id":42 } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-0/*" ]
}
it "returns empty array when no rows updated and return=rep" $
request methodPatch "/items?id=eq.999999"
[("Prefer", "return=representation")] [json| { "id":999999 } |]
`shouldRespondWith` "[]"
{
matchStatus = 404,
matchHeaders = []
}
it "gives a 404 when no rows updated" $
request methodPatch "/items?id=eq.99999999" []
[json| { "id": 42 } |]
`shouldRespondWith` 404
it "returns updated object as array when return=rep" $
request methodPatch "/items?id=eq.2"
[("Prefer", "return=representation")] [json| { "id":2 } |]
`shouldRespondWith` [json|[{"id":2}]|]
{ matchStatus = 200,
matchHeaders = ["Content-Range" <:> "0-0/*"]
}
it "can update multiple items" $ do
get "/no_pk?select=a&b=eq.1"
`shouldRespondWith`
[json|[]|]
request methodPatch "/no_pk?b=eq.0"
[("Prefer", "tx=commit")]
[json| { b: "1" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-1/*"
, "Preference-Applied" <:> "tx=commit" ]
}
-- check it really got updated
get "/no_pk?select=a&b=eq.1"
`shouldRespondWith`
[json|[ { a: "1" }, { a: "2" } ]|]
-- put value back for other tests
request methodPatch "/no_pk?b=eq.1"
[("Prefer", "tx=commit")]
[json| { b: "0" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "can set a column to NULL" $ do
request methodPatch "/no_pk?a=eq.1"
[("Prefer", "return=representation")]
[json| { b: null } |]
`shouldRespondWith`
[json| [{ a: "1", b: null }] |]
context "filtering by a computed column" $ do
it "is successful" $
request methodPatch
"/items?is_first=eq.true"
[("Prefer", "return=representation")]
[json| { id: 100 } |]
`shouldRespondWith` [json| [{ id: 100 }] |]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
}
it "indicates no records updated by returning 404" $
request methodPatch
"/items?always_true=eq.false"
[("Prefer", "return=representation")]
[json| { id: 100 } |]
`shouldRespondWith` "[]"
{ matchStatus = 404,
matchHeaders = []
}
context "with representation requested" $ do
it "can provide a representation" $ do
_ <- post "/items"
[json| { id: 1 } |]
request methodPatch
"/items?id=eq.1"
[("Prefer", "return=representation")]
[json| { id: 99 } |]
`shouldRespondWith` [json| [{id:99}] |]
{ matchHeaders = [matchContentTypeJson] }
-- put value back for other tests
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
it "can return computed columns" $
request methodPatch
"/items?id=eq.1&select=id,always_true"
[("Prefer", "return=representation")]
[json| { id: 1 } |]
`shouldRespondWith` [json| [{ id: 1, always_true: true }] |]
{ matchHeaders = [matchContentTypeJson] }
it "can select overloaded computed columns" $ do
request methodPatch
"/items?id=eq.1&select=id,computed_overload"
[("Prefer", "return=representation")]
[json| { id: 1 } |]
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
{ matchHeaders = [matchContentTypeJson] }
request methodPatch
"/items2?id=eq.1&select=id,computed_overload"
[("Prefer", "return=representation")]
[json| { id: 1 } |]
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
{ matchHeaders = [matchContentTypeJson] }
it "ignores ?select= when return not set or return=minimal" $ do
request methodPatch "/items?id=eq.1&select=id"
[] [json| { id:1 } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-0/*" ]
}
request methodPatch "/items?id=eq.1&select=id"
[("Prefer", "return=minimal")]
[json| { id:1 } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-0/*" ]
}
context "when patching with an empty body" $ do
it "makes no updates and returns 204 without return= and without ?select=" $ do
request methodPatch "/items"
[]
[json| {} |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
request methodPatch "/items"
[]
[json| [] |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
request methodPatch "/items"
[]
[json| [{}] |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
it "makes no updates and returns 204 without return= and with ?select=" $ do
request methodPatch "/items?select=id"
[]
[json| {} |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
request methodPatch "/items?select=id"
[]
[json| [] |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
request methodPatch "/items?select=id"
[]
[json| [{}] |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "*/*" ]
}
it "makes no updates and returns 200 with return=rep and without ?select=" $
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]"
{
matchStatus = 200,
matchHeaders = ["Content-Range" <:> "*/*"]
}
it "makes no updates and returns 200 with return=rep and with ?select=" $
request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]"
{
matchStatus = 200,
matchHeaders = ["Content-Range" <:> "*/*"]
}
it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $
request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]"
{
matchStatus = 200,
matchHeaders = ["Content-Range" <:> "*/*"]
}
context "with unicode values" $
it "succeeds and returns values intact" $ do
request methodPatch "/no_pk?a=eq.1"
[("Prefer", "return=representation")]
[json| { "a":"圍棋", "b":"" } |]
`shouldRespondWith`
[json|[ { "a":"圍棋", "b":"" } ]|]
context "PATCH with ?columns parameter" $ do
it "ignores json keys not included in ?columns" $ do
request methodPatch "/articles?id=eq.1&columns=body"
[("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |]
`shouldRespondWith`
[json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
it "ignores json keys and gives 404 if no record updated" $
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
context "tables with self reference foreign keys" $ do
it "embeds children after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched"}|]
`shouldRespondWith`
[json|
[ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds parent, children and grandchildren after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched-2"}|]
`shouldRespondWith`
[json| [
{
"id": 0,
"name": "tardis-patched-2",
"parent_content": { "name": "wat" },
"web_content": [
{ "name": "fezz", "web_content": [ { "name": "wut" } ] },
{ "name": "foo", "web_content": [] },
{ "name": "bar", "web_content": [] }
]
}
] |]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds children after update without explicitly including the id in the ?select" $
request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched"}|]
`shouldRespondWith`
[json|
[ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds an M2M relationship plus parent after update" $
request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
[("Prefer", "return=representation")]
[json|{"name": "Kevin Malone"}|]
`shouldRespondWith`
[json|[
{
"name": "Kevin Malone",
"tasks": [
{ "name": "Design w7", "project": { "name": "Windows 7" } },
{ "name": "Code w7", "project": { "name": "Windows 7" } },
{ "name": "Design w10", "project": { "name": "Windows 10" } },
{ "name": "Code w10", "project": { "name": "Windows 10" } }
]
}
]|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
context "table with limited privileges" $ do
it "succeeds updating row and gives a 204 when using return=minimal" $
request methodPatch "/app_users?id=eq.1"
[("Prefer", "return=minimal")]
[json| { "password": "passxyz" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
it "can update without return=minimal and no explicit select" $
request methodPatch "/app_users?id=eq.1"
[]
[json| { "password": "passabc" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
+433
View File
@@ -0,0 +1,433 @@
module Feature.Query.UpsertSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Config.PgVersion (PgVersion, pgVersion110)
import Protolude hiding (get, put)
import SpecHelper
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion =
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]
}
when (actualPgVersion >= pgVersion110) $
it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $
request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "name": "Murcielago", "year": 2001, "car_brand_name": null},
{ "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
]|] `shouldRespondWith` [json| [
{ "name": "Murcielago", "year": 2001, "car_brand_name": null},
{ "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
]|]
{ 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]
}
when (actualPgVersion >= pgVersion110) $
it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $
request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json| [
{ "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },
{ "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
]|] `shouldRespondWith` [json| [
{ "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
]|]
{ 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": 2, "value": "C" },
{ "unique_key": 3, "value": "D" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
}
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": 2, "value": "C" },
{ "key1": 1, "key2": 3, "value": "D" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
}
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")]
[json| [ { "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"
[json| [ { "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"
[json| [ { "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"
[json| [ { "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"
[json| [ { "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)"
[json| [ { "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)"
[json| [ { "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"
[json| [ { "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"
[json| [ { "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" [json| [ { "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"
[json| [ { "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" [json| [ { "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
-- assert that the next request will indeed be an insert
get "/tiobe_pls?name=eq.Go"
`shouldRespondWith`
[json|[]|]
request methodPut "/tiobe_pls?name=eq.Go"
[("Prefer", "return=representation")]
[json| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json| [ { "name": "Go", "rank": 19 } ]|]
it "succeeds on table with composite pk" $ do
-- assert that the next request will indeed be an insert
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json|[]|]
request methodPut "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[("Prefer", "return=representation")]
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
when (actualPgVersion >= pgVersion110) $
it "succeeds on a partitioned table with composite pk" $ do
-- assert that the next request will indeed be an insert
get "/car_models?name=eq.Supra&year=eq.2021"
`shouldRespondWith`
[json|[]|]
request methodPut "/car_models?name=eq.Supra&year=eq.2021"
[("Prefer", "return=representation")]
[json| [ { "name": "Supra", "year": 2021 } ]|]
`shouldRespondWith`
[json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
it "succeeds if the table has only PK cols and no other cols" $ do
-- assert that the next request will indeed be an insert
get "/only_pk?id=eq.10"
`shouldRespondWith`
[json|[]|]
request methodPut "/only_pk?id=eq.10"
[("Prefer", "return=representation")]
[json|[ { "id": 10 } ]|]
`shouldRespondWith`
[json|[ { "id": 10 } ]|]
context "Updating row" $ do
it "succeeds on table with single pk col" $ do
-- assert that the next request will indeed be an update
get "/tiobe_pls?name=eq.Java"
`shouldRespondWith`
[json|[ { "name": "Java", "rank": 1 } ]|]
request methodPut "/tiobe_pls?name=eq.Java"
[("Prefer", "return=representation")]
[json| [ { "name": "Java", "rank": 13 } ]|]
`shouldRespondWith`
[json| [ { "name": "Java", "rank": 13 } ]|]
-- TODO: move this to SingularSpec?
it "succeeds if the payload has more than one row, but it only puts the first element" $ do
-- assert that the next request will indeed be an update
get "/tiobe_pls?name=eq.Java"
`shouldRespondWith`
[json|[ { "name": "Java", "rank": 1 } ]|]
request methodPut "/tiobe_pls?name=eq.Java"
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
[json| [ { "name": "Java", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]
`shouldRespondWith`
[json|{ "name": "Java", "rank": 19 }|]
{ matchHeaders = [matchContentTypeSingular] }
it "succeeds on table with composite pk" $ do
-- assert that the next request will indeed be an update
get "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
`shouldRespondWith`
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$24,000.00", "company": "One-Up Realty", "occupation": "Author" } ]|]
request methodPut "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
[("Prefer", "return=representation")]
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
when (actualPgVersion >= pgVersion110) $
it "succeeds on a partitioned table with composite pk" $ do
-- assert that the next request will indeed be an update
get "/car_models?name=eq.DeLorean&year=eq.1981"
`shouldRespondWith`
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]
request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"
[("Prefer", "return=representation")]
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
`shouldRespondWith`
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
it "succeeds if the table has only PK cols and no other cols" $ do
-- assert that the next request will indeed be an update
get "/only_pk?id=eq.1"
`shouldRespondWith`
[json|[ { "id": 1 } ]|]
request methodPut "/only_pk?id=eq.1"
[("Prefer", "return=representation")]
[json|[ { "id": 1 } ]|]
`shouldRespondWith`
[json|[ { "id": 1 } ]|]
-- TODO: move this to SingularSpec?
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")]
[json| [ { "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" $ 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"]
}
it "works with POST and ignore-duplicates headers" $ do
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|[
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
}
it "works with PUT" $ do
put "/UnitTest?idUnitTest=eq.1"
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [matchHeaderAbsent hContentType]
}
get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]