feat: add computed relationships

* work for select, mutations, rpc
* overrides detected relationships
This commit is contained in:
steve-chavez
2022-08-17 22:50:06 -05:00
committed by Steve Chavez
parent 06c9e246f4
commit d6ec171bcb
12 changed files with 281 additions and 23 deletions
+2 -1
View File
@@ -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"` + 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` + 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`. + 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 ### 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 - #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 - #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 - #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 ### Changed
+1
View File
@@ -192,6 +192,7 @@ test-suite spec
Feature.OpenApi.SecurityOpenApiSpec Feature.OpenApi.SecurityOpenApiSpec
Feature.OptionsSpec Feature.OptionsSpec
Feature.Query.AndOrParamsSpec Feature.Query.AndOrParamsSpec
Feature.Query.ComputedRelsSpec
Feature.Query.DeleteSpec Feature.Query.DeleteSpec
Feature.Query.EmbedDisambiguationSpec Feature.Query.EmbedDisambiguationSpec
Feature.Query.EmbedInnerJoinSpec Feature.Query.EmbedInnerJoinSpec
+68 -5
View File
@@ -90,18 +90,36 @@ queryDbStructure schemas extraSearchPath prepared = do
keyDeps <- SQL.statement (schemas, extraSearchPath) $ allViewsKeyDependencies prepared keyDeps <- SQL.statement (schemas, extraSearchPath) $ allViewsKeyDependencies prepared
m2oRels <- SQL.statement mempty $ allM2ORels pgVer prepared m2oRels <- SQL.statement mempty $ allM2ORels pgVer prepared
procs <- SQL.statement schemas $ allProcs pgVer prepared procs <- SQL.statement schemas $ allProcs pgVer prepared
cRels <- SQL.statement mempty $ allComputedRels prepared
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
rels = relsToMap $ addO2MRels $ addM2MRels tabsWViewsPks $ addViewM2ORels keyDeps m2oRels rels = addO2MRels $ addM2MRels tabsWViewsPks $ addViewM2ORels keyDeps m2oRels
return $ removeInternal schemas $ DbStructure { return $ removeInternal schemas $ DbStructure {
dbTables = tabsWViewsPks dbTables = tabsWViewsPks
, dbRelationships = rels , dbRelationships = getOverrideRelationshipsMap rels cRels
, dbProcs = procs , dbProcs = procs
} }
-- | overrides detected relationships with the computed relationships and gets the RelationshipsMap
getOverrideRelationshipsMap :: [Relationship] -> [Relationship] -> RelationshipsMap
getOverrideRelationshipsMap rels cRels =
sort <$> deformedRelMap patchedRels
where where
relsToMap = map sort . HM.fromListWith (++) . map ((\(x, fSch, y) -> ((x, fSch), [y])) . addKey) -- 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
addKey rel = (relTable rel, qiSchema $ relForeignTable rel, rel) 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. -- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure.
removeInternal :: [Schema] -> DbStructure -> 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. , dbProcs = dbProcs dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
} }
where where
hasInternalJunction rel = case relCardinality rel of hasInternalJunction ComputedRelationship{} = False
hasInternalJunction Relationship{relCardinality=card} = case card of
M2M Junction{junTable} -> qiSchema junTable `notElem` schemas M2M Junction{junTable} -> qiSchema junTable `notElem` schemas
_ -> False _ -> False
@@ -643,6 +662,50 @@ allM2ORels pgVer =
else mempty) <> else mempty) <>
"ORDER BY conrelid, conname" "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 -- | Returns all the views' primary keys and foreign keys dependencies
allViewsKeyDependencies :: Bool -> SQL.Statement ([Schema], [Schema]) [ViewKeyDependency] allViewsKeyDependencies :: Bool -> SQL.Statement ([Schema], [Schema]) [ViewKeyDependency]
allViewsKeyDependencies = allViewsKeyDependencies =
@@ -26,6 +26,13 @@ data Relationship = Relationship
, relTableIsView :: Bool , relTableIsView :: Bool
, relFTableIsView :: Bool , relFTableIsView :: Bool
} }
| ComputedRelationship
{ relFunction :: QualifiedIdentifier
, relTable :: QualifiedIdentifier
, relForeignTable :: QualifiedIdentifier
, relToOne :: Bool
, relIsSelf :: Bool
}
deriving (Eq, Ord, Generic, JSON.ToJSON) deriving (Eq, Ord, Generic, JSON.ToJSON)
-- | The relationship cardinality -- | The relationship cardinality
+4
View File
@@ -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)] "hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
compressedRel :: Relationship -> JSON.Value 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{..} = compressedRel Relationship{..} =
let let
fmtEls els = "(" <> T.intercalate ", " els <> ")" fmtEls els = "(" <> T.intercalate ", " els <> ")"
@@ -200,6 +202,8 @@ relHint rels = T.intercalate ", " (hintList <$> rels)
M2M Junction{..} -> buildHint (qiName junTable) M2M Junction{..} -> buildHint (qiName junTable)
M2O cons _ -> buildHint cons M2O cons _ -> buildHint cons
O2M 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 data PgError = PgError Authenticated SQL.UsageError
type Authenticated = Bool type Authenticated = Bool
+30 -14
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-| {-|
Module : PostgREST.Query.QueryBuilder Module : PostgREST.Query.QueryBuilder
Description : PostgREST SQL queries generating functions. 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) = readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinConditions_ ordts range, (_, rel, _, _, _, _)) forest) =
"SELECT " <> "SELECT " <>
intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> " " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> " " <>
"FROM " <> SQL.sql tabl <> implicitJoinF rel <> " " <> fromFrag <> " " <>
intercalateSnippet " " joins <> " " <> intercalateSnippet " " joins <> " " <>
(if null logicForest && null joinConditions_ (if null logicForest && null joinConditions_
then mempty then mempty
@@ -48,23 +49,26 @@ readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinCond
orderF qi ordts <> " " <> orderF qi ordts <> " " <>
limitOffsetF range limitOffsetF range
where where
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias fromFrag = fromF rel mainQi tblAlias
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias qi = getQualifiedIdentifier rel mainQi tblAlias
(selects, joins) = foldr getSelectsJoins ([],[]) forest (selects, joins) = foldr getSelectsJoins ([],[]) forest
getSelectsJoins :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) getSelectsJoins :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
getSelectsJoins (Node (_, (_, Nothing, _, _, _, _)) _) _ = ([], []) 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 let
subquery = readRequestToQuery rr subquery = readRequestToQuery rr
aliasOrName = fromMaybe name alias aliasOrName = fromMaybe name alias
locTblName = table <> "_" <> aliasOrName locTblName = qiName (relTable rel) <> "_" <> aliasOrName
localTableName = pgFmtIdent locTblName localTableName = pgFmtIdent locTblName
internalTableName = pgFmtIdent $ "_" <> locTblName internalTableName = pgFmtIdent $ "_" <> locTblName
correlatedSubquery sub al cond = correlatedSubquery sub al cond =
(if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond (if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
(sel, joi) = case card of (sel, joi) = case rel of
M2O _ _ -> 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) ( SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName)
, correlatedSubquery subquery localTableName "TRUE") , 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. -- Only for the nodes that have an INNER JOIN linked to the root level.
readRequestToCountQuery :: ReadRequest -> SQL.Snippet readRequestToCountQuery :: ReadRequest -> SQL.Snippet
readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=logicForest, joinConditions=joinConditions_}, (_, rel, _, _, _, _)) forest) = 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 (if null logicForest && null joinConditions_ && null subQueries
then mempty then mempty
else " WHERE " ) <> else " WHERE " ) <>
@@ -229,8 +233,8 @@ readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=lo
subQueries subQueries
) )
where where
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias qi = getQualifiedIdentifier rel mainQi tblAlias
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias fromFrag = fromF rel mainQi tblAlias
subQueries = foldr existsSubquery [] forest subQueries = foldr existsSubquery [] forest
existsSubquery :: ReadRequest -> [SQL.Snippet] -> [SQL.Snippet] existsSubquery :: ReadRequest -> [SQL.Snippet] -> [SQL.Snippet]
existsSubquery readReq@(Node (_, (_, _, _, _, joinType, _)) _) rest = 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 :: SQL.Snippet -> Maybe Integer -> SQL.Snippet
limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows) limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
implicitJoinF :: Maybe Relationship -> SQL.Snippet -- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadQuery type
implicitJoinF rel = case relCardinality <$> rel of getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier
Just (M2M Junction{junTable=jt}) -> ", " <> SQL.sql (fromQi jt) getQualifiedIdentifier rel mainQi tblAlias = case rel of
_ -> mempty 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)
+12 -3
View File
@@ -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 -- applies aliasing to join conditions TODO refactor, this should go into the querybuilder module
addJoinConditions :: Maybe Alias -> ReadRequest -> ReadRequest addJoinConditions :: Maybe Alias -> ReadRequest -> ReadRequest
addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Nothing, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest) 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) = 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) Node (query{joinConditions=joinConds}, nodeProps) (addJoinConditions tblAlias <$> forest)
where where
@@ -188,8 +189,9 @@ findRel schema allRels origin target hint =
isO2M card = case card of isO2M card = case card of
O2M _ _ -> True O2M _ _ -> True
_ -> False _ -> False
rels = filter ( rels = filter (\case
\Relationship{..} -> 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: -- 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 -- TODO: handle one-to-one and many-to-many self-relationships
if relIsSelf if relIsSelf
@@ -365,6 +367,10 @@ returningCols rr@(Node _ forest) pkCols
Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2) Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2)
_ -> Nothing _ -> Nothing
) forest ) forest
hasComputedRel = isJust $ find (\case
Node (_, (_, Just ComputedRelationship{}, _, _, _, _)) _ -> True
_ -> False
) forest
-- However if the "client_id" is present, e.g. mutateRequest to -- However if the "client_id" is present, e.g. mutateRequest to
-- /projects?select=client_id,name,clients(name) we would get `RETURNING -- /projects?select=client_id,name,clients(name) we would get `RETURNING
-- client_id, name, client_id` and then we would produce the "column -- 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 -- 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 -- make sure, that a proper location header can always be built for
-- INSERT/POST -- 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 -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
-- they are later concatenated with AND in the QueryBuilder -- they are later concatenated with AND in the QueryBuilder
+101
View File
@@ -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] }
+2
View File
@@ -37,6 +37,7 @@ import qualified Feature.OpenApi.RootSpec
import qualified Feature.OpenApi.SecurityOpenApiSpec import qualified Feature.OpenApi.SecurityOpenApiSpec
import qualified Feature.OptionsSpec import qualified Feature.OptionsSpec
import qualified Feature.Query.AndOrParamsSpec import qualified Feature.Query.AndOrParamsSpec
import qualified Feature.Query.ComputedRelsSpec
import qualified Feature.Query.DeleteSpec import qualified Feature.Query.DeleteSpec
import qualified Feature.Query.EmbedDisambiguationSpec import qualified Feature.Query.EmbedDisambiguationSpec
import qualified Feature.Query.EmbedInnerJoinSpec import qualified Feature.Query.EmbedInnerJoinSpec
@@ -147,6 +148,7 @@ main = do
, ("Feature.Query.SingularSpec" , Feature.Query.SingularSpec.spec) , ("Feature.Query.SingularSpec" , Feature.Query.SingularSpec.spec)
, ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec) , ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec)
, ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion) , ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion)
, ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec)
] ]
hspec $ do hspec $ do
+6
View File
@@ -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); 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; 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); 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);
+2
View File
@@ -197,6 +197,8 @@ GRANT ALL ON TABLE
, safe_delete_items , safe_delete_items
, unsafe_update_items , unsafe_update_items
, unsafe_delete_items , unsafe_delete_items
, videogames
, designers
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
+46
View File
@@ -2707,3 +2707,49 @@ CREATE OR REPLACE FUNCTION test.load_safeupdate() RETURNS VOID AS $$
BEGIN BEGIN
LOAD 'safeupdate'; LOAD 'safeupdate';
END; $$ LANGUAGE plpgsql SECURITY DEFINER; 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;