feat: add spread embeds

This commit is contained in:
steve-chavez
2022-11-16 21:26:16 -05:00
committed by Steve Chavez
parent 78d45b4e32
commit 2aa0e091bb
10 changed files with 200 additions and 31 deletions
+3 -1
View File
@@ -8,7 +8,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
- #1414, Add related orders - @steve-chavez
+ On a many-to-one or one-to-one relationship, you can order a parent by a child column `/projects?select=*,clients(*)&order=clients(name).desc.nullsfirst`
+ On a many-to-one or one-to-one relationship, you can order a parent by a child column `/projects?select=*,clients(*)&order=clients(name).desc.nullsfirst`
- #1233, Allow spreading embedded resources - @steve-chavez
+ On a many-to-one or one-to-one relationship, you can unnest a json object with `/projects?select=*,..clients(client_name:name)`
### Fixed
+1
View File
@@ -212,6 +212,7 @@ test-suite spec
Feature.Query.RelatedQueriesSpec
Feature.Query.RpcSpec
Feature.Query.SingularSpec
Feature.Query.SpreadQueriesSpec
Feature.Query.UnicodeSpec
Feature.Query.UpdateSpec
Feature.Query.UpsertSpec
+58 -18
View File
@@ -46,7 +46,8 @@ import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
Filter (..), FtsOperator (..),
JoinType (..), JsonOperand (..),
Hint, JoinType (..),
JsonOperand (..),
JsonOperation (..), JsonPath,
ListVal, LogicOperator (..),
LogicTree (..), OpExpr (..),
@@ -317,6 +318,9 @@ pTreePath = do
-- >>> P.parse pFieldForest "" "*,client(*,nested(*))"
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}]
--
-- >>> P.parse pFieldForest "" "*,..client(*),other(*)"
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
--
-- >>> P.parse pFieldForest "" "id,clients(name[])"
-- Left (line 1, column 16):
-- unexpected '['
@@ -325,7 +329,8 @@ pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
where
pFieldTree :: Parser (Tree SelectItem)
pFieldTree = try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
pFieldTree = try (Node <$> pSpreadRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
Node <$> pFieldSelect <*> pure []
pStar :: Parser Text
@@ -454,24 +459,10 @@ pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
name <- pFieldName
prm1 <- optionMaybe pEmbedParam
prm2 <- optionMaybe pEmbedParam
(hint, jType) <- pEmbedParams
try (void $ lookAhead (string "("))
return $ SelectRelation name alias (embedParamHint prm1 <|> embedParamHint prm2) (embedParamJoin prm1 <|> embedParamJoin prm2)
return $ SelectRelation name alias hint jType
)
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
-- |
-- Parse regular fields in select
@@ -527,6 +518,55 @@ pFieldSelect = lexeme $
try (void $ lookAhead (string ",")) <|>
try eof
-- |
-- Parse spread relations in select
--
-- >>> P.parse pSpreadRelationSelect "" "..rel(*)"
-- Right (SpreadRelation {selRelation = "rel", selHint = Nothing, selJoinType = Nothing})
--
-- >>> P.parse pSpreadRelationSelect "" "..rel!hint!inner(*)"
-- Right (SpreadRelation {selRelation = "rel", selHint = Just "hint", selJoinType = Just JTInner})
--
-- >>> P.parse pSpreadRelationSelect "" "rel(*)"
-- Left (line 1, column 1):
-- unexpected "r"
-- expecting "..."
--
-- >>> P.parse pSpreadRelationSelect "" "alias:..rel(*)"
-- Left (line 1, column 1):
-- unexpected "a"
-- expecting ".."
--
-- >>> P.parse pSpreadRelationSelect "" "..rel->jsonpath(*)"
-- Left (line 1, column 8):
-- unexpected '>'
pSpreadRelationSelect :: Parser SelectItem
pSpreadRelationSelect = lexeme $ try ( do
name <- string ".." >> pFieldName
(hint, jType) <- pEmbedParams
try (void $ lookAhead (string "("))
return $ SpreadRelation name hint jType
)
pEmbedParams :: Parser (Maybe Hint, Maybe JoinType)
pEmbedParams = do
prm1 <- optionMaybe pEmbedParam
prm2 <- optionMaybe pEmbedParam
return (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
-- |
-- Parse operator expression used in horizontal filtering
--
+9 -1
View File
@@ -38,19 +38,26 @@ import PostgREST.SchemaCache.Relationship (Relationship)
import Protolude
-- | The select value in `/tbl?select=alias:field::cast`
-- | The value in `/tbl?select=alias:field::cast`
data SelectItem
= SelectField
{ selField :: Field
, selCast :: Maybe Cast
, selAlias :: Maybe Alias
}
-- | The value in `/tbl?select=alias:another_tbl(*)`
| SelectRelation
{ selRelation :: FieldName
, selAlias :: Maybe Alias
, selHint :: Maybe Hint
, selJoinType :: Maybe JoinType
}
-- | The value in `/tbl?select=...another_tbl(*)`
| SpreadRelation
{ selRelation :: FieldName
, selHint :: Maybe Hint
, selJoinType :: Maybe JoinType
}
deriving (Eq)
data ApiRequestError
@@ -71,6 +78,7 @@ data ApiRequestError
| ParseRequestError Text Text
| PutRangeNotAllowedError
| QueryParamError QPError
| SpreadNotToOne Text Text
| UnacceptableSchema [Text]
| UnsupportedMethod ByteString
+9
View File
@@ -72,6 +72,7 @@ instance PgrstError ApiRequestError where
status ParseRequestError{} = HTTP.status400
status PutRangeNotAllowedError = HTTP.status400
status QueryParamError{} = HTTP.status400
status SpreadNotToOne{} = HTTP.status400
status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
@@ -159,6 +160,12 @@ instance JSON.ToJSON ApiRequestError where
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (SpreadNotToOne origin target) = JSON.object [
"code" .= ApiRequestErrorCode19,
"message" .= ("A spread operation on '" <> target <> "' is not possible" :: Text),
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
"hint" .= JSON.Null]
toJSON (NoRelBetween parent child schema) = JSON.object [
"code" .= SchemaCacheErrorCode00,
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
@@ -471,6 +478,7 @@ data ErrorCode
| ApiRequestErrorCode16
| ApiRequestErrorCode17
| ApiRequestErrorCode18
| ApiRequestErrorCode19
-- Schema Cache errors
| SchemaCacheErrorCode00
| SchemaCacheErrorCode01
@@ -513,6 +521,7 @@ buildErrorCode code = "PGRST" <> case code of
ApiRequestErrorCode16 -> "116"
ApiRequestErrorCode17 -> "117"
ApiRequestErrorCode18 -> "118"
ApiRequestErrorCode19 -> "119"
SchemaCacheErrorCode00 -> "200"
SchemaCacheErrorCode01 -> "201"
+25 -7
View File
@@ -97,6 +97,7 @@ readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Eit
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbRelationships} apiRequest =
mapLeft ApiRequestError $
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
validateSpreadEmbeds =<<
addRelatedOrders =<<
addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
addLogicTrees apiRequest =<<
@@ -110,15 +111,23 @@ initReadRequest qi@QualifiedIdentifier{..} =
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi, relName=qiName, depth=rootDepth} []
where
rootDepth = 0
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing rootDepth
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False rootDepth
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
treeEntry depth (Node SelectRelation{..} fldForest) (Node q rForest) =
treeEntry depth (Node si fldForest) (Node q rForest) =
let nxtDepth = succ depth in
Node q $
foldr (treeEntry nxtDepth)
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relAlias=selAlias, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth} [])
fldForest:rForest
treeEntry _ (Node SelectField{..} _) (Node q rForest) = Node q{select=(selField, selCast, selAlias):select q} rForest
case si of
SelectRelation{..} ->
Node q $
foldr (treeEntry nxtDepth)
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relAlias=selAlias, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth} [])
fldForest:rForest
SpreadRelation{..} ->
Node q $
foldr (treeEntry nxtDepth)
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} [])
fldForest:rForest
SelectField{..} ->
Node q{select=(selField, selCast, selAlias):select q} rForest
-- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
@@ -344,6 +353,15 @@ addLogicTrees ApiRequest{..} rReq =
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{where_=lf} f) -> Node q{ReadPlan.where_=t:lf} f)
-- Validates that spread embeds are only done on to-one relationships
validateSpreadEmbeds :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
validateSpreadEmbeds (Node rp@ReadPlan{relToParent=Nothing} forest) = Node rp <$> validateSpreadEmbeds `traverse` forest
validateSpreadEmbeds (Node rp@ReadPlan{relIsSpread,relToParent=Just rel,relName} forest) = do
validRP <- if relIsSpread && not (relIsToOne rel)
then Left $ SpreadNotToOne (qiName $ relTable rel) relName -- TODO using relTable is not entirely right because ReadPlan might have an alias, need to store the parent alias on ReadPlan
else Right rp
Node validRP <$> validateSpreadEmbeds `traverse` forest
-- Find a Node of the Tree and apply a function to it
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
updateNode f ([], a) rr = f a <$> rr
+1
View File
@@ -41,6 +41,7 @@ data ReadPlan = ReadPlan
, relAggAlias :: Alias
, relHint :: Maybe Hint
, relJoinType :: Maybe JoinType
, relIsSpread :: Bool
, depth :: Depth
-- ^ used for aliasing
}
+6 -4
View File
@@ -57,23 +57,25 @@ readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,o
getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], [])
getSelectsJoins rr@(Node ReadPlan{relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType=joinType} _) (selects,joins) =
getSelectsJoins rr@(Node ReadPlan{relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType, relIsSpread} _) (selects,joins) =
let
subquery = readPlanToQuery rr
aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
aggAlias = pgFmtIdent relAggAlias
correlatedSubquery sub al cond =
(if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
(if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
(sel, joi) = if relIsToOne rel
then
( SQL.sql ("row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName)
( if relIsSpread
then SQL.sql aggAlias <> ".*"
else SQL.sql ("row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName)
, correlatedSubquery subquery aggAlias "TRUE")
else
( SQL.sql $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
, correlatedSubquery (
"SELECT json_agg(" <> SQL.sql aggAlias <> ") AS " <> SQL.sql aggAlias <>
"FROM (" <> subquery <> " ) AS " <> SQL.sql aggAlias
) aggAlias $ if joinType == Just JTInner then SQL.sql aggAlias <> " IS NOT NULL" else "TRUE")
) aggAlias $ if relJoinType == Just JTInner then SQL.sql aggAlias <> " IS NOT NULL" else "TRUE")
in
(sel:selects, joi:joins)
@@ -0,0 +1,86 @@
module Feature.Query.SpreadQueriesSpec 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 "spread embeds" $ do
it "works on a many-to-one relationship" $ do
get "/projects?select=id,..clients(client_name:name)" `shouldRespondWith`
[json|[
{"id":1,"client_name":"Microsoft"},
{"id":2,"client_name":"Microsoft"},
{"id":3,"client_name":"Apple"},
{"id":4,"client_name":"Apple"},
{"id":5,"client_name":null}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
get "/grandchild_entities?select=name,..child_entities(parent_name:name,..entities(grandparent_name:name))&limit=3" `shouldRespondWith`
[json|[
{"name":"grandchild entity 1","parent_name":"child entity 1","grandparent_name":"entity 1"},
{"name":"grandchild entity 2","parent_name":"child entity 1","grandparent_name":"entity 1"},
{"name":"grandchild entity 3","parent_name":"child entity 2","grandparent_name":"entity 1"}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
get "/videogames?select=name,..computed_designers(designer_name: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"}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "works inside a normal embed" $
get "/grandchild_entities?select=name,child_entity:child_entities(name,..entities(parent_name:name))&limit=1" `shouldRespondWith`
[json|[
{"name":"grandchild entity 1","child_entity":{"name":"child entity 1","parent_name":"entity 1"}}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "works on a one-to-one relationship" $
get "/country?select=name,..capital(capital:name)" `shouldRespondWith`
[json|[
{"name":"Afghanistan","capital":"Kabul"},
{"name":"Algeria","capital":"Algiers"}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "fails when is not a to-one relationship" $ do
get "/clients?select=*,..projects(*)" `shouldRespondWith`
[json|{
"code":"PGRST119",
"details":"'clients' and 'projects' do not form a many-to-one or one-to-one relationship",
"hint":null,
"message":"A spread operation on 'projects' is not possible"
}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/designers?select=*,..computed_videogames(*)" `shouldRespondWith`
[json|{
"code":"PGRST119",
"details":"'designers' and 'computed_videogames' do not form a many-to-one or one-to-one relationship",
"hint":null,
"message":"A spread operation on 'computed_videogames' is not possible"
}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
+2
View File
@@ -56,6 +56,7 @@ import qualified Feature.Query.RawOutputTypesSpec
import qualified Feature.Query.RelatedQueriesSpec
import qualified Feature.Query.RpcSpec
import qualified Feature.Query.SingularSpec
import qualified Feature.Query.SpreadQueriesSpec
import qualified Feature.Query.UnicodeSpec
import qualified Feature.Query.UpdateSpec
import qualified Feature.Query.UpsertSpec
@@ -151,6 +152,7 @@ main = do
, ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion)
, ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec)
, ("Feature.Query.RelatedQueriesSpec" , Feature.Query.RelatedQueriesSpec.spec)
, ("Feature.Query.SpreadQueriesSpec" , Feature.Query.SpreadQueriesSpec.spec)
]
hspec $ do