diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f67a1d9a..aec2dd57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). + Can generate the plan for different media types using the `for` parameter: `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.object"` + Different options for the plan can be used with the `options` parameter: `Accept: application/vnd.pgrst.plan; options=analyze|verbose|settings|buffers|wal` + The plan can be obtained in text or json by using different media type suffixes: `Accept: application/vnd.pgrst.plan+text` and `Accept: application/vnd.pgrst.plan+json`. - - #2397, Fix race conditions managing database connection helper - @robx + - #2144, Allow extending/overriding relationships for resource embedding - @steve-chavez ### Fixed @@ -65,6 +65,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #2376, OPTIONS requests no longer start an empty database transaction - @steve-chavez - #2395, Allow using columns with dollar sign($) without double quoting in filters and `select` - @steve-chavez - #2410, Fix loop crash error on startup in Postgres 15 beta 2. Log: "UNION types \"char\" and text cannot be matched". - @yevon + - #2397, Fix race conditions managing database connection helper - @robx ### Changed diff --git a/postgrest.cabal b/postgrest.cabal index 9c04aed30..d24c1b9f5 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -192,6 +192,7 @@ test-suite spec Feature.OpenApi.SecurityOpenApiSpec Feature.OptionsSpec Feature.Query.AndOrParamsSpec + Feature.Query.ComputedRelsSpec Feature.Query.DeleteSpec Feature.Query.EmbedDisambiguationSpec Feature.Query.EmbedInnerJoinSpec diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs index 3d69a584c..5c5cd5458 100644 --- a/src/PostgREST/DbStructure.hs +++ b/src/PostgREST/DbStructure.hs @@ -90,18 +90,36 @@ queryDbStructure schemas extraSearchPath prepared = do keyDeps <- SQL.statement (schemas, extraSearchPath) $ allViewsKeyDependencies prepared m2oRels <- SQL.statement mempty $ allM2ORels pgVer prepared procs <- SQL.statement schemas $ allProcs pgVer prepared + cRels <- SQL.statement mempty $ allComputedRels prepared let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps - rels = relsToMap $ addO2MRels $ addM2MRels tabsWViewsPks $ addViewM2ORels keyDeps m2oRels + rels = addO2MRels $ addM2MRels tabsWViewsPks $ addViewM2ORels keyDeps m2oRels return $ removeInternal schemas $ DbStructure { dbTables = tabsWViewsPks - , dbRelationships = rels + , dbRelationships = getOverrideRelationshipsMap rels cRels , dbProcs = procs } + +-- | overrides detected relationships with the computed relationships and gets the RelationshipsMap +getOverrideRelationshipsMap :: [Relationship] -> [Relationship] -> RelationshipsMap +getOverrideRelationshipsMap rels cRels = + sort <$> deformedRelMap patchedRels where - relsToMap = map sort . HM.fromListWith (++) . map ((\(x, fSch, y) -> ((x, fSch), [y])) . addKey) - addKey rel = (relTable rel, qiSchema $ relForeignTable rel, rel) + -- there can only be a single (table_type, func_name) pair in a function definition `test.function(table_type)`, so we use HM.fromList to disallow duplicates + computedRels = HM.fromList $ relMapKey <$> cRels + -- here we override the detected relationships with the user computed relationships, HM.union makes sure computedRels prevail + patchedRels = HM.union computedRels (relsMap rels) + relsMap = HM.fromListWith (++) . fmap relMapKey + relMapKey rel = case rel of + Relationship{relTable,relForeignTable} -> ((relTable, relForeignTable), [rel]) + -- we use (relTable, relFunction) as key to override detected relationships with the function name + ComputedRelationship{relTable,relFunction} -> ((relTable, relFunction), [rel]) + -- Since a relationship is between a table and foreign table, the logical way to index/search is by their table/ftable QualifiedIdentifier + -- However, because we allow searching a relationship by the columns of the foreign key(using the "column as target" disambiguation) we lose the + -- ability to index by the foreign table name, so we deform the key. TODO remove once support for "column as target" is gone. + deformedRelMap = HM.fromListWith (++) . fmap addDeformedRelKey . HM.toList + addDeformedRelKey ((relT, relFT), rls) = ((relT, qiSchema relFT), rls) -- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure. removeInternal :: [Schema] -> DbStructure -> DbStructure @@ -113,7 +131,8 @@ removeInternal schemas dbStruct = , dbProcs = dbProcs dbStruct -- procs are only obtained from the exposed schemas, no need to filter them. } where - hasInternalJunction rel = case relCardinality rel of + hasInternalJunction ComputedRelationship{} = False + hasInternalJunction Relationship{relCardinality=card} = case card of M2M Junction{junTable} -> qiSchema junTable `notElem` schemas _ -> False @@ -643,6 +662,50 @@ allM2ORels pgVer = else mempty) <> "ORDER BY conrelid, conname" +allComputedRels :: Bool -> SQL.Statement () [Relationship] +allComputedRels = + SQL.Statement sql HE.noParams (HD.rowList cRelRow) + where + sql = [q| + with + all_relations as ( + select reltype + from pg_class + where relkind in ('v','r','m','f','p') + ), + computed_rels as ( + select + p.pronamespace::regnamespace::text as schema, + p.proname::text as name, + arg_schema.nspname::text as rel_table_schema, + arg_name.typname::text as rel_table_name, + ret_schema.nspname::text as rel_ftable_schema, + ret_name.typname::text as rel_ftable_name, + p.prorows = 1 as single_row + from pg_proc p + join pg_type arg_name on arg_name.oid = p.proargtypes[0] + join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace + join pg_type ret_name on ret_name.oid = p.prorettype + join pg_namespace ret_schema on ret_schema.oid = ret_name.typnamespace + where + p.pronargs = 1 + and p.proargtypes[0] in (select reltype from all_relations) + and p.prorettype in (select reltype from all_relations) + ) + select + *, + row(rel_table_schema, rel_table_name) = row(rel_ftable_schema, rel_ftable_name) as is_self + from computed_rels; + |] + + cRelRow = + ComputedRelationship <$> + (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> + (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> + (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> + column HD.bool <*> + column HD.bool + -- | Returns all the views' primary keys and foreign keys dependencies allViewsKeyDependencies :: Bool -> SQL.Statement ([Schema], [Schema]) [ViewKeyDependency] allViewsKeyDependencies = diff --git a/src/PostgREST/DbStructure/Relationship.hs b/src/PostgREST/DbStructure/Relationship.hs index 37f839dbc..b8d4b1f60 100644 --- a/src/PostgREST/DbStructure/Relationship.hs +++ b/src/PostgREST/DbStructure/Relationship.hs @@ -26,6 +26,13 @@ data Relationship = Relationship , relTableIsView :: Bool , relFTableIsView :: Bool } + | ComputedRelationship + { relFunction :: QualifiedIdentifier + , relTable :: QualifiedIdentifier + , relForeignTable :: QualifiedIdentifier + , relToOne :: Bool + , relIsSelf :: Bool + } deriving (Eq, Ord, Generic, JSON.ToJSON) -- | The relationship cardinality diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index c8f80e267..ed0836993 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -171,6 +171,8 @@ instance JSON.ToJSON ApiRequestError where "hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)] compressedRel :: Relationship -> JSON.Value +-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed +compressedRel ComputedRelationship{} = JSON.object mempty compressedRel Relationship{..} = let fmtEls els = "(" <> T.intercalate ", " els <> ")" @@ -200,6 +202,8 @@ relHint rels = T.intercalate ", " (hintList <$> rels) M2M Junction{..} -> buildHint (qiName junTable) M2O cons _ -> buildHint cons O2M cons _ -> buildHint cons + -- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed + hintList ComputedRelationship{} = mempty data PgError = PgError Authenticated SQL.UsageError type Authenticated = Bool diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 9cb99dc0e..6ac9f2ac9 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {-| Module : PostgREST.Query.QueryBuilder Description : PostgREST SQL queries generating functions. @@ -40,7 +41,7 @@ readRequestToQuery :: ReadRequest -> SQL.Snippet readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinConditions_ ordts range, (_, rel, _, _, _, _)) forest) = "SELECT " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> " " <> - "FROM " <> SQL.sql tabl <> implicitJoinF rel <> " " <> + fromFrag <> " " <> intercalateSnippet " " joins <> " " <> (if null logicForest && null joinConditions_ then mempty @@ -48,23 +49,26 @@ readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinCond orderF qi ordts <> " " <> limitOffsetF range where - tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias - qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias + fromFrag = fromF rel mainQi tblAlias + qi = getQualifiedIdentifier rel mainQi tblAlias (selects, joins) = foldr getSelectsJoins ([],[]) forest getSelectsJoins :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) getSelectsJoins (Node (_, (_, Nothing, _, _, _, _)) _) _ = ([], []) -getSelectsJoins rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=QualifiedIdentifier{qiName=table}}, alias, _, joinType, _)) _) (selects,joins) = +getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (selects,joins) = let subquery = readRequestToQuery rr aliasOrName = fromMaybe name alias - locTblName = table <> "_" <> aliasOrName + locTblName = qiName (relTable rel) <> "_" <> aliasOrName localTableName = pgFmtIdent locTblName internalTableName = pgFmtIdent $ "_" <> locTblName correlatedSubquery sub al cond = (if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond - (sel, joi) = case card of - M2O _ _ -> + (sel, joi) = case rel of + Relationship{relCardinality=M2O _ _} -> + ( SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) + , correlatedSubquery subquery localTableName "TRUE") + ComputedRelationship{relToOne=True} -> ( SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) , correlatedSubquery subquery localTableName "TRUE") _ -> @@ -219,7 +223,7 @@ requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall r -- Only for the nodes that have an INNER JOIN linked to the root level. readRequestToCountQuery :: ReadRequest -> SQL.Snippet readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=logicForest, joinConditions=joinConditions_}, (_, rel, _, _, _, _)) forest) = - "SELECT 1 FROM " <> SQL.sql tabl <> implicitJoinF rel <> + "SELECT 1 " <> fromFrag <> (if null logicForest && null joinConditions_ && null subQueries then mempty else " WHERE " ) <> @@ -229,8 +233,8 @@ readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=lo subQueries ) where - qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias - tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias + qi = getQualifiedIdentifier rel mainQi tblAlias + fromFrag = fromF rel mainQi tblAlias subQueries = foldr existsSubquery [] forest existsSubquery :: ReadRequest -> [SQL.Snippet] -> [SQL.Snippet] existsSubquery readReq@(Node (_, (_, _, _, _, joinType, _)) _) rest = @@ -241,7 +245,19 @@ readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=lo limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows) -implicitJoinF :: Maybe Relationship -> SQL.Snippet -implicitJoinF rel = case relCardinality <$> rel of - Just (M2M Junction{junTable=jt}) -> ", " <> SQL.sql (fromQi jt) - _ -> mempty +-- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadQuery type +getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier +getQualifiedIdentifier rel mainQi tblAlias = case rel of + Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias + _ -> maybe mainQi (QualifiedIdentifier mempty) tblAlias + +-- FROM clause plus implicit joins +fromF :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> SQL.Snippet +fromF rel mainQi tblAlias = SQL.sql $ "FROM " <> + (case rel of + Just ComputedRelationship{relFunction,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTable) <> ")" + _ -> fromQi mainQi) <> + maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias <> + (case rel of + Just Relationship{relCardinality=M2M Junction{junTable=jt}} -> ", " <> fromQi jt + _ -> mempty) diff --git a/src/PostgREST/Request/DbRequestBuilder.hs b/src/PostgREST/Request/DbRequestBuilder.hs index 7b2ac532a..a5ed77181 100644 --- a/src/PostgREST/Request/DbRequestBuilder.hs +++ b/src/PostgREST/Request/DbRequestBuilder.hs @@ -137,6 +137,7 @@ addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, a -- applies aliasing to join conditions TODO refactor, this should go into the querybuilder module addJoinConditions :: Maybe Alias -> ReadRequest -> ReadRequest addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Nothing, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest) +addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Just ComputedRelationship{}, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest) addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodeProps@(_, Just (Relationship QualifiedIdentifier{qiSchema=tSchema, qiName=tN} QualifiedIdentifier{qiName=ftN} _ card _ _), _, _, _, _)) forest) = Node (query{joinConditions=joinConds}, nodeProps) (addJoinConditions tblAlias <$> forest) where @@ -188,8 +189,9 @@ findRel schema allRels origin target hint = isO2M card = case card of O2M _ _ -> True _ -> False - rels = filter ( - \Relationship{..} -> + rels = filter (\case + ComputedRelationship{relFunction} -> target == qiName relFunction + Relationship{..} -> -- In a self-relationship we have a single foreign key but two relationships with different cardinalities: M2O/O2M. For disambiguation, we use the convention of getting: -- TODO: handle one-to-one and many-to-many self-relationships if relIsSelf @@ -365,6 +367,10 @@ returningCols rr@(Node _ forest) pkCols Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2) _ -> Nothing ) forest + hasComputedRel = isJust $ find (\case + Node (_, (_, Just ComputedRelationship{}, _, _, _, _)) _ -> True + _ -> False + ) forest -- However if the "client_id" is present, e.g. mutateRequest to -- /projects?select=client_id,name,clients(name) we would get `RETURNING -- client_id, name, client_id` and then we would produce the "column @@ -372,7 +378,10 @@ returningCols rr@(Node _ forest) pkCols -- deduplicate with Set: We are adding the primary key columns as well to -- make sure, that a proper location header can always be built for -- INSERT/POST - returnings = S.toList . S.fromList $ fldNames ++ fkCols ++ pkCols + returnings = + if not hasComputedRel + then S.toList . S.fromList $ fldNames ++ fkCols ++ pkCols + else ["*"] -- on computed relationships we cannot know the required columns for an embedding to succeed, so we just return all -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree -- they are later concatenated with AND in the QueryBuilder diff --git a/test/spec/Feature/Query/ComputedRelsSpec.hs b/test/spec/Feature/Query/ComputedRelsSpec.hs new file mode 100644 index 000000000..2a205f5b9 --- /dev/null +++ b/test/spec/Feature/Query/ComputedRelsSpec.hs @@ -0,0 +1,101 @@ +module Feature.Query.ComputedRelsSpec 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 "computed relationships" $ do + it "can define a many-to-one relationship and embed" $ + get "/videogames?select=name,designers:computed_designers(name)" + `shouldRespondWith` + [json|[ + {"name":"Civilization I","designers":{"name":"Sid Meier"}}, + {"name":"Civilization II","designers":{"name":"Sid Meier"}}, + {"name":"Final Fantasy I","designers":{"name":"Hironobu Sakaguchi"}}, + {"name":"Final Fantasy II","designers":{"name":"Hironobu Sakaguchi"}} + ]|] { matchHeaders = [matchContentTypeJson] } + + it "can define a one-to-many relationship and embed" $ + get "/designers?select=name,videogames:computed_videogames(name)" + `shouldRespondWith` + [json|[ + {"name":"Sid Meier","videogames":[{"name":"Civilization I"}, {"name":"Civilization II"}]}, + {"name":"Hironobu Sakaguchi","videogames":[{"name":"Final Fantasy I"}, {"name":"Final Fantasy II"}]} + ]|] { matchHeaders = [matchContentTypeJson] } + + it "works with !inner and count=exact" $ do + request methodGet "/designers?select=name,videogames:computed_videogames!inner(name)&videogames.name=eq.Civilization%20I" + [("Prefer", "count=exact")] "" + `shouldRespondWith` + [json|[{"name":"Sid Meier","videogames":[{"name":"Civilization I"}]}]|] + { matchStatus = 200 + , matchHeaders = ["Content-Range" <:> "0-0/1"] + } + request methodGet "/videogames?select=name,designer:computed_designers!inner(name)&designer.name=like.*Hironobu*" + [("Prefer", "count=exact")] "" + `shouldRespondWith` + [json|[ + {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}}, + {"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}} + ]|] + { matchStatus = 200 + , matchHeaders = ["Content-Range" <:> "0-1/2"] + } + + it "works with rpc" $ do + get "/rpc/getallvideogames?select=name,designer:computed_designers(name)" + `shouldRespondWith` + [json|[ + {"name":"Civilization I","designer":{"name":"Sid Meier"}}, + {"name":"Civilization II","designer":{"name":"Sid Meier"}}, + {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}}, + {"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}} + ]|] { matchHeaders = [matchContentTypeJson] } + get "/rpc/getalldesigners?select=name,videogames:computed_videogames(name)" + `shouldRespondWith` + [json|[ + {"name":"Sid Meier","videogames":[{"name":"Civilization I"}, {"name":"Civilization II"}]}, + {"name":"Hironobu Sakaguchi","videogames":[{"name":"Final Fantasy I"}, {"name":"Final Fantasy II"}]} + ]|] { matchHeaders = [matchContentTypeJson] } + + it "works with mutations" $ do + request methodPost "/videogames?select=name,designer:computed_designers(name)" + [("Prefer", "return=representation")] + [json| {"id": 5, "name": "Chrono Trigger", "designer_id": 2} |] + `shouldRespondWith` + [json|[ {"name":"Chrono Trigger","designer":{"name":"Hironobu Sakaguchi"}} ]|] + { matchStatus = 201 } + request methodPatch "/designers?select=name,videogames:computed_videogames(name)&id=eq.1" + [("Prefer", "return=representation")] + [json| {"name": "Sidney K. Meier"} |] + `shouldRespondWith` + [json|[ { "name": "Sidney K. Meier", "videogames": [{"name":"Civilization I"}, {"name":"Civilization II"}] } ]|] + { matchStatus = 200 } + request methodDelete "/videogames?select=name,designer:computed_designers(name)&id=eq.3" + [("Prefer", "return=representation")] "" + `shouldRespondWith` + [json|[ {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}} ]|] + { matchStatus = 200 } + + it "works with self joins" $ + get "/web_content?select=name,child_web_content(name),parent_web_content(name)&id=in.(0,1)" + `shouldRespondWith` + [json|[ + {"name":"tardis","child_web_content":[{"name":"fezz"}, {"name":"foo"}, {"name":"bar"}],"parent_web_content":{"name":"wat"}}, + {"name":"fezz","child_web_content":[{"name":"wut"}],"parent_web_content":{"name":"tardis"}} + ]|] { matchHeaders = [matchContentTypeJson] } + + it "can override detected relationships" $ do + get "/videogames?select=*,designers!inner(*)" + `shouldRespondWith` + [json|[]|] { matchHeaders = [matchContentTypeJson] } + get "/designers?select=*,videogames!inner(*)" + `shouldRespondWith` + [json|[]|] { matchHeaders = [matchContentTypeJson] } diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 2ab5b4b91..1f647ede2 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -37,6 +37,7 @@ import qualified Feature.OpenApi.RootSpec import qualified Feature.OpenApi.SecurityOpenApiSpec import qualified Feature.OptionsSpec import qualified Feature.Query.AndOrParamsSpec +import qualified Feature.Query.ComputedRelsSpec import qualified Feature.Query.DeleteSpec import qualified Feature.Query.EmbedDisambiguationSpec import qualified Feature.Query.EmbedInnerJoinSpec @@ -147,6 +148,7 @@ main = do , ("Feature.Query.SingularSpec" , Feature.Query.SingularSpec.spec) , ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec) , ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion) + , ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec) ] hspec $ do diff --git a/test/spec/fixtures/data.sql b/test/spec/fixtures/data.sql index b8d121da6..0e31c0be9 100644 --- a/test/spec/fixtures/data.sql +++ b/test/spec/fixtures/data.sql @@ -806,3 +806,9 @@ TRUNCATE TABLE unsafe_update_items CASCADE; INSERT INTO unsafe_update_items(id, name, observation) VALUES (1, 'item-1', NULL), (2, 'item-2', NULL), (3, 'item-3', NULL); TRUNCATE TABLE unsafe_delete_items CASCADE; INSERT INTO unsafe_delete_items(id, name, observation) VALUES (1, 'item-1', NULL), (2, 'item-2', NULL), (3, 'item-3', NULL); + +TRUNCATE TABLE designers CASCADE; +INSERT INTO designers(id, name) VALUES (1, 'Sid Meier'), (2, 'Hironobu Sakaguchi'); + +TRUNCATE TABLE videogames CASCADE; +INSERT INTO videogames(id, name, designer_id) VALUES (1, 'Civilization I', 1), (2, 'Civilization II', 1), (3, 'Final Fantasy I', 2), (4, 'Final Fantasy II', 2); diff --git a/test/spec/fixtures/privileges.sql b/test/spec/fixtures/privileges.sql index 5a3e9fede..a2d079d4a 100644 --- a/test/spec/fixtures/privileges.sql +++ b/test/spec/fixtures/privileges.sql @@ -197,6 +197,8 @@ GRANT ALL ON TABLE , safe_delete_items , unsafe_update_items , unsafe_delete_items + , videogames + , designers TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index a8a8bb36b..8910a944b 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -2707,3 +2707,49 @@ CREATE OR REPLACE FUNCTION test.load_safeupdate() RETURNS VOID AS $$ BEGIN LOAD 'safeupdate'; END; $$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TABLE designers ( + id int primary key +, name text +); + +CREATE TABLE videogames ( + id int primary key +, name text +, designer_id int references designers(id) +); + +-- computed relationships +CREATE FUNCTION test.computed_designers(test.videogames) RETURNS SETOF test.designers AS $$ + SELECT * FROM test.designers WHERE id = $1.designer_id; +$$ LANGUAGE sql STABLE ROWS 1; + +CREATE FUNCTION test.computed_videogames(test.designers) RETURNS SETOF test.videogames AS $$ + SELECT * FROM test.videogames WHERE designer_id = $1.id; +$$ LANGUAGE sql STABLE; + +CREATE FUNCTION test.getallvideogames() RETURNS SETOF test.videogames AS $$ + SELECT * FROM test.videogames; +$$ LANGUAGE sql STABLE; + +CREATE FUNCTION test.getalldesigners() RETURNS SETOF test.designers AS $$ + SELECT * FROM test.designers; +$$ LANGUAGE sql STABLE; + +-- self join for computed relationships +CREATE FUNCTION test.child_web_content(test.web_content) RETURNS SETOF test.web_content AS $$ + SELECT * FROM test.web_content WHERE $1.id = p_web_id; +$$ LANGUAGE sql STABLE; + +CREATE FUNCTION test.parent_web_content(test.web_content) RETURNS SETOF test.web_content AS $$ + SELECT * FROM test.web_content WHERE $1.p_web_id = id; +$$ LANGUAGE sql STABLE ROWS 1; + +-- overriding computed rels that empty the results +CREATE FUNCTION test.designers(test.videogames) RETURNS SETOF test.designers AS $$ + SELECT * FROM test.designers WHERE FALSE; +$$ LANGUAGE sql STABLE ROWS 1; + +CREATE FUNCTION test.videogames(test.designers) RETURNS SETOF test.videogames AS $$ + SELECT * FROM test.videogames WHERE FALSE; +$$ LANGUAGE sql STABLE;