test: Split spec tests into multiple subfolders
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
This commit is contained in:
committed by
Wolfgang Walther
parent
8a722e2cfe
commit
927ff6f1e5
@@ -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] }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" <:> "*/*" ]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"}]|]
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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" } ]|]
|
||||
Reference in New Issue
Block a user