feat: add computed relationships
* work for select, mutations, rpc * overrides detected relationships
This commit is contained in:
committed by
Steve Chavez
parent
06c9e246f4
commit
d6ec171bcb
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -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);
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
Vendored
+46
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user