feat: filter top-level resource with embed filter
This is enabled by adding `!inner` to the embedded resource /projects?select=*,clients!inner(*)&clients.id=eq.12 This behaviour can be enabled by default with the config option db-embed-default-join='inner' Which saves the need for specifying `!inner` on every request. If this is enabled, the previous behavior can be restored per request by specifying `!left` on the embedded resource. /projects?select=*,clients!left(*)&clients.id=eq.12` Tested on M20/02M/M2M relationships, views, RPC.
This commit is contained in:
committed by
Steve Chavez
parent
bf91187e63
commit
ee56dd5db1
+4
-1
@@ -14,6 +14,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
+ Enables uploading bytea to a function with `Content-Type: application/octet-stream`
|
+ Enables uploading bytea to a function with `Content-Type: application/octet-stream`
|
||||||
+ Enables uploading raw text to a function with `Content-Type: text/plain`
|
+ Enables uploading raw text to a function with `Content-Type: text/plain`
|
||||||
- #1938, Allow escaping inside double quotes with a backslash, e.g. `?col=in.("Double\"Quote")`, `?col=in.("Back\\slash")` - @steve-chavez
|
- #1938, Allow escaping inside double quotes with a backslash, e.g. `?col=in.("Double\"Quote")`, `?col=in.("Back\\slash")` - @steve-chavez
|
||||||
|
- #1075, Allow filtering top-level resource based on embedded resources filters - @steve-chavez, @Iced-Sun
|
||||||
|
+ This is enabled by adding `!inner` to the embedded resource, e.g. `/projects?select=*,clients!inner(*)&clients.id=eq.12`
|
||||||
|
+ This behavior can be enabled by default with the `db-embed-default-join='inner'` config option, which saves the need for specifying `!inner` on every request. In this case, you can go back to the previous behavior per request by specifying `!left` on the embedded resource, e.g `/projects?select=*,clients!left(*)&clients.id=eq.12`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -579,4 +582,4 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Make filter position match docs, e.g. `?order=col.asc` rather
|
- Make filter position match docs, e.g. `?order=col.asc` rather
|
||||||
than `?order=asc.col`.
|
than `?order=asc.col`.
|
||||||
@@ -176,6 +176,7 @@ test-suite spec
|
|||||||
Feature.DeleteSpec
|
Feature.DeleteSpec
|
||||||
Feature.DisabledOpenApiSpec
|
Feature.DisabledOpenApiSpec
|
||||||
Feature.EmbedDisambiguationSpec
|
Feature.EmbedDisambiguationSpec
|
||||||
|
Feature.EmbedInnerJoinSpec
|
||||||
Feature.ExtraSearchPathSpec
|
Feature.ExtraSearchPathSpec
|
||||||
Feature.HtmlRawOutputSpec
|
Feature.HtmlRawOutputSpec
|
||||||
Feature.InsertSpec
|
Feature.InsertSpec
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ returnsScalar _ = False
|
|||||||
readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest
|
readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest
|
||||||
readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =
|
readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =
|
||||||
liftEither $
|
liftEither $
|
||||||
ReqBuilder.readRequest qiSchema qiName configDbMaxRows
|
ReqBuilder.readRequest qiSchema qiName configDbMaxRows configDbEmbedDefaultJoin
|
||||||
(dbRelationships dbStructure)
|
(dbRelationships dbStructure)
|
||||||
apiRequest
|
apiRequest
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import PostgREST.Config.JSPath (JSPath, JSPathExp (..),
|
|||||||
import PostgREST.Config.Proxy (Proxy (..),
|
import PostgREST.Config.Proxy (Proxy (..),
|
||||||
isMalformedProxyUri, toURI)
|
isMalformedProxyUri, toURI)
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier, toQi)
|
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier, toQi)
|
||||||
|
import PostgREST.Request.Types (JoinType (..))
|
||||||
|
|
||||||
import Protolude hiding (Proxy, toList, toS)
|
import Protolude hiding (Proxy, toList, toS)
|
||||||
import Protolude.Conv (toS)
|
import Protolude.Conv (toS)
|
||||||
@@ -79,6 +80,7 @@ data AppConfig = AppConfig
|
|||||||
, configDbTxAllowOverride :: Bool
|
, configDbTxAllowOverride :: Bool
|
||||||
, configDbTxRollbackAll :: Bool
|
, configDbTxRollbackAll :: Bool
|
||||||
, configDbUri :: Text
|
, configDbUri :: Text
|
||||||
|
, configDbEmbedDefaultJoin :: JoinType
|
||||||
, configFilePath :: Maybe FilePath
|
, configFilePath :: Maybe FilePath
|
||||||
, configJWKS :: Maybe JWKSet
|
, configJWKS :: Maybe JWKSet
|
||||||
, configJwtAudience :: Maybe StringOrURI
|
, configJwtAudience :: Maybe StringOrURI
|
||||||
@@ -132,6 +134,7 @@ toText conf =
|
|||||||
,("db-config", q . T.toLower . show . configDbConfig)
|
,("db-config", q . T.toLower . show . configDbConfig)
|
||||||
,("db-tx-end", q . showTxEnd)
|
,("db-tx-end", q . showTxEnd)
|
||||||
,("db-uri", q . configDbUri)
|
,("db-uri", q . configDbUri)
|
||||||
|
,("db-embed-default-join", q . show . configDbEmbedDefaultJoin)
|
||||||
,("jwt-aud", toS . encode . maybe "" toJSON . configJwtAudience)
|
,("jwt-aud", toS . encode . maybe "" toJSON . configJwtAudience)
|
||||||
,("jwt-role-claim-key", q . T.intercalate mempty . fmap show . configJwtRoleClaimKey)
|
,("jwt-role-claim-key", q . T.intercalate mempty . fmap show . configJwtRoleClaimKey)
|
||||||
,("jwt-secret", q . toS . showJwtSecret)
|
,("jwt-secret", q . toS . showJwtSecret)
|
||||||
@@ -222,6 +225,7 @@ parser optPath env dbSettings =
|
|||||||
<*> parseTxEnd "db-tx-end" snd
|
<*> parseTxEnd "db-tx-end" snd
|
||||||
<*> parseTxEnd "db-tx-end" fst
|
<*> parseTxEnd "db-tx-end" fst
|
||||||
<*> reqString "db-uri"
|
<*> reqString "db-uri"
|
||||||
|
<*> parseEmbedDefaultJoin "db-embed-default-join"
|
||||||
<*> pure optPath
|
<*> pure optPath
|
||||||
<*> pure Nothing
|
<*> pure Nothing
|
||||||
<*> parseJwtAudience "jwt-aud"
|
<*> parseJwtAudience "jwt-aud"
|
||||||
@@ -304,6 +308,14 @@ parser optPath env dbSettings =
|
|||||||
Just "rollback-allow-override" -> pure $ f (True, True)
|
Just "rollback-allow-override" -> pure $ f (True, True)
|
||||||
Just _ -> fail "Invalid transaction termination. Check your configuration."
|
Just _ -> fail "Invalid transaction termination. Check your configuration."
|
||||||
|
|
||||||
|
parseEmbedDefaultJoin :: C.Key -> C.Parser C.Config JoinType
|
||||||
|
parseEmbedDefaultJoin k =
|
||||||
|
optString k >>= \case
|
||||||
|
Nothing -> pure JTLeft
|
||||||
|
Just "left" -> pure JTLeft
|
||||||
|
Just "inner" -> pure JTInner
|
||||||
|
Just _ -> fail "Invalid db-embed-default-join. Check your configuration."
|
||||||
|
|
||||||
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
|
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
|
||||||
parseRoleClaimKey k al =
|
parseRoleClaimKey k al =
|
||||||
optWithAlias (optString k) (optString al) >>= \case
|
optWithAlias (optString k) (optString al) >>= \case
|
||||||
|
|||||||
@@ -50,22 +50,31 @@ readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicFores
|
|||||||
(joins, selects) = foldr getJoinsSelects ([],[]) forest
|
(joins, selects) = foldr getJoinsSelects ([],[]) forest
|
||||||
|
|
||||||
getJoinsSelects :: ReadRequest -> ([H.Snippet], [H.Snippet]) -> ([H.Snippet], [H.Snippet])
|
getJoinsSelects :: ReadRequest -> ([H.Snippet], [H.Snippet]) -> ([H.Snippet], [H.Snippet])
|
||||||
getJoinsSelects rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =
|
getJoinsSelects rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=Table{tableName=table}}, alias, _, Just joinType, _)) _) (j,s) =
|
||||||
let subquery = readRequestToQuery rr in
|
let subquery = readRequestToQuery rr in
|
||||||
case card of
|
case card of
|
||||||
M2O _ ->
|
M2O _ ->
|
||||||
let aliasOrName = fromMaybe name alias
|
let aliasOrName = fromMaybe name alias
|
||||||
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||||
sel = H.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName)
|
sel = H.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName)
|
||||||
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> H.sql localTableName <> " ON TRUE " in
|
joi = (if joinType == JTInner then " INNER" else " LEFT")
|
||||||
|
<> " JOIN LATERAL( " <> subquery <> " ) AS " <> H.sql localTableName <> " ON TRUE " in
|
||||||
(joi:j,sel:s)
|
(joi:j,sel:s)
|
||||||
_ ->
|
_ -> case joinType of
|
||||||
let sel = "COALESCE (("
|
JTInner ->
|
||||||
<> "SELECT json_agg(" <> H.sql (pgFmtIdent table) <> ".*) "
|
let aliasOrName = fromMaybe name alias
|
||||||
<> "FROM (" <> subquery <> ") " <> H.sql (pgFmtIdent table) <> " "
|
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||||
<> "), '[]') AS " <> H.sql (pgFmtIdent (fromMaybe name alias)) in
|
sel = H.sql $ localTableName <> "._ AS " <> pgFmtIdent aliasOrName
|
||||||
(j,sel:s)
|
joi = "INNER JOIN LATERAL( SELECT json_agg(_) AS _ FROM (" <> subquery <> " ) _) AS " <>
|
||||||
getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])
|
H.sql localTableName <> " ON " <> H.sql localTableName <> "IS NOT NULL" in
|
||||||
|
(joi:j,sel:s)
|
||||||
|
JTLeft ->
|
||||||
|
let sel = "COALESCE (("
|
||||||
|
<> "SELECT json_agg(" <> H.sql (pgFmtIdent table) <> ".*) "
|
||||||
|
<> "FROM (" <> subquery <> ") " <> H.sql (pgFmtIdent table) <> " "
|
||||||
|
<> "), '[]') AS " <> H.sql (pgFmtIdent (fromMaybe name alias)) in
|
||||||
|
(j,sel:s)
|
||||||
|
getJoinsSelects _ _ = ([], [])
|
||||||
|
|
||||||
mutateRequestToQuery :: MutateRequest -> H.Snippet
|
mutateRequestToQuery :: MutateRequest -> H.Snippet
|
||||||
mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) =
|
mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) =
|
||||||
|
|||||||
@@ -206,11 +206,11 @@ pgFmtField :: QualifiedIdentifier -> Field -> H.Snippet
|
|||||||
pgFmtField table (c, jp) = H.sql (pgFmtColumn table c) <> pgFmtJsonPath jp
|
pgFmtField table (c, jp) = H.sql (pgFmtColumn table c) <> pgFmtJsonPath jp
|
||||||
|
|
||||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> H.Snippet
|
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> H.Snippet
|
||||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> H.sql (pgFmtAs fName jp alias)
|
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _, _) = pgFmtField table f <> H.sql (pgFmtAs fName jp alias)
|
||||||
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
||||||
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
|
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
|
||||||
-- Not quoting should be fine, we validate the input on Parsers.
|
-- Not quoting should be fine, we validate the input on Parsers.
|
||||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> H.sql (encodeUtf8 cast) <> " )" <> H.sql (pgFmtAs fName jp alias)
|
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _, _) = "CAST (" <> pgFmtField table f <> " AS " <> H.sql (encodeUtf8 cast) <> " )" <> H.sql (pgFmtAs fName jp alias)
|
||||||
|
|
||||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> H.Snippet
|
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> H.Snippet
|
||||||
pgFmtOrderTerm qi ot =
|
pgFmtOrderTerm qi ot =
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ import Protolude hiding (from)
|
|||||||
-- | Builds the ReadRequest tree on a number of stages.
|
-- | Builds the ReadRequest tree on a number of stages.
|
||||||
-- | Adds filters, order, limits on its respective nodes.
|
-- | Adds filters, order, limits on its respective nodes.
|
||||||
-- | Adds joins conditions obtained from resource embedding.
|
-- | Adds joins conditions obtained from resource embedding.
|
||||||
readRequest :: Schema -> TableName -> Maybe Integer -> [Relationship] -> ApiRequest -> Either Error ReadRequest
|
readRequest :: Schema -> TableName -> Maybe Integer -> JoinType -> [Relationship] -> ApiRequest -> Either Error ReadRequest
|
||||||
readRequest schema rootTableName maxRows allRels apiRequest =
|
readRequest schema rootTableName maxRows defJoinType allRels apiRequest =
|
||||||
mapLeft ApiRequestError $
|
mapLeft ApiRequestError $
|
||||||
treeRestrictRange maxRows =<<
|
treeRestrictRange maxRows =<<
|
||||||
augmentRequestWithJoin schema rootRels =<<
|
augmentRequestWithJoin schema rootRels defJoinType =<<
|
||||||
(addFiltersOrdersRanges apiRequest . initReadRequest rootName =<< pRequestSelect sel)
|
(addFiltersOrdersRanges apiRequest . initReadRequest rootName =<< pRequestSelect sel)
|
||||||
where
|
where
|
||||||
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
|
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
|
||||||
@@ -100,16 +100,16 @@ initReadRequest rootQi =
|
|||||||
rootDepth = 0
|
rootDepth = 0
|
||||||
rootSchema = qiSchema rootQi
|
rootSchema = qiSchema rootQi
|
||||||
rootName = qiName rootQi
|
rootName = qiName rootQi
|
||||||
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
|
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, Nothing, rootDepth)) []
|
||||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
||||||
treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =
|
treeEntry depth (Node fld@((fn, _),_,alias, hint, joinType) fldForest) (Node (q, i) rForest) =
|
||||||
let nxtDepth = succ depth in
|
let nxtDepth = succ depth in
|
||||||
case fldForest of
|
case fldForest of
|
||||||
[] -> Node (q {select=fld:select q}, i) rForest
|
[] -> Node (q {select=fld:select q}, i) rForest
|
||||||
_ -> Node (q, i) $
|
_ -> Node (q, i) $
|
||||||
foldr (treeEntry nxtDepth)
|
foldr (treeEntry nxtDepth)
|
||||||
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
|
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
|
||||||
(fn, Nothing, alias, embedHint, nxtDepth)) [])
|
(fn, Nothing, alias, hint, joinType, nxtDepth)) [])
|
||||||
fldForest:rForest
|
fldForest:rForest
|
||||||
|
|
||||||
-- | Enforces the `max-rows` config on the result
|
-- | Enforces the `max-rows` config on the result
|
||||||
@@ -119,26 +119,26 @@ treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
|
|||||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
||||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
||||||
|
|
||||||
augmentRequestWithJoin :: Schema -> [Relationship] -> ReadRequest -> Either ApiRequestError ReadRequest
|
augmentRequestWithJoin :: Schema -> [Relationship] -> JoinType -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
augmentRequestWithJoin schema allRels request =
|
augmentRequestWithJoin schema allRels defJoinType request =
|
||||||
addRels schema allRels Nothing request
|
addRels schema allRels Nothing defJoinType request
|
||||||
>>= addJoinConditions Nothing
|
>>= addJoinConditions Nothing
|
||||||
|
|
||||||
addRels :: Schema -> [Relationship] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addRels :: Schema -> [Relationship] -> Maybe ReadRequest -> JoinType -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
|
addRels schema allRels parentNode defJoinType (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, joinType, depth)) forest) =
|
||||||
case parentNode of
|
case parentNode of
|
||||||
Just (Node (Select{from=parentNodeQi}, _) _) ->
|
Just (Node (Select{from=parentNodeQi}, _) _) ->
|
||||||
let newFrom r = if qiName tbl == nodeName then tableQi (relForeignTable r) else tbl
|
let newFrom r = if qiName tbl == nodeName then tableQi (relForeignTable r) else tbl
|
||||||
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, hint, joinType <|> Just defJoinType, depth))) <$> rel
|
||||||
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
|
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
|
||||||
in
|
in
|
||||||
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
||||||
_ ->
|
_ ->
|
||||||
let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
|
let rn = (query, (nodeName, Nothing, alias, Nothing, joinType, depth)) in
|
||||||
Node rn <$> updateForest (Just $ Node rn forest)
|
Node rn <$> updateForest (Just $ Node rn forest)
|
||||||
where
|
where
|
||||||
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
||||||
updateForest rq = addRels schema allRels rq `traverse` forest
|
updateForest rq = addRels schema allRels rq defJoinType `traverse` forest
|
||||||
|
|
||||||
-- Finds a relationship between an origin and a target in the request:
|
-- Finds a relationship between an origin and a target in the request:
|
||||||
-- /origin?select=target(*) If more than one relationship is found then the
|
-- /origin?select=target(*) If more than one relationship is found then the
|
||||||
@@ -150,7 +150,7 @@ addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, a
|
|||||||
-- target = table / view / constraint / column-from-origin
|
-- target = table / view / constraint / column-from-origin
|
||||||
-- hint = table / view / constraint / column-from-origin / column-from-target
|
-- hint = table / view / constraint / column-from-origin / column-from-target
|
||||||
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
|
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
|
||||||
findRel :: Schema -> [Relationship] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relationship
|
findRel :: Schema -> [Relationship] -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
|
||||||
findRel schema allRels origin target hint =
|
findRel schema allRels origin target hint =
|
||||||
case rel of
|
case rel of
|
||||||
[] -> Left $ NoRelBetween origin target
|
[] -> Left $ NoRelBetween origin target
|
||||||
@@ -210,7 +210,7 @@ findRel schema allRels origin target hint =
|
|||||||
|
|
||||||
-- previousAlias is only used for the case of self joins
|
-- previousAlias is only used for the case of self joins
|
||||||
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
|
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, _, depth)) forest) =
|
||||||
case rel of
|
case rel of
|
||||||
Just r@Relationship{relCardinality=M2M Junction{junTable}} ->
|
Just r@Relationship{relCardinality=M2M Junction{junTable}} ->
|
||||||
let rq = augmentQuery r in
|
let rq = augmentQuery r in
|
||||||
@@ -307,7 +307,7 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
|
|||||||
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
|
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
|
||||||
Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
|
Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
|
||||||
where
|
where
|
||||||
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
pathNode = find (\(Node (_,(nodeName,_,alias,_,_, _)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
||||||
|
|
||||||
mutateRequest :: Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest
|
mutateRequest :: Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest
|
||||||
mutateRequest schema tName apiRequest pkCols readReq = mapLeft ApiRequestError $
|
mutateRequest schema tName apiRequest pkCols readReq = mapLeft ApiRequestError $
|
||||||
@@ -379,7 +379,7 @@ returningCols rr@(Node _ forest) pkCols
|
|||||||
-- projects. So this adds the foreign key columns to ensure the embedding
|
-- projects. So this adds the foreign key columns to ensure the embedding
|
||||||
-- succeeds, result would be `RETURNING name, client_id`.
|
-- succeeds, result would be `RETURNING name, client_id`.
|
||||||
fkCols = concat $ mapMaybe (\case
|
fkCols = concat $ mapMaybe (\case
|
||||||
Node (_, (_, Just Relationship{relColumns=cols}, _, _, _)) _ -> Just cols
|
Node (_, (_, Just Relationship{relColumns=cols}, _, _, _, _)) _ -> Just cols
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
) forest
|
) forest
|
||||||
-- However if the "client_id" is present, e.g. mutateRequest to
|
-- However if the "client_id" is present, e.g. mutateRequest to
|
||||||
|
|||||||
@@ -158,9 +158,23 @@ pRelationSelect :: Parser SelectItem
|
|||||||
pRelationSelect = lexeme $ try ( do
|
pRelationSelect = lexeme $ try ( do
|
||||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||||
fld <- pField
|
fld <- pField
|
||||||
hint <- optionMaybe $ char '!' *> pFieldName
|
prm1 <- optionMaybe pEmbedParam
|
||||||
return (fld, Nothing, alias, hint)
|
prm2 <- optionMaybe pEmbedParam
|
||||||
|
return (fld, Nothing, alias, embedParamHint prm1 <|> embedParamHint prm2, embedParamJoin prm1 <|> embedParamJoin prm2)
|
||||||
)
|
)
|
||||||
|
where
|
||||||
|
pEmbedParam :: Parser EmbedParam
|
||||||
|
pEmbedParam =
|
||||||
|
char '!' *> (
|
||||||
|
try (string "left" $> EPJoinType JTLeft) <|>
|
||||||
|
try (string "inner" $> EPJoinType JTInner) <|>
|
||||||
|
try (EPHint <$> pFieldName))
|
||||||
|
embedParamHint prm = case prm of
|
||||||
|
Just (EPHint hint) -> Just hint
|
||||||
|
_ -> Nothing
|
||||||
|
embedParamJoin prm = case prm of
|
||||||
|
Just (EPJoinType jt) -> Just jt
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
pFieldSelect :: Parser SelectItem
|
pFieldSelect :: Parser SelectItem
|
||||||
pFieldSelect = lexeme $
|
pFieldSelect = lexeme $
|
||||||
@@ -169,11 +183,11 @@ pFieldSelect = lexeme $
|
|||||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||||
fld <- pField
|
fld <- pField
|
||||||
cast' <- optionMaybe (string "::" *> many letter)
|
cast' <- optionMaybe (string "::" *> many letter)
|
||||||
return (fld, toS <$> cast', alias, Nothing)
|
return (fld, toS <$> cast', alias, Nothing, Nothing)
|
||||||
)
|
)
|
||||||
<|> do
|
<|> do
|
||||||
s <- pStar
|
s <- pStar
|
||||||
return ((s, []), Nothing, Nothing, Nothing)
|
return ((s, []), Nothing, Nothing, Nothing, Nothing)
|
||||||
|
|
||||||
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
||||||
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
module PostgREST.Request.Types
|
module PostgREST.Request.Types
|
||||||
( Alias
|
( Alias
|
||||||
, Depth
|
, Depth
|
||||||
, EmbedHint
|
, EmbedParam(..)
|
||||||
, EmbedPath
|
, EmbedPath
|
||||||
, Field
|
, Field
|
||||||
, Filter(..)
|
, Filter(..)
|
||||||
|
, Hint
|
||||||
, CallQuery(..)
|
, CallQuery(..)
|
||||||
, CallParams(..)
|
, CallParams(..)
|
||||||
, CallRequest
|
, CallRequest
|
||||||
, JoinCondition(..)
|
, JoinCondition(..)
|
||||||
|
, JoinType(..)
|
||||||
, JsonOperand(..)
|
, JsonOperand(..)
|
||||||
, JsonOperation(..)
|
, JsonOperation(..)
|
||||||
, JsonPath
|
, JsonPath
|
||||||
@@ -54,7 +56,7 @@ type MutateRequest = MutateQuery
|
|||||||
type CallRequest = CallQuery
|
type CallRequest = CallQuery
|
||||||
|
|
||||||
type ReadNode =
|
type ReadNode =
|
||||||
(ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe EmbedHint, Depth))
|
(ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe Hint, Maybe JoinType, Depth))
|
||||||
|
|
||||||
type NodeName = Text
|
type NodeName = Text
|
||||||
type Depth = Integer
|
type Depth = Integer
|
||||||
@@ -140,16 +142,27 @@ data CallParams
|
|||||||
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
|
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
|
||||||
|
|
||||||
-- | The select value in `/tbl?select=alias:field::cast`
|
-- | The select value in `/tbl?select=alias:field::cast`
|
||||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint)
|
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe Hint, Maybe JoinType)
|
||||||
|
|
||||||
type Field = (FieldName, JsonPath)
|
type Field = (FieldName, JsonPath)
|
||||||
type Cast = Text
|
type Cast = Text
|
||||||
type Alias = Text
|
type Alias = Text
|
||||||
|
type Hint = Text
|
||||||
|
|
||||||
-- | Disambiguates an embedding operation when there's multiple relationships
|
data EmbedParam
|
||||||
-- between two tables. Can be the name of a foreign key constraint, column
|
-- | Disambiguates an embedding operation when there's multiple relationships
|
||||||
-- name or the junction in an m2m relationship.
|
-- between two tables. Can be the name of a foreign key constraint, column
|
||||||
type EmbedHint = Text
|
-- name or the junction in an m2m relationship.
|
||||||
|
= EPHint Hint
|
||||||
|
| EPJoinType JoinType
|
||||||
|
|
||||||
|
data JoinType
|
||||||
|
= JTInner
|
||||||
|
| JTLeft
|
||||||
|
deriving Eq
|
||||||
|
instance Show JoinType where
|
||||||
|
show JTInner = "inner"
|
||||||
|
show JTLeft = "left"
|
||||||
|
|
||||||
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path
|
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path
|
||||||
-- ["clients", "projects"]
|
-- ["clients", "projects"]
|
||||||
@@ -176,7 +189,7 @@ data JsonOperand
|
|||||||
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
|
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
|
||||||
fstFieldNames :: ReadRequest -> [FieldName]
|
fstFieldNames :: ReadRequest -> [FieldName]
|
||||||
fstFieldNames (Node (sel, _) _) =
|
fstFieldNames (Node (sel, _) _) =
|
||||||
fst . (\(f, _, _, _) -> f) <$> select sel
|
fst . (\(f, _, _, _, _) -> f) <$> select sel
|
||||||
|
|
||||||
|
|
||||||
-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
module Feature.EmbedInnerJoinSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
|
spec :: SpecWith ((), Application)
|
||||||
|
spec =
|
||||||
|
describe "Embedding with an inner join" $ do
|
||||||
|
context "many-to-one relationships" $ do
|
||||||
|
it "ignores null embeddings while the default left join doesn't" $ do
|
||||||
|
get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
|
||||||
|
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
|
||||||
|
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
|
||||||
|
{"id":5,"clients":null}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when the embedded table is filtered" $ do
|
||||||
|
get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"clients":{"id":1}},
|
||||||
|
{"id":2,"clients":{"id":1}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":3,"clients":{"id":2}},
|
||||||
|
{"id":4,"clients":{"id":2}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
|
||||||
|
[json|[]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||||
|
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
|
||||||
|
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
|
||||||
|
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
|
||||||
|
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
|
||||||
|
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
|
||||||
|
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
|
||||||
|
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "only affects the source table rows if his direct embedding is an inner join" $
|
||||||
|
get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"projects":null},
|
||||||
|
{"id":2,"projects":null},
|
||||||
|
{"id":3,"projects":null},
|
||||||
|
{"id":4,"projects":null},
|
||||||
|
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
|
||||||
|
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
|
||||||
|
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
|
||||||
|
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works with views" $
|
||||||
|
get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
|
||||||
|
[json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "one-to-many relationships" $ do
|
||||||
|
it "ignores empty array embeddings while the default left join doesn't" $ do
|
||||||
|
get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
|
||||||
|
{"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
|
||||||
|
{"id":2,"child_entities":[{"id":3}, {"id":6}]},
|
||||||
|
{"id":3,"child_entities":[]},
|
||||||
|
{"id":4,"child_entities":[]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when the embedded table is filtered" $ do
|
||||||
|
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"child_entities":[{"id":1}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
|
||||||
|
[json|[{"id":2,"child_entities":[{"id":3}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
|
||||||
|
[json|[]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||||
|
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"child_entities": [
|
||||||
|
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
|
||||||
|
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
|
||||||
|
}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"child_entities": [
|
||||||
|
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
|
||||||
|
}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "only affects the source table rows if his direct embedding is an inner join" $
|
||||||
|
get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"child_entities": [
|
||||||
|
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] },
|
||||||
|
{ "id": 2, "grandchild_entities": [] },
|
||||||
|
{ "id": 4, "grandchild_entities": [] },
|
||||||
|
{ "id": 5, "grandchild_entities": [] } ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"child_entities": [
|
||||||
|
{ "id": 3, "grandchild_entities": [] },
|
||||||
|
{ "id": 6, "grandchild_entities": [] } ]
|
||||||
|
}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works with views" $
|
||||||
|
get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
|
||||||
|
[json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "many-to-many relationships" $ do
|
||||||
|
it "ignores empty array embeddings while the default left join doesn't" $ do
|
||||||
|
get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
|
||||||
|
{"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
|
||||||
|
{"id":2,"suppliers":[{"id":1}, {"id":3}]},
|
||||||
|
{"id":3,"suppliers":[]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when the embedded table is filtered" $ do
|
||||||
|
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
|
||||||
|
[json| [{"id":1,"suppliers":[{"id":2}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
|
||||||
|
[json| [{"id":2,"suppliers":[{"id":3}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||||
|
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "only affects the source table rows if his direct embedding is an inner join" $
|
||||||
|
get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
|
||||||
|
{"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works with views" $ do
|
||||||
|
get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
|
||||||
|
[json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
|
||||||
|
[json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works with m2o and m2m relationships combined" $
|
||||||
|
get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
|
||||||
|
{"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
|
||||||
|
{"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
|
||||||
|
{"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works with rpc" $
|
||||||
|
get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
|
||||||
|
[json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works when using hints" $ do
|
||||||
|
get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
|
||||||
|
[json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
|
||||||
|
[json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
notDefaultConfig :: SpecWith ((), Application)
|
||||||
|
notDefaultConfig =
|
||||||
|
describe "Embedding with a default inner join(db-embed-default-join = 'inner')" $ do
|
||||||
|
it "works on many-to-one relationships" $
|
||||||
|
get "/tasks?select=id,projects(id,clients(id))&projects.clients.id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
|
||||||
|
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
|
||||||
|
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
|
||||||
|
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works on one-to-many relationships" $
|
||||||
|
get "/entities?select=id,child_entities(id,grandchild_entities(id))&child_entities.grandchild_entities.id=in.(1,5)"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"child_entities": [
|
||||||
|
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
|
||||||
|
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
|
||||||
|
}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works on many-to-many relationships" $
|
||||||
|
get "/products?select=id,suppliers(id,trade_unions(id))&suppliers.trade_unions.id=eq.3"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can restore default left join behavior" $
|
||||||
|
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
|
||||||
|
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
|
||||||
|
{"id":5,"clients":null}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
@@ -30,6 +30,7 @@ import qualified Feature.CorsSpec
|
|||||||
import qualified Feature.DeleteSpec
|
import qualified Feature.DeleteSpec
|
||||||
import qualified Feature.DisabledOpenApiSpec
|
import qualified Feature.DisabledOpenApiSpec
|
||||||
import qualified Feature.EmbedDisambiguationSpec
|
import qualified Feature.EmbedDisambiguationSpec
|
||||||
|
import qualified Feature.EmbedInnerJoinSpec
|
||||||
import qualified Feature.ExtraSearchPathSpec
|
import qualified Feature.ExtraSearchPathSpec
|
||||||
import qualified Feature.HtmlRawOutputSpec
|
import qualified Feature.HtmlRawOutputSpec
|
||||||
import qualified Feature.IgnorePrivOpenApiSpec
|
import qualified Feature.IgnorePrivOpenApiSpec
|
||||||
@@ -94,6 +95,7 @@ main = do
|
|||||||
|
|
||||||
let withApp = app testCfg
|
let withApp = app testCfg
|
||||||
maxRowsApp = app testMaxRowsCfg
|
maxRowsApp = app testMaxRowsCfg
|
||||||
|
embedInnerJoinApp = app testEmbedInnerJoinCfg
|
||||||
disabledOpenApi = app testDisabledOpenApiCfg
|
disabledOpenApi = app testDisabledOpenApiCfg
|
||||||
proxyApp = app testProxyCfg
|
proxyApp = app testProxyCfg
|
||||||
noJwtApp = app testCfgNoJWT
|
noJwtApp = app testCfgNoJWT
|
||||||
@@ -125,6 +127,7 @@ main = do
|
|||||||
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||||
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
||||||
, ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
|
, ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
|
||||||
|
, ("Feature.EmbedInnerJoinSpec" , Feature.EmbedInnerJoinSpec.spec)
|
||||||
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
|
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
|
||||||
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
|
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
|
||||||
, ("Feature.OpenApiSpec" , Feature.OpenApiSpec.spec actualPgVersion)
|
, ("Feature.OpenApiSpec" , Feature.OpenApiSpec.spec actualPgVersion)
|
||||||
@@ -207,6 +210,10 @@ main = do
|
|||||||
parallel $ before multipleSchemaApp $
|
parallel $ before multipleSchemaApp $
|
||||||
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
|
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
|
||||||
|
|
||||||
|
-- this test runs with db-embed-default-join = inner
|
||||||
|
before embedInnerJoinApp $
|
||||||
|
describe "Feature.EmbedInnerJoinSpecNotDefaultConfig" Feature.EmbedInnerJoinSpec.notDefaultConfig
|
||||||
|
|
||||||
-- Note: the rollback tests can not run in parallel, because they test persistance and
|
-- Note: the rollback tests can not run in parallel, because they test persistance and
|
||||||
-- this results in race conditions
|
-- this results in race conditions
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import PostgREST.Config (AppConfig (..),
|
|||||||
OpenAPIMode (..),
|
OpenAPIMode (..),
|
||||||
parseSecret)
|
parseSecret)
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
||||||
|
import PostgREST.Request.Types (JoinType (..))
|
||||||
import Protolude hiding (toS)
|
import Protolude hiding (toS)
|
||||||
import Protolude.Conv (toS)
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ _baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
|||||||
, configDbSchemas = fromList ["test"]
|
, configDbSchemas = fromList ["test"]
|
||||||
, configDbConfig = False
|
, configDbConfig = False
|
||||||
, configDbUri = mempty
|
, configDbUri = mempty
|
||||||
|
, configDbEmbedDefaultJoin = JTLeft
|
||||||
, configFilePath = Nothing
|
, configFilePath = Nothing
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = parseSecret <$> secret
|
||||||
, configJwtAudience = Nothing
|
, configJwtAudience = Nothing
|
||||||
@@ -125,6 +127,9 @@ testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["
|
|||||||
testMaxRowsCfg :: Text -> AppConfig
|
testMaxRowsCfg :: Text -> AppConfig
|
||||||
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 }
|
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 }
|
||||||
|
|
||||||
|
testEmbedInnerJoinCfg :: Text -> AppConfig
|
||||||
|
testEmbedInnerJoinCfg testDbConn = (testCfg testDbConn) { configDbEmbedDefaultJoin = JTInner }
|
||||||
|
|
||||||
testDisabledOpenApiCfg :: Text -> AppConfig
|
testDisabledOpenApiCfg :: Text -> AppConfig
|
||||||
testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }
|
testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }
|
||||||
|
|
||||||
|
|||||||
Vendored
+16
-1
@@ -691,4 +691,19 @@ DO $do$BEGIN
|
|||||||
INSERT INTO test.reference_to_partitioned(id) VALUES (1);
|
INSERT INTO test.reference_to_partitioned(id) VALUES (1);
|
||||||
INSERT INTO test.reference_to_partitioned(id, id_a, name_a) VALUES (2, 2, 'first');
|
INSERT INTO test.reference_to_partitioned(id, id_a, name_a) VALUES (2, 2, 'first');
|
||||||
END IF;
|
END IF;
|
||||||
END$do$;
|
END$do$;
|
||||||
|
|
||||||
|
TRUNCATE TABLE test.products CASCADE;
|
||||||
|
INSERT INTO test.products (id, name) VALUES (1,'product-1'), (2,'product-2'), (3,'product-3');
|
||||||
|
|
||||||
|
TRUNCATE TABLE test.suppliers CASCADE;
|
||||||
|
INSERT INTO test.suppliers (id, name) VALUES (1,'supplier-1'), (2,'supplier-2'), (3, 'supplier-3');
|
||||||
|
|
||||||
|
TRUNCATE TABLE test.products_suppliers CASCADE;
|
||||||
|
INSERT INTO test.products_suppliers (product_id, supplier_id) VALUES (1,1), (1,2), (2,1), (2,3);
|
||||||
|
|
||||||
|
TRUNCATE TABLE test.trade_unions CASCADE;
|
||||||
|
INSERT INTO test.trade_unions (id, name) VALUES (1,'union-1'), (2,'union-2'), (3, 'union-3'), (4, 'union-4');
|
||||||
|
|
||||||
|
TRUNCATE TABLE test.suppliers_trade_unions CASCADE;
|
||||||
|
INSERT INTO test.suppliers_trade_unions (supplier_id, trade_union_id) VALUES (1,1), (1,2), (2,3), (2,4);
|
||||||
Vendored
+5
@@ -151,6 +151,11 @@ GRANT ALL ON TABLE
|
|||||||
, schauspieler
|
, schauspieler
|
||||||
, filme
|
, filme
|
||||||
, rollen
|
, rollen
|
||||||
|
, products
|
||||||
|
, suppliers
|
||||||
|
, products_suppliers
|
||||||
|
, trade_unions
|
||||||
|
, suppliers_trade_unions
|
||||||
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;
|
||||||
|
|||||||
Vendored
+27
@@ -2284,3 +2284,30 @@ $$ language sql;
|
|||||||
create or replace function test.overloaded_unnamed_param(x int, y int) returns int as $$
|
create or replace function test.overloaded_unnamed_param(x int, y int) returns int as $$
|
||||||
select x + y;
|
select x + y;
|
||||||
$$ language sql;
|
$$ language sql;
|
||||||
|
|
||||||
|
create table products(
|
||||||
|
id int primary key
|
||||||
|
, name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table suppliers(
|
||||||
|
id int primary key
|
||||||
|
, name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table products_suppliers(
|
||||||
|
product_id int references products(id),
|
||||||
|
supplier_id int references suppliers(id),
|
||||||
|
primary key (product_id, supplier_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table trade_unions(
|
||||||
|
id int primary key
|
||||||
|
, name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table suppliers_trade_unions(
|
||||||
|
supplier_id int references suppliers(id),
|
||||||
|
trade_union_id int references trade_unions(id),
|
||||||
|
primary key (supplier_id, trade_union_id)
|
||||||
|
);
|
||||||
@@ -12,6 +12,7 @@ db-schemas = "provided_through_alias"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "commit"
|
db-tx-end = "commit"
|
||||||
db-uri = "required"
|
db-uri = "required"
|
||||||
|
db-embed-default-join = "left"
|
||||||
jwt-aud = ""
|
jwt-aud = ""
|
||||||
jwt-role-claim-key = ".\"aliased\""
|
jwt-role-claim-key = ".\"aliased\""
|
||||||
jwt-secret = ""
|
jwt-secret = ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "required"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "commit"
|
db-tx-end = "commit"
|
||||||
db-uri = "required"
|
db-uri = "required"
|
||||||
|
db-embed-default-join = "left"
|
||||||
jwt-aud = ""
|
jwt-aud = ""
|
||||||
jwt-role-claim-key = ".\"role\""
|
jwt-role-claim-key = ".\"role\""
|
||||||
jwt-secret = ""
|
jwt-secret = ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "required"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "commit"
|
db-tx-end = "commit"
|
||||||
db-uri = "required"
|
db-uri = "required"
|
||||||
|
db-embed-default-join = "left"
|
||||||
jwt-aud = ""
|
jwt-aud = ""
|
||||||
jwt-role-claim-key = ".\"role\""
|
jwt-role-claim-key = ".\"role\""
|
||||||
jwt-secret = ""
|
jwt-secret = ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "required"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "commit"
|
db-tx-end = "commit"
|
||||||
db-uri = "required"
|
db-uri = "required"
|
||||||
|
db-embed-default-join = "left"
|
||||||
jwt-aud = ""
|
jwt-aud = ""
|
||||||
jwt-role-claim-key = ".\"role\""
|
jwt-role-claim-key = ".\"role\""
|
||||||
jwt-secret = ""
|
jwt-secret = ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "test,other_tenant1,other_tenant2"
|
|||||||
db-config = "true"
|
db-config = "true"
|
||||||
db-tx-end = "rollback-allow-override"
|
db-tx-end = "rollback-allow-override"
|
||||||
db-uri = "<REPLACED_WITH_DB_URI>"
|
db-uri = "<REPLACED_WITH_DB_URI>"
|
||||||
|
db-embed-default-join = "inner"
|
||||||
jwt-aud = "https://otherexample.org"
|
jwt-aud = "https://otherexample.org"
|
||||||
jwt-role-claim-key = ".\"other\".\"role\""
|
jwt-role-claim-key = ".\"other\".\"role\""
|
||||||
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
|
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "test,tenant1,tenant2"
|
|||||||
db-config = "true"
|
db-config = "true"
|
||||||
db-tx-end = "commit-allow-override"
|
db-tx-end = "commit-allow-override"
|
||||||
db-uri = "<REPLACED_WITH_DB_URI>"
|
db-uri = "<REPLACED_WITH_DB_URI>"
|
||||||
|
db-embed-default-join = "inner"
|
||||||
jwt-aud = "https://example.org"
|
jwt-aud = "https://example.org"
|
||||||
jwt-role-claim-key = ".\"a\".\"role\""
|
jwt-role-claim-key = ".\"a\".\"role\""
|
||||||
jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE"
|
jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "multi,tenant,setup"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "rollback-allow-override"
|
db-tx-end = "rollback-allow-override"
|
||||||
db-uri = "tmp_db"
|
db-uri = "tmp_db"
|
||||||
|
db-embed-default-join = "inner"
|
||||||
jwt-aud = "https://postgrest.org"
|
jwt-aud = "https://postgrest.org"
|
||||||
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
||||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "required"
|
|||||||
db-config = "true"
|
db-config = "true"
|
||||||
db-tx-end = "commit"
|
db-tx-end = "commit"
|
||||||
db-uri = "required"
|
db-uri = "required"
|
||||||
|
db-embed-default-join = "left"
|
||||||
jwt-aud = ""
|
jwt-aud = ""
|
||||||
jwt-role-claim-key = ".\"role\""
|
jwt-role-claim-key = ".\"role\""
|
||||||
jwt-secret = ""
|
jwt-secret = ""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ PGRST_DB_SCHEMAS: multi, tenant,setup
|
|||||||
PGRST_DB_CONFIG: false
|
PGRST_DB_CONFIG: false
|
||||||
PGRST_DB_TX_END: rollback-allow-override
|
PGRST_DB_TX_END: rollback-allow-override
|
||||||
PGRST_DB_URI: tmp_db
|
PGRST_DB_URI: tmp_db
|
||||||
|
PGRST_DB_EMBED_DEFAULT_JOIN: inner
|
||||||
PGRST_JWT_AUD: 'https://postgrest.org'
|
PGRST_JWT_AUD: 'https://postgrest.org'
|
||||||
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
||||||
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
|
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ db-schemas = "multi, tenant,setup"
|
|||||||
db-config = "false"
|
db-config = "false"
|
||||||
db-tx-end = "rollback-allow-override"
|
db-tx-end = "rollback-allow-override"
|
||||||
db-uri = "tmp_db"
|
db-uri = "tmp_db"
|
||||||
|
db-embed-default-join = "inner"
|
||||||
jwt-aud = "https://postgrest.org"
|
jwt-aud = "https://postgrest.org"
|
||||||
jwt-role-claim-key = ".user[0].\"real-role\""
|
jwt-role-claim-key = ".user[0].\"real-role\""
|
||||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||||
|
|||||||
@@ -179,3 +179,8 @@ invalidopenapimodes:
|
|||||||
- 'follow-'
|
- 'follow-'
|
||||||
- 'ignore-'
|
- 'ignore-'
|
||||||
- '.#$$%&$%/'
|
- '.#$$%&$%/'
|
||||||
|
|
||||||
|
invalidjointypes:
|
||||||
|
- 'left!'
|
||||||
|
- 'right'
|
||||||
|
- '.#$$%&$%/'
|
||||||
|
|||||||
@@ -440,6 +440,21 @@ def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
|
|||||||
print(line)
|
print(line)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalidjointypes", FIXTURES["invalidjointypes"])
|
||||||
|
def test_invalid_db_embed_default_join(invalidjointypes, defaultenv):
|
||||||
|
"Given an invalid db-embed-default-join, Postgrest should exit with a non-zero exit code."
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"PGRST_DB_EMBED_DEFAULT_JOIN": invalidjointypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(PostgrestError):
|
||||||
|
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
|
||||||
|
for line in dump.split("\n"):
|
||||||
|
if line.startswith("db-embed-default-join"):
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
|
||||||
def test_iat_claim(defaultenv):
|
def test_iat_claim(defaultenv):
|
||||||
"""
|
"""
|
||||||
A claim with an 'iat' (issued at) attribute should be successful.
|
A claim with an 'iat' (issued at) attribute should be successful.
|
||||||
|
|||||||
Reference in New Issue
Block a user