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.
This commit is contained in:
committed by
Steve Chavez
parent
2afe13fa89
commit
c45e85c5a6
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+12
@@ -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);
|
||||
|
||||
Vendored
+10
@@ -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;
|
||||
|
||||
Vendored
+74
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user