From 44e5a311d1bd8e7966a09df6a1f41d2d6e533acc Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Thu, 31 Oct 2019 20:17:27 -0500 Subject: [PATCH] Err embedding when multiple relationships found When having one-to-many relationships like: person -< message[sender] person -< message[recipient] person_detail -< message[sender] person_detail -< message[recipient] Where person_detail is a view of person. This request: GET "/message?select=*,sender(*)" Is ambiguous. Both person or person_detail could be embedded. Until now we have returned the first detected relationship but now we return a 300 Multiple Choices error with a descriptive error message asking the user to disambiguate. This is more helpful for the user and also aids in cases of more complex relationships. --- CHANGELOG.md | 1 + postgrest.cabal | 1 + src/PostgREST/DbRequestBuilder.hs | 25 +- src/PostgREST/Error.hs | 40 +++- src/PostgREST/Types.hs | 4 +- test/Feature/DeleteSpec.hs | 2 +- test/Feature/EmbedDisambiguationSpec.hs | 297 ++++++++++++++++++++++++ test/Feature/QueryLimitedSpec.hs | 2 +- test/Feature/QuerySpec.hs | 177 +------------- test/Main.hs | 20 +- 10 files changed, 371 insertions(+), 198 deletions(-) create mode 100644 test/Feature/EmbedDisambiguationSpec.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 293bf6a1d9..c25c5eafde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - #1385, bulk RPC call now should be done by specifying a `Prefer: params=multiple-objects` header - @steve-chavez +- #1401, resource embedding now outputs an error when multiple relationships between two tables are found - @steve-chavez ## [6.0.2] - 2019-08-22 diff --git a/postgrest.cabal b/postgrest.cabal index 585018f281..d7c523ed82 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -127,6 +127,7 @@ test-suite spec Feature.ConcurrentSpec Feature.CorsSpec Feature.DeleteSpec + Feature.EmbedDisambiguationSpec Feature.ExtraSearchPathSpec Feature.InsertSpec Feature.JsonOperatorSpec diff --git a/src/PostgREST/DbRequestBuilder.hs b/src/PostgREST/DbRequestBuilder.hs index c258cc20b8..5e68d4d402 100644 --- a/src/PostgREST/DbRequestBuilder.hs +++ b/src/PostgREST/DbRequestBuilder.hs @@ -23,7 +23,7 @@ import qualified Data.Set as S import Control.Arrow ((***)) import Data.Either.Combinators (mapLeft) import Data.Foldable (foldr1) -import Data.List (delete) +import Data.List (delete, head, (!!)) import Data.Maybe (fromJust) import Data.Text (isInfixOf) import Text.Regex.TDFA ((=~)) @@ -38,7 +38,7 @@ import PostgREST.Error (ApiRequestError (..), errorResponseFor) import PostgREST.Parsers import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange) import PostgREST.Types -import Protolude hiding (from) +import Protolude hiding (from, head) readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest readRequest schema rootTableName maxRows allRels apiRequest = @@ -105,9 +105,20 @@ addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (node let newFrom r = if qiName tbl == nodeName then tableQi (relTable r) else tbl newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel parentNodeTable = qiName parentNodeQi + results = findRelation schema allRelations nodeName parentNodeTable relationDetail rel :: Either ApiRequestError Relation - rel = note (NoRelationBetween parentNodeTable nodeName) $ - findRelation schema allRelations nodeName parentNodeTable relationDetail in + rel = case results of + [] -> Left $ NoRelBetween parentNodeTable nodeName + [r] -> Right r + rs -> + -- Temporary hack for handling a self reference relationship. In this case we get a parent and child rel with the same relTable/relFtable. + -- We output the child rel, the parent can be obtained by using the fk column as an embed hint. + let rel0 = head rs + rel1 = rs !! 1 in + if length rs == 2 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1 + then note (NoRelBetween parentNodeTable nodeName) (find (\r -> relType r == Child) rs) + else Left $ AmbiguousRelBetween parentNodeTable nodeName rs + in Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest) _ -> let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in @@ -116,9 +127,9 @@ addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (node updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest] updateForest rq = mapM (addRelations schema allRelations rq) forest -findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation +findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> [Relation] findRelation schema allRelations nodeTableName parentNodeTableName relationDetail = - find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} -> + filter (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} -> -- Both relation ends need to be on the exposed schema schema == tableSchema relTable && schema == tableSchema relFTable && case relationDetail of @@ -210,7 +221,7 @@ addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_ Left UnknownRelation Nothing -> Node node <$> updatedForest where - newAlias = case isSelfJoin <$> relation of + newAlias = case isSelfReference <$> relation of Just True | depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased | otherwise -> Nothing diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 1df8fbdcbc..56fab98d64 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -16,12 +16,12 @@ module PostgREST.Error ( ) where import qualified Data.Aeson as JSON +import qualified Data.Text as T import qualified Hasql.Pool as P import qualified Hasql.Session as H import qualified Network.HTTP.Types.Status as HT import Data.Aeson ((.=)) -import Data.Text (unwords) import Network.Wai (Response, responseLBS) import Text.Read (readMaybe) @@ -48,7 +48,8 @@ data ApiRequestError | InvalidRange | InvalidBody ByteString | ParseRequestError Text Text - | NoRelationBetween Text Text + | NoRelBetween Text Text + | AmbiguousRelBetween Text Text [Relation] | InvalidFilters | UnknownRelation -- Unreachable? | UnsupportedVerb -- Unreachable? @@ -62,7 +63,8 @@ instance PgrstError ApiRequestError where status UnknownRelation = HT.status404 status ActionInappropriate = HT.status405 status (ParseRequestError _ _) = HT.status400 - status (NoRelationBetween _ _) = HT.status400 + status (NoRelBetween _ _) = HT.status400 + status AmbiguousRelBetween{} = HT.status300 headers _ = [toHeader CTApplicationJSON] @@ -77,13 +79,41 @@ instance JSON.ToJSON ApiRequestError where "message" .= ("HTTP Range error" :: Text)] toJSON UnknownRelation = JSON.object [ "message" .= ("Unknown relation" :: Text)] - toJSON (NoRelationBetween parent child) = JSON.object [ + toJSON (NoRelBetween parent child) = JSON.object [ "message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)] + toJSON (AmbiguousRelBetween parent child rels) = JSON.object [ + "hint" .= ("Disambiguate by choosing a relationship from the `details` key" :: Text), + "message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text), + "details" .= (compressedRel <$> rels) ] toJSON UnsupportedVerb = JSON.object [ "message" .= ("Unsupported HTTP verb" :: Text)] toJSON InvalidFilters = JSON.object [ "message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)] +compressedRel :: Relation -> JSON.Value +compressedRel rel = + let + -- | Format like "test.orders[billing_address_id]". For easier debugging the format is compressed instead of structured. + fmt sch tbl cols = schTbl sch tbl <> joinCols cols + fmtMany sch tbl cols1 cols2 = schTbl sch tbl <> joinCols cols1 <> joinCols cols2 + schTbl sch tbl = sch <> "." <> tbl + joinCols cols = "[" <> T.intercalate ", " cols <> "]" + + tab = relTable rel + fTab = relFTable rel + in + JSON.object $ [ + "source" .= fmt (tableSchema tab) (tableName tab) (colName <$> relColumns rel) + , "target" .= fmt (tableSchema fTab) (tableName fTab) (colName <$> relFColumns rel) + , "cardinality" .= (show $ relType rel :: Text) + ] ++ + if relType rel == Many + then [ + "junction" .= case (relLinkTable rel, relLinkCols1 rel, relLinkCols2 rel) of + (Just lt, Just lc1, Just lc2) -> fmtMany (tableSchema lt) (tableName lt) (colName <$> lc1) (colName <$> lc2) + _ -> toS $ JSON.encode JSON.Null + ] + else mempty data PgError = PgError Authenticated P.UsageError type Authenticated = Bool @@ -241,7 +271,7 @@ instance JSON.ToJSON SimpleError where "message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)] toJSON (SingularityError n) = JSON.object [ "message" .= ("JSON object requested, multiple (or no) rows returned" :: Text), - "details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]] + "details" .= T.unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]] toJSON JwtTokenMissing = JSON.object [ "message" .= ("Server lacks JWT secret" :: Text)] diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 11ded2246a..79a38c8498 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -284,8 +284,8 @@ data Relation = Relation { , relLinkCols2 :: Maybe [Column] } deriving (Show, Eq) -isSelfJoin :: Relation -> Bool -isSelfJoin r = relTable r == relFTable r +isSelfReference :: Relation -> Bool +isSelfReference r = relTable r == relFTable r data PayloadJSON = -- | Cached attributes of a JSON payload diff --git a/test/Feature/DeleteSpec.hs b/test/Feature/DeleteSpec.hs index 43436c412f..de549f2d9a 100644 --- a/test/Feature/DeleteSpec.hs +++ b/test/Feature/DeleteSpec.hs @@ -37,7 +37,7 @@ spec = request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] "" `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|] it "can embed (parent) entities" $ - request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] "" + request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] "" `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "*/*"] diff --git a/test/Feature/EmbedDisambiguationSpec.hs b/test/Feature/EmbedDisambiguationSpec.hs new file mode 100644 index 0000000000..54eabcf251 --- /dev/null +++ b/test/Feature/EmbedDisambiguationSpec.hs @@ -0,0 +1,297 @@ +module Feature.EmbedDisambiguationSpec where + +import Network.Wai (Application) + +import Test.Hspec hiding (pendingWith) +import Test.Hspec.Wai +import Test.Hspec.Wai.JSON +import Text.Heredoc + +import Protolude hiding (get) +import SpecHelper + +spec :: SpecWith Application +spec = + describe "resource embedding disambiguation" $ do + + it "gives a 300 Multiple Choices error when the request is ambiguous" $ do + get "/message?select=id,body,sender(name,sent)" `shouldRespondWith` + [json| + { + "details": [ + { + "cardinality": "Parent", + "source": "test.person[id]", + "target": "test.message[sender]" + }, + { + "cardinality": "Parent", + "source": "test.person_detail[id]", + "target": "test.message[sender]" + } + ], + "hint": "Disambiguate by choosing a relationship from the `details` key", + "message": "More than one relationship was found for message and sender" + } + |] + { matchStatus = 300 + , matchHeaders = [matchContentTypeJson] + } + + get "/users?select=*,id(*)" `shouldRespondWith` + [json| + { + "details": [ + { + "cardinality": "Child", + "source": "test.articleStars[userId]", + "target": "test.users[id]" + }, + { + "cardinality": "Child", + "source": "test.limited_article_stars[user_id]", + "target": "test.users[id]" + }, + { + "cardinality": "Child", + "source": "test.comments[commenter_id]", + "target": "test.users[id]" + }, + { + "cardinality": "Child", + "source": "test.users_projects[user_id]", + "target": "test.users[id]" + }, + { + "cardinality": "Child", + "source": "test.users_tasks[user_id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "private.article_stars[article_id][user_id]", + "source": "test.articles[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.articleStars[articleId][userId]", + "source": "test.articles[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.limited_article_stars[article_id][user_id]", + "source": "test.articles[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_projects[project_id][user_id]", + "source": "test.projects[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_projects[project_id][user_id]", + "source": "test.materialized_projects[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_projects[project_id][user_id]", + "source": "test.projects_view[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_projects[project_id][user_id]", + "source": "test.projects_view_alt[t_id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_tasks[task_id][user_id]", + "source": "test.tasks[id]", + "target": "test.users[id]" + }, + { + "cardinality": "Many", + "junction": "test.users_tasks[task_id][user_id]", + "source": "test.filtered_tasks[myId]", + "target": "test.users[id]" + } + ], + "hint": "Disambiguate by choosing a relationship from the `details` key", + "message": "More than one relationship was found for users and id" + } + |] + { matchStatus = 300 + , matchHeaders = [matchContentTypeJson] + } + + it "works when requesting children 2 levels" $ + get "/clients?id=eq.1&select=id,projects:projects!client_id(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 "works with parent relation" $ + 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 "fails with an unknown relation" $ + get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith` + [json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|] + { matchStatus = 400 + , matchHeaders = [matchContentTypeJson] } + + it "works with a parent view relation" $ + get "/message?select=id,body,sender:person_detail!sender(name,sent),recipient:person_detail!recipient(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 "works with many<->many relation" $ + get "/tasks?select=id,users:users!users_tasks(id)" `shouldRespondWith` + [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|] + { matchHeaders = [matchContentTypeJson] } + + context "using FK col to specify the relationship" $ do + it "can embed by FK column name" $ + 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 FK column name and select the FK value 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 "requests parents two levels up" $ + get "/tasks?id=eq.1&select=id,name,project:projects!project_id(id,name,client:client_id(id,name))" `shouldRespondWith` + [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|] + + + 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 childs recursively" $ + get "/family_tree?id=eq.1&select=id,name, childs:family_tree!parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith` + [json|[{ + "id": "1", "name": "Parental Unit", "childs": [ + { "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] }, + { "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] } + ] + }]|] { matchHeaders = [matchContentTypeJson] } + + it "embeds parent and then embeds childs" $ + get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith` + [json|[{ + "id": "2", "name": "Kid One", "parent": { + "id": "1", "name": "Parental Unit", "childs": [ { "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 childs" $ 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] } + + -- TODO Remove in next major version(7.0) + describe "old dot '.' symbol, deprecated" $ + it "still works" $ do + get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith` + [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith` + [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|] + { matchHeaders = [matchContentTypeJson] } diff --git a/test/Feature/QueryLimitedSpec.hs b/test/Feature/QueryLimitedSpec.hs index e4b1d52027..e5d2c9f0e1 100644 --- a/test/Feature/QueryLimitedSpec.hs +++ b/test/Feature/QueryLimitedSpec.hs @@ -37,7 +37,7 @@ spec = } it "succeeds in getting parent embeds despite the limit, see #647" $ - get "/tasks?select=id,project(id)&id=gt.5" + get "/tasks?select=id,project:projects(id)&id=gt.5" `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-1/*"] diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 3bde32795c..da50751beb 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -305,14 +305,6 @@ spec actualPgVersion = do [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] { matchHeaders = [matchContentTypeJson] } - it "requesting parents two levels up while using FK to specify the link" $ - get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith` - [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|] - - it "requesting parents two levels up while using FK to specify the link (with rename)" $ - get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith` - [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|] - it "requesting parents and filtering parent columns" $ get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith` [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|] @@ -351,16 +343,6 @@ spec actualPgVersion = do [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|] { matchHeaders = [matchContentTypeJson] } - it "can embed by FK column name" $ - 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 FK column name and select the FK value 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 select by column name sans id" $ 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"}}]|] @@ -394,16 +376,16 @@ spec actualPgVersion = do { matchHeaders = [matchContentTypeJson] } it "detects parent relations when having many views of a private table" $ do - get "/books?select=title,author(name)&id=eq.5" `shouldRespondWith` + get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith` [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|] { matchHeaders = [matchContentTypeJson] } - get "/forties_books?select=title,author(name)&limit=1" `shouldRespondWith` + get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith` [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|] { matchHeaders = [matchContentTypeJson] } - get "/fifties_books?select=title,author(name)&limit=1" `shouldRespondWith` + get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith` [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|] { matchHeaders = [matchContentTypeJson] } - get "/sixties_books?select=title,author(name)&limit=1" `shouldRespondWith` + get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith` [json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|] { matchHeaders = [matchContentTypeJson] } @@ -478,49 +460,6 @@ spec actualPgVersion = do [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |] { matchHeaders = [matchContentTypeJson] } - describe "path fixed" $ do - it "works when requesting children 2 levels" $ - get "/clients?id=eq.1&select=id,projects:projects!client_id(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 "works with parent relation" $ - 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 "fails with an unknown relation" $ - get "/message?select=id,sender:person.space(name)&id=lt.4" `shouldRespondWith` - [json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|] - { matchStatus = 400 - , matchHeaders = [matchContentTypeJson] } - - it "works with a parent view relation" $ - get "/message?select=id,body,sender:person_detail!sender(name,sent),recipient:person_detail!recipient(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 "works with many<->many relation" $ - get "/tasks?select=id,users:users!users_tasks(id)" `shouldRespondWith` - [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|] - { matchHeaders = [matchContentTypeJson] } - - -- TODO Remove in next major version(7.0) - describe "old dot '.' symbol, deprecated" $ - it "still works" $ do - get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith` - [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|] - { matchHeaders = [matchContentTypeJson] } - get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith` - [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|] - { matchHeaders = [matchContentTypeJson] } - describe "aliased embeds" $ do it "works with child relation" $ get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith` @@ -578,114 +517,6 @@ spec actualPgVersion = do { "id":4,"childs":[]} ]|] { matchHeaders = [matchContentTypeJson] } - describe "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 childs recursively" $ - get "/family_tree?id=eq.1&select=id,name, childs:family_tree!parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith` - [json|[{ - "id": "1", "name": "Parental Unit", "childs": [ - { "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] }, - { "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] } - ] - }]|] { matchHeaders = [matchContentTypeJson] } - - it "embeds parent and then embeds childs" $ - get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith` - [json|[{ - "id": "2", "name": "Kid One", "parent": { - "id": "1", "name": "Parental Unit", "childs": [ { "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 childs" $ 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] } - describe "ordering response" $ do it "by a column asc" $ get "/items?id=lte.2&order=id.asc" diff --git a/test/Main.hs b/test/Main.hs index 3b344c8022..d213bf484e 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -26,6 +26,7 @@ import qualified Feature.BinaryJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec +import qualified Feature.EmbedDisambiguationSpec import qualified Feature.ExtraSearchPathSpec import qualified Feature.HtmlRawOutputSpec import qualified Feature.InsertSpec @@ -91,15 +92,16 @@ main = do [("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96] specs = uncurry describe <$> [ - ("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion) - , ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec) - , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) - , ("Feature.CorsSpec" , Feature.CorsSpec.spec) - , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion) - , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion) - , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion) - , ("Feature.StructureSpec" , Feature.StructureSpec.spec) - , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion) + ("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion) + , ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec) + , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) + , ("Feature.CorsSpec" , Feature.CorsSpec.spec) + , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion) + , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion) + , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec) + , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion) + , ("Feature.StructureSpec" , Feature.StructureSpec.spec) + , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion) ] ++ extraSpecs mutSpecs = uncurry describe <$> [