From c45e85c5a60ba3efa89a8ab048b8c831e37e291a Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Thu, 18 Aug 2022 08:46:16 -0500 Subject: [PATCH] feat: add one to one relationship for embedding BREAKING CHANGE: For the cases where one to one relationships are detected, json objects will be returned instead of json arrays of length 1. If you wish to override this behavior, you can use computed relationships to return arrays again. --- CHANGELOG.md | 4 + src/PostgREST/DbStructure.hs | 90 +++++++++++++------ src/PostgREST/DbStructure/Relationship.hs | 3 +- src/PostgREST/Error.hs | 5 ++ src/PostgREST/Query/QueryBuilder.hs | 14 +-- src/PostgREST/Request/DbRequestBuilder.hs | 6 ++ test/spec/Feature/Query/ComputedRelsSpec.hs | 10 ++- test/spec/Feature/Query/DeleteSpec.hs | 26 ++++++ .../Feature/Query/EmbedDisambiguationSpec.hs | 14 +++ test/spec/Feature/Query/QuerySpec.hs | 59 ++++++++++++ test/spec/Feature/Query/RpcSpec.hs | 14 +++ test/spec/Feature/Query/UpdateSpec.hs | 28 ++++++ test/spec/fixtures/data.sql | 12 +++ test/spec/fixtures/privileges.sql | 10 +++ test/spec/fixtures/schema.sql | 75 +++++++++++++++- 15 files changed, 332 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a78e68f..48764f229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). + 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`. - #2144, Allow extending/overriding relationships for resource embedding - @steve-chavez + - #1984, Detect one-to-one relationships for resource embedding - @steve-chavez + + Detected when there's a foreign key with a unique constraint or when a foreign key is also a primary key ### Fixed @@ -81,6 +83,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). + This embedding form was easily made ambiguous whenever a new view was added. + For migrating, clients must be updated to the embedding form of `/view?select=*,other_view!column(*)`. - #2312, Using `Prefer: return=representation` no longer returns a `Location` header - @laurenceisla + - #1984, For the cases where one to one relationships are detected, json objects will be returned instead of json arrays of length 1 + + If you wish to override this behavior, you can use computed relationships to return arrays again ## [9.0.1] - 2022-06-03 diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs index be02d2669..c1e717cfb 100644 --- a/src/PostgREST/DbStructure.hs +++ b/src/PostgREST/DbStructure.hs @@ -88,12 +88,12 @@ queryDbStructure schemas extraSearchPath prepared = do pgVer <- SQL.statement mempty pgVersionStatement tabs <- SQL.statement schemas $ allTables pgVer prepared keyDeps <- SQL.statement (schemas, extraSearchPath) $ allViewsKeyDependencies prepared - m2oRels <- SQL.statement mempty $ allM2ORels pgVer prepared + m2oRels <- SQL.statement mempty $ allM2OandO2ORels pgVer prepared procs <- SQL.statement schemas $ allProcs pgVer prepared cRels <- SQL.statement mempty $ allComputedRels prepared let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps - rels = addO2MRels $ addM2MRels tabsWViewsPks $ addViewM2ORels keyDeps m2oRels + rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels return $ removeInternal schemas $ DbStructure { dbTables = tabsWViewsPks @@ -163,14 +163,15 @@ decodeRels :: HD.Result [Relationship] decodeRels = HD.rowList relRow where - relRow = - Relationship <$> + relRow = (\(qi1, qi2, isSelf, constr, cols, isOneToOne) -> Relationship qi1 qi2 isSelf ((if isOneToOne then O2O else M2O) constr cols) False False) <$> row + row = + (,,,,,) <$> (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> column HD.bool <*> - (M2O <$> column HD.text <*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text)) <*> - pure False <*> - pure False + column HD.text <*> + compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text) <*> + column HD.bool decodeViewKeyDeps :: HD.Result [ViewKeyDependency] decodeViewKeyDeps = @@ -337,9 +338,9 @@ accessibleTables pgVer = sql = tablesSqlQuery False pgVer {- -Adds M2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified. +Adds M2O and O2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified. ---allM2ORels sample query result-- +--allM2OandO2ORels sample query result-- private | personnages | private | actors | personnages_role_id_fkey | {"(role_id,id)"} --allViewsKeyDependencies sample query result-- @@ -351,32 +352,37 @@ test | personnages_view | private | actors | personnage private | personnages | test | actors_view | personnages_role_id_fkey | f_ref | {"(role_id,actorId)"} | tableViewM2O test | personnages_view | test | actors_view | personnages_role_id_fkey | f,r_ref | {"(roleId,actorId)"} | viewViewM2O -} -addViewM2ORels :: [ViewKeyDependency] -> [Relationship] -> [Relationship] -addViewM2ORels keyDeps rels = +addViewM2OAndO2ORels :: [ViewKeyDependency] -> [Relationship] -> [Relationship] +addViewM2OAndO2ORels keyDeps rels = rels ++ concat (viewRels <$> rels) where - viewRels Relationship{relTable,relForeignTable,relCardinality=M2O cons relColumns} = + isM2O card = case card of {M2O _ _ -> True; _ -> False;} + isO2O card = case card of {O2O _ _ -> True; _ -> False;} + viewRels Relationship{relTable,relForeignTable,relCardinality=card} = + if isM2O card || isO2O card then let - viewTableM2Os = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relTable && keyDepCons == cons && keyDepType == FKDep) keyDeps - tableViewM2Os = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relForeignTable && keyDepCons == cons && keyDepType == FKDepRef) keyDeps + cons = relCons card + relCols = relColumns card + viewTableRels = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relTable && keyDepCons == cons && keyDepType == FKDep) keyDeps + tableViewRels = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relForeignTable && keyDepCons == cons && keyDepType == FKDepRef) keyDeps in [ Relationship (keyDepView vwTbl) relForeignTable False - (M2O cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) (keyDepCols vwTbl) relColumns) + ((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) (keyDepCols vwTbl) relCols) True False - | vwTbl <- viewTableM2Os ] + | vwTbl <- viewTableRels ] ++ [ Relationship relTable (keyDepView tblVw) False - (M2O cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relColumns (keyDepCols tblVw)) + ((if isM2O card then M2O else O2O) cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relCols (keyDepCols tblVw)) False True - | tblVw <- tableViewM2Os ] + | tblVw <- tableViewRels ] ++ [ let @@ -387,17 +393,19 @@ addViewM2ORels keyDeps rels = vw1 vw2 (vw1 == vw2) - (M2O cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) (keyDepCols vwTbl) (keyDepCols tblVw)) + ((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) (keyDepCols vwTbl) (keyDepCols tblVw)) True True - | vwTbl <- viewTableM2Os - , tblVw <- tableViewM2Os ] + | vwTbl <- viewTableRels + , tblVw <- tableViewRels ] + else [] viewRels _ = [] - -addO2MRels :: [Relationship] -> [Relationship] -addO2MRels rels = rels ++ [ Relationship ft t isSelf (O2M cons (swap <$> cols)) fTableIsView tableIsView - | Relationship t ft isSelf (M2O cons cols) tableIsView fTableIsView <- rels ] +addInverseRels :: [Relationship] -> [Relationship] +addInverseRels rels = + rels ++ + [ Relationship ft t isSelf (O2M cons (swap <$> cols)) fTableIsView tableIsView | Relationship t ft isSelf (M2O cons cols) tableIsView fTableIsView <- rels ] ++ + [ Relationship ft t isSelf (O2O cons (swap <$> cols)) fTableIsView tableIsView | Relationship t ft isSelf (O2O cons cols) tableIsView fTableIsView <- rels ] -- | Adds a m2m relationship if a table has FKs to two other tables and the FK columns are part of the PK columns addM2MRels :: TablesMap -> [Relationship] -> [Relationship] @@ -634,11 +642,31 @@ tablesSqlQuery getAll pgVer = )|] relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty -allM2ORels :: PgVersion -> Bool -> SQL.Statement () [Relationship] -allM2ORels pgVer = + +-- | Gets many-to-one relationships and one-to-one(O2O) relationships, which are a refinement of the many-to-one's +allM2OandO2ORels :: PgVersion -> Bool -> SQL.Statement () [Relationship] +allM2OandO2ORels pgVer = SQL.Statement sql HE.noParams decodeRels where + -- We use jsonb_agg for comparing the uniques/pks instead of array_agg to avoid the ERROR: cannot accumulate arrays of different dimensionality sql = [q| + WITH + pks_uniques_cols AS ( + SELECT + connamespace, + conrelid, + jsonb_agg(column_info.cols) as cols + FROM pg_constraint + JOIN lateral ( + SELECT array_agg(cols.attname order by cols.attnum) as cols + FROM ( select unnest(conkey) as col) _ + JOIN pg_attribute cols on cols.attrelid = conrelid and cols.attnum = col + ) column_info ON TRUE + WHERE + contype IN ('p', 'u') and + connamespace::regnamespace::text <> 'pg_catalog' + GROUP BY connamespace, conrelid + ) SELECT ns1.nspname AS table_schema, tab.relname AS table_name, @@ -646,10 +674,13 @@ allM2ORels pgVer = other.relname AS foreign_table_name, (ns1.nspname, tab.relname) = (ns2.nspname, other.relname) AS is_self, traint.conname AS constraint_name, - column_info.cols_and_fcols + column_info.cols_and_fcols, + (column_info.cols IN (SELECT * FROM jsonb_array_elements(pks_uqs.cols))) AS one_to_one FROM pg_constraint traint JOIN LATERAL ( - SELECT array_agg(row(cols.attname, refs.attname) order by cols.attnum) AS cols_and_fcols + SELECT + array_agg(row(cols.attname, refs.attname) order by cols.attnum) AS cols_and_fcols, + jsonb_agg(cols.attname order by cols.attnum) AS cols FROM ( SELECT unnest(traint.conkey) AS col, unnest(traint.confkey) AS ref) _ JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref @@ -658,6 +689,7 @@ allM2ORels pgVer = JOIN pg_class tab ON tab.oid = traint.conrelid JOIN pg_class other ON other.oid = traint.confrelid JOIN pg_namespace ns2 ON ns2.oid = other.relnamespace + LEFT JOIN pks_uniques_cols pks_uqs ON pks_uqs.connamespace = traint.connamespace AND pks_uqs.conrelid = traint.conrelid WHERE traint.contype = 'f' |] <> (if pgVer >= pgVersion110 diff --git a/src/PostgREST/DbStructure/Relationship.hs b/src/PostgREST/DbStructure/Relationship.hs index b8d4b1f60..40028ea54 100644 --- a/src/PostgREST/DbStructure/Relationship.hs +++ b/src/PostgREST/DbStructure/Relationship.hs @@ -37,12 +37,13 @@ data Relationship = Relationship -- | The relationship cardinality -- | https://en.wikipedia.org/wiki/Cardinality_(data_modeling) --- TODO: missing one-to-one data Cardinality = O2M {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]} -- ^ one-to-many | M2O {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]} -- ^ many-to-one + | O2O {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]} + -- ^ one-to-one, this is a refinement over M2O so operating on it is pretty much the same as M2O | M2M Junction -- ^ many-to-many deriving (Eq, Ord, Generic, JSON.ToJSON) diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index ed0836993..2a9f65fe5 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -188,6 +188,10 @@ compressedRel Relationship{..} = "cardinality" .= ("many-to-one" :: Text) , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns)) ] + O2O cons relColumns -> [ + "cardinality" .= ("one-to-one" :: Text) + , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns)) + ] O2M cons relColumns -> [ "cardinality" .= ("one-to-many" :: Text) , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns)) @@ -201,6 +205,7 @@ relHint rels = T.intercalate ", " (hintList <$> rels) case relCardinality of M2M Junction{..} -> buildHint (qiName junTable) M2O cons _ -> buildHint cons + O2O 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 diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 6ac9f2ac9..f32021859 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -64,14 +64,16 @@ getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (select 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 rel of - Relationship{relCardinality=M2O _ _} -> + isToOne = case rel of + Relationship{relCardinality=M2O _ _} -> True + Relationship{relCardinality=O2O _ _} -> True + ComputedRelationship{relToOne=True} -> True + _ -> False + (sel, joi) = if isToOne + then ( 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") - _ -> + else ( SQL.sql $ "COALESCE( " <> localTableName <> "." <> internalTableName <> ", '[]') AS " <> pgFmtIdent aliasOrName , correlatedSubquery ( "SELECT json_agg(" <> SQL.sql internalTableName <> ") AS " <> SQL.sql internalTableName <> diff --git a/src/PostgREST/Request/DbRequestBuilder.hs b/src/PostgREST/Request/DbRequestBuilder.hs index a5ed77181..f0856976f 100644 --- a/src/PostgREST/Request/DbRequestBuilder.hs +++ b/src/PostgREST/Request/DbRequestBuilder.hs @@ -149,6 +149,8 @@ addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodePro toJoinCondition previousAlias tblAlias tN ftN <$> cols M2O _ cols -> toJoinCondition previousAlias tblAlias tN ftN <$> cols + O2O _ cols -> + toJoinCondition previousAlias tblAlias tN ftN <$> cols toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition toJoinCondition prAl newAl tb ftb (c, fc) = let qi1 = QualifiedIdentifier tSchema ftb @@ -171,14 +173,17 @@ findRel schema allRels origin target hint = matchFKSingleCol hint_ card = case card of O2M _ [(col, _)] -> hint_ == col M2O _ [(col, _)] -> hint_ == col + O2O _ [(col, _)] -> hint_ == col _ -> False matchFKRefSingleCol hint_ card = case card of O2M _ [(_, fCol)] -> hint_ == fCol M2O _ [(_, fCol)] -> hint_ == fCol + O2O _ [(_, fCol)] -> hint_ == fCol _ -> False matchConstraint tar card = case card of O2M cons _ -> tar == cons M2O cons _ -> tar == cons + O2O cons _ -> tar == cons _ -> False matchJunction hint_ card = case card of M2M Junction{junTable} -> hint_ == qiName junTable @@ -364,6 +369,7 @@ returningCols rr@(Node _ forest) pkCols fkCols = concat $ mapMaybe (\case Node (_, (_, Just Relationship{relCardinality=O2M _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols Node (_, (_, Just Relationship{relCardinality=M2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols + Node (_, (_, Just Relationship{relCardinality=O2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2) _ -> Nothing ) forest diff --git a/test/spec/Feature/Query/ComputedRelsSpec.hs b/test/spec/Feature/Query/ComputedRelsSpec.hs index 2a205f5b9..e33a406f5 100644 --- a/test/spec/Feature/Query/ComputedRelsSpec.hs +++ b/test/spec/Feature/Query/ComputedRelsSpec.hs @@ -92,10 +92,18 @@ spec = describe "computed relationships" $ do {"name":"fezz","child_web_content":[{"name":"wut"}],"parent_web_content":{"name":"tardis"}} ]|] { matchHeaders = [matchContentTypeJson] } - it "can override detected relationships" $ do + it "can override many-to-one and one-to-many relationships" $ do get "/videogames?select=*,designers!inner(*)" `shouldRespondWith` [json|[]|] { matchHeaders = [matchContentTypeJson] } get "/designers?select=*,videogames!inner(*)" `shouldRespondWith` [json|[]|] { matchHeaders = [matchContentTypeJson] } + + it "can override one-to-one relationships(would give disambiguation errors otherwise)" $ do + get "/first_1?select=*,second_1(*)" + `shouldRespondWith` + [json|[]|] { matchHeaders = [matchContentTypeJson] } + get "/second_1?select=*,first_1(*)" + `shouldRespondWith` + [json|[]|] { matchHeaders = [matchContentTypeJson] } diff --git a/test/spec/Feature/Query/DeleteSpec.hs b/test/spec/Feature/Query/DeleteSpec.hs index 0feee4eb5..772c7d490 100644 --- a/test/spec/Feature/Query/DeleteSpec.hs +++ b/test/spec/Feature/Query/DeleteSpec.hs @@ -78,6 +78,32 @@ spec = , matchHeaders = ["Content-Range" <:> "*/*"] } + it "embeds an O2O relationship after delete" $ do + request methodDelete "/students?id=eq.1&select=name,students_info(address)" + [("Prefer", "return=representation")] "" + `shouldRespondWith` + [json|[ + { + "name": "John Doe", + "students_info":{"address":"Street 1"} + } + ]|] + { matchStatus = 200, + matchHeaders = [matchContentTypeJson] + } + request methodDelete "/students_info?id=eq.1&select=address,students(name)" + [("Prefer", "return=representation")] "" + `shouldRespondWith` + [json|[ + { + "address": "Street 1", + "students":{"name": "John Doe"} + } + ]|] + { matchStatus = 200, + matchHeaders = [matchContentTypeJson] + } + context "known route, no records matched" $ it "includes [] body if return=rep" $ request methodDelete "/items?id=eq.101" diff --git a/test/spec/Feature/Query/EmbedDisambiguationSpec.hs b/test/spec/Feature/Query/EmbedDisambiguationSpec.hs index 62f9bbe2f..18ecb6935 100644 --- a/test/spec/Feature/Query/EmbedDisambiguationSpec.hs +++ b/test/spec/Feature/Query/EmbedDisambiguationSpec.hs @@ -106,6 +106,20 @@ spec = , matchHeaders = [matchContentTypeJson] } + it "errs on an ambiguous embed that has two one-to-one relationships" $ + get "/first?select=second(*)" `shouldRespondWith` + [json| { + "code":"PGRST201", + "details":[ + {"cardinality":"one-to-one","embedding":"first with second","relationship":"first_second_id_1_fkey using first(second_id_1) and second(id)"}, + {"cardinality":"one-to-one","embedding":"first with second","relationship":"first_second_id_2_fkey using first(second_id_2) and second(id)"} + ], + "hint":"Try changing 'second' to one of the following: 'second!first_second_id_1_fkey', 'second!first_second_id_2_fkey'. Find the desired relationship in the 'details' key.","message":"Could not embed because more than one relationship was found for 'first' and 'second'" + }|] + { matchStatus = 300 + , matchHeaders = [matchContentTypeJson] + } + context "disambiguating requests with embed hints" $ do context "using FK to specify the relationship" $ do diff --git a/test/spec/Feature/Query/QuerySpec.hs b/test/spec/Feature/Query/QuerySpec.hs index 8a959a62e..c63d8c028 100644 --- a/test/spec/Feature/Query/QuerySpec.hs +++ b/test/spec/Feature/Query/QuerySpec.hs @@ -460,6 +460,51 @@ spec actualPgVersion = do [json|[]|] { matchHeaders = [matchContentTypeJson] } + context "one to one relationships" $ do + it "works when having a pk as fk" $ do + get "/students_info?select=address,students(name)" `shouldRespondWith` + [json|[{"address":"Street 1","students":{"name":"John Doe"}}, {"address":"Street 2","students":{"name":"Jane Doe"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/students?select=name,students_info(address)" `shouldRespondWith` + [json|[{"name":"John Doe","students_info":{"address":"Street 1"}},{"name":"Jane Doe","students_info":{"address":"Street 2"}}]|] + { matchHeaders = [matchContentTypeJson] } + + it "works when having a fk with a unique constraint" $ do + get "/country?select=name,capital(name)" `shouldRespondWith` + [json|[{"name":"Afghanistan","capital":{"name":"Kabul"}}, {"name":"Algeria","capital":{"name":"Algiers"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/capital?select=name,country(name)" `shouldRespondWith` + [json|[{"name":"Kabul","country":{"name":"Afghanistan"}}, {"name":"Algiers","country":{"name":"Algeria"}}]|] + { matchHeaders = [matchContentTypeJson] } + + it "works when using column as target" $ do + get "/capital?select=name,country_id(name)" `shouldRespondWith` + [json|[{"name":"Kabul","country_id":{"name":"Afghanistan"}}, {"name":"Algiers","country_id":{"name":"Algeria"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/capital?select=name,capital_country_id_fkey(name)" `shouldRespondWith` + [json|[{"name":"Kabul","capital_country_id_fkey":{"name":"Afghanistan"}}, {"name":"Algiers","capital_country_id_fkey":{"name":"Algeria"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/country?select=name,capital_country_id_fkey(name)" `shouldRespondWith` + [json|[{"name":"Afghanistan","capital_country_id_fkey":{"name":"Kabul"}}, {"name":"Algeria","capital_country_id_fkey":{"name":"Algiers"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/country?select=name,id(name)" `shouldRespondWith` + [json|[{"name":"Afghanistan","id":{"name":"Kabul"}}, {"name":"Algeria","id":{"name":"Algiers"}}]|] + { matchHeaders = [matchContentTypeJson] } + + it "works when using column as hint" $ do + get "/country?select=name,capital!id(name)" `shouldRespondWith` + [json|[{"name":"Afghanistan","capital":{"name":"Kabul"}}, {"name":"Algeria","capital":{"name":"Algiers"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/country?select=name,capital!country_id(name)" `shouldRespondWith` + [json|[{"name":"Afghanistan","capital":{"name":"Kabul"}}, {"name":"Algeria","capital":{"name":"Algiers"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/capital?select=name,country!id(name)" `shouldRespondWith` + [json|[{"name":"Kabul","country":{"name":"Afghanistan"}}, {"name":"Algiers","country":{"name":"Algeria"}}]|] + { matchHeaders = [matchContentTypeJson] } + get "/capital?select=name,country!country_id(name)" `shouldRespondWith` + [json|[{"name":"Kabul","country":{"name":"Afghanistan"}}, {"name":"Algiers","country":{"name":"Algeria"}}]|] + { matchHeaders = [matchContentTypeJson] } + describe "computed columns" $ do it "computed column on table" $ get "/items?id=eq.1&select=id,always_true" `shouldRespondWith` @@ -707,6 +752,20 @@ spec actualPgVersion = do [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |] { matchHeaders = [matchContentTypeJson] } + it "works with one to one relationships" $ do + get "/students_view?select=name,students_info(address)" `shouldRespondWith` + [json| [{"name":"John Doe","students_info":{"address":"Street 1"}}, {"name":"Jane Doe","students_info":{"address":"Street 2"}}] |] + { matchHeaders = [matchContentTypeJson] } + get "/students_view?select=name,students_info_view(address)" `shouldRespondWith` + [json| [{"name":"John Doe","students_info_view":{"address":"Street 1"}}, {"name":"Jane Doe","students_info_view":{"address":"Street 2"}}] |] + { matchHeaders = [matchContentTypeJson] } + get "/students_info_view?select=address,students(name)" `shouldRespondWith` + [json| [{"address":"Street 1","students":{"name":"John Doe"}}, {"address":"Street 2","students":{"name":"Jane Doe"}}] |] + { matchHeaders = [matchContentTypeJson] } + get "/students_info_view?select=address,students_view(name)" `shouldRespondWith` + [json| [{"address":"Street 1","students_view":{"name":"John Doe"}}, {"address":"Street 2","students_view":{"name":"Jane Doe"}}] |] + { 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` diff --git a/test/spec/Feature/Query/RpcSpec.hs b/test/spec/Feature/Query/RpcSpec.hs index eb558d17d..66cb1b98a 100644 --- a/test/spec/Feature/Query/RpcSpec.hs +++ b/test/spec/Feature/Query/RpcSpec.hs @@ -269,6 +269,20 @@ spec actualPgVersion = ]|] { matchHeaders = [matchContentTypeJson] } + it "can embed an O2O relationship" $ do + get "/rpc/allcapitals?select=name,country(name)" + `shouldRespondWith` [json|[ + {"name":"Kabul","country":{"name":"Afghanistan"}}, + {"name":"Algiers","country":{"name":"Algeria"}}] + |] + { matchHeaders = [matchContentTypeJson] } + get "/rpc/allcountries?select=name,capital(name)" + `shouldRespondWith` [json|[ + {"name":"Afghanistan","capital":{"name":"Kabul"}}, + {"name":"Algeria","capital":{"name":"Algiers"}} + ]|] + { matchHeaders = [matchContentTypeJson] } + when (actualPgVersion >= pgVersion110) $ it "can embed if rpc returns domain of table type" $ do post "/rpc/getproject_domain?select=id,name,client:clients(id),tasks(id)" diff --git a/test/spec/Feature/Query/UpdateSpec.hs b/test/spec/Feature/Query/UpdateSpec.hs index 0d774e271..247a542ef 100644 --- a/test/spec/Feature/Query/UpdateSpec.hs +++ b/test/spec/Feature/Query/UpdateSpec.hs @@ -374,6 +374,34 @@ spec = do matchHeaders = [matchContentTypeJson] } + it "embeds an O2O relationship after update" $ do + request methodPatch "/students?id=eq.1&select=name,students_info(address)" + [("Prefer", "return=representation")] + [json|{"name": "Johnny Doe"}|] + `shouldRespondWith` + [json|[ + { + "name": "Johnny Doe", + "students_info":{"address":"Street 1"} + } + ]|] + { matchStatus = 200, + matchHeaders = [matchContentTypeJson] + } + request methodPatch "/students_info?id=eq.1&select=address,students(name)" + [("Prefer", "return=representation")] + [json|{"address": "New Street 1"}|] + `shouldRespondWith` + [json|[ + { + "address": "New Street 1", + "students":{"name": "John Doe"} + } + ]|] + { 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" diff --git a/test/spec/fixtures/data.sql b/test/spec/fixtures/data.sql index 0e31c0be9..63aba4f5a 100644 --- a/test/spec/fixtures/data.sql +++ b/test/spec/fixtures/data.sql @@ -812,3 +812,15 @@ 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); + +TRUNCATE TABLE students CASCADE; +INSERT INTO students(id, code, name) VALUES (1, '0001', 'John Doe'), (2, '0002', 'Jane Doe'); + +TRUNCATE TABLE students_info CASCADE; +INSERT INTO students_info(id, code, address) VALUES (1, '0001', 'Street 1'), (2, '0002', 'Street 2'); + +TRUNCATE TABLE country CASCADE; +INSERT INTO country(id, name) VALUES (1, 'Afghanistan'), (2, 'Algeria'); + +TRUNCATE TABLE capital CASCADE; +INSERT INTO capital(id, name, country_id) VALUES (1, 'Kabul', 1), (2, 'Algiers', 2); diff --git a/test/spec/fixtures/privileges.sql b/test/spec/fixtures/privileges.sql index a2d079d4a..e43508cb3 100644 --- a/test/spec/fixtures/privileges.sql +++ b/test/spec/fixtures/privileges.sql @@ -199,6 +199,16 @@ GRANT ALL ON TABLE , unsafe_delete_items , videogames , designers + , students + , students_info + , students_view + , students_info_view + , country + , capital + , first + , second + , first_1 + , second_1 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 8910a944b..cfa821c12 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -2400,7 +2400,7 @@ CREATE TABLE contact ( CREATE TABLE clientinfo ( id serial primary key -, clientid int unique references client(id) +, clientid int references client(id) , other text ); @@ -2753,3 +2753,76 @@ $$ 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; + +CREATE TABLE test.students( + id int +, code text +, name text +, primary key(id, code) +); + +CREATE TABLE test.students_info( + id int +, code text +, address text +, primary key(id, code) +, foreign key (id, code) references test.students(id, code) on delete cascade +); + +CREATE TABLE test.country( + id int primary key +, name text +); + +CREATE TABLE test.capital( + id int primary key +, name text +, country_id int unique +, foreign key (country_id) references test.country(id) +); + +CREATE FUNCTION test.allcountries() RETURNS SETOF test.country AS $$ + SELECT * FROM test.country; +$$ LANGUAGE sql STABLE; + +CREATE FUNCTION test.allcapitals() RETURNS SETOF test.capital AS $$ + SELECT * FROM test.capital; +$$ LANGUAGE sql STABLE; + +create view students_view as +select * from students; + +create view students_info_view as +select * from students_info; + +create table test.second ( + id int primary key, + name text +); + +create table test.first ( + id int primary key, + name text, + second_id_1 int references test.second unique, + second_id_2 int references test.second unique +); + +create table test.second_1 ( + id int primary key, + name text +); + +create table test.first_1 ( + id int primary key, + name text, + second_id_1 int references test.second unique, + second_id_2 int references test.second unique +); + +CREATE FUNCTION test.second_1(test.first_1) RETURNS SETOF test.second_1 AS $$ + SELECT * FROM test.second_1 WHERE id = $1.second_id_1; +$$ LANGUAGE sql STABLE ROWS 1; + +CREATE FUNCTION test.first_1(test.second_1) RETURNS SETOF test.first_1 AS $$ + SELECT * FROM test.first_1 WHERE second_id_1 = $1.id; +$$ LANGUAGE sql STABLE ROWS 1;