From a64cce1963e478f98c7365fb2550e0a0e63c39d9 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Wed, 27 Nov 2024 19:35:59 -0500 Subject: [PATCH 1/5] feat: allow spreading one-to-many and many-to-many embedded resources --- CHANGELOG.md | 2 + docs/references/api/aggregate_functions.rst | 46 +- docs/references/api/resource_embedding.rst | 79 ++- docs/references/errors.rst | 4 - src/PostgREST/ApiRequest/Types.hs | 1 - src/PostgREST/Error.hs | 10 +- src/PostgREST/Plan.hs | 94 ++-- src/PostgREST/Plan/ReadPlan.hs | 6 +- src/PostgREST/Query/QueryBuilder.hs | 26 +- src/PostgREST/Query/SqlFragment.hs | 15 +- .../Feature/Query/AggregateFunctionsSpec.hs | 456 ++++++++++++----- test/spec/Feature/Query/SpreadQueriesSpec.hs | 462 +++++++++++++++++- test/spec/fixtures/data.sql | 35 +- test/spec/fixtures/schema.sql | 19 + 14 files changed, 1019 insertions(+), 236 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e98711d2..d3af6742db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #3727, Log maximum pool size - @steve-chavez - #1536, Add string comparison feature for jwt-role-claim-key - @taimoorzaeem - #3747, Allow `not_null` value for the `is` operator - @taimoorzaeem + - #3041, Allow spreading one-to-many and many-to-many embedded resources - @laurenceisla + + The selected columns in the embedded resources are aggregated into arrays ### Fixed diff --git a/docs/references/api/aggregate_functions.rst b/docs/references/api/aggregate_functions.rst index 8fe1168cec..27b9c3b449 100644 --- a/docs/references/api/aggregate_functions.rst +++ b/docs/references/api/aggregate_functions.rst @@ -180,6 +180,8 @@ You will then get the summed amount, along with the embedded customer resource: .. note:: The previous example uses a has-one association to demonstrate this functionality, but you may also use has-many associations as grouping columns, although there are few obvious use cases for this. +.. _aggregate_functions_embed_context: + Using Aggregate Functions Within the Context of an Embedded Resource -------------------------------------------------------------------- @@ -228,13 +230,13 @@ Continuing with the example relationship between ``orders`` and ``customers`` fr In this example, the ``amount`` column is summed and grouped by the ``order_date`` *within* the context of the embedded resource. That is, the ``name``, ``city``, and ``state`` from the ``customers`` table have no bearing on the aggregation performed in the context of the ``orders`` association; instead, each aggregation can be seen as being performed independently on just the orders belonging to a particular customer, using only the data from the embedded resource for both grouping and aggregation. -Using Columns from a Spreaded Resource --------------------------------------- +Using Columns from a To-One Spreaded Resource +--------------------------------------------- -When you :ref:`spread an embedded resource `, the columns from the spreaded resource are treated as if they were columns of the top-level resource, both when using them as grouping columns and when applying aggregate functions to them. +When you :ref:`spread a to-one embedded resource `, the columns from the spreaded resource are treated as if they were columns of the top-level resource, both when using them as grouping columns and when applying aggregate functions to them. -Grouping with Columns from a Spreaded Resource -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Grouping with Columns from a To-One Spreaded Resource +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For instance, assume you want to sum the ``amount`` column from the ``orders`` table, using the ``city`` and ``state`` columns from the ``customers`` table as grouping columns. To achieve this, you may select these two columns from the ``customers`` table and spread them; they will then be used as grouping columns: @@ -259,8 +261,8 @@ The result will be the same as if ``city`` and ``state`` were columns from the ` } ] -Aggregate Functions with Columns from a Spreaded Resource -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Aggregate Functions with Columns from a To-One Spreaded Resource +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Now imagine that the ``customers`` table has a ``joined_date`` column that represents the date that the customer joined. You want to get both the most recent and the oldest ``joined_date`` for customers that placed an order on every distinct order date. This can be expressed as follows: @@ -286,3 +288,33 @@ The result will be the same as if the aggregations were applied to columns from "min": "2016-02-11" } ] + +Using Aggregates in a To-Many Spread Resource +--------------------------------------------- + +Unlike the to-one spreads, the columns inside a :ref:`to-many spread relationship ` are not treated as if they were part of the top-level resource. +The aggregates will be done :ref:`in the context of the to-many spread resource `. +For example: + +.. code-block:: bash + + curl "http://localhost:3000/customers?select=name,city,state,...orders(amount.sum(),order_date)" + +.. code-block:: json + + [ + { + "name": "Customer A", + "city": "New York", + "state": "NY", + "sum": [215.22, 905.73], + "order_date": ["2023-09-01", "2023-09-02"] + }, + { + "name": "Customer B", + "city": "Los Angeles", + "state": "CA", + "sum": [329.71, 425.87], + "order_date": ["2023-09-01", "2023-09-03"] + } + ] diff --git a/docs/references/api/resource_embedding.rst b/docs/references/api/resource_embedding.rst index 3aafa76a64..8436213043 100644 --- a/docs/references/api/resource_embedding.rst +++ b/docs/references/api/resource_embedding.rst @@ -1150,7 +1150,19 @@ For example, to arrange the films in descending order using the director's last Spread embedded resource ======================== -On many-to-one and one-to-one relationships, you can "spread" the embedded resource. That is, remove the surrounding JSON object for the embedded resource columns. +The ``...`` operator lets you "spread" an embedded resource. +That is, it removes the surrounding JSON object for the embedded resource columns. + +.. note:: + + The spread operator ``...`` is borrowed from the Javascript `spread syntax `_. + +.. _spread_to_one_embed: + +Spread One-To-One and Many-To-One relationships +----------------------------------------------- + +Take the following example: .. code-block:: bash @@ -1196,6 +1208,67 @@ You can use this to get the columns of a join table in a many-to-many relationsh } ] -.. note:: +.. _spread_to_many_embed: - The spread operator ``...`` is borrowed from the Javascript `spread syntax `_. +Spread One-To-Many and Many-To-Many relationships +------------------------------------------------- + +The spread columns in these relationships will show the data in arrays. + +.. code-block:: bash + + # curl -g "http://localhost:3000/directors?select=first_name,...films(film_titles:title,film_years:year)&first_name=like.Quentin*" + + curl --get "http://localhost:3000/directors" \ + -d "select=first_name,...films(film_titles:title,film_years:year)" \ + -d "first_name=like.Quentin*" + +.. code-block:: json + + [ + { + "first_name": "Quentin", + "film_titles": [ + "Pulp Fiction", + "Reservoir Dogs" + ], + "film_years": [ + 1994, + 1992 + ] + } + ] + +Note that there is no "films" array of objects. + +By default, the order of the values inside the resulting array is unspecified. +But `it is safe to assume `_ that all the columns return the values in the same unspecified order. +From the previous result, we can say that "Pulp Fiction" premiered in 1994 and "Reservoir Dogs" in 1992. +You can still order all the resulting arrays explicitly. For example, to order by the release year: + +.. code-block:: bash + + # curl -g "http://localhost:3000/directors?select=first_name,...films(film_titles:title,film_years:year)&first_name=like.Quentin*&films.order=film_years" + + curl --get "http://localhost:3000/directors" \ + -d "select=first_name,...films(film_titles:title,film_years:year)" \ + -d "first_name=like.Quentin*" \ + -d "films.order=film_years" + +.. code-block:: json + + [ + { + "first_name": "Quentin", + "film_titles": [ + "Reservoir Dogs", + "Pulp Fiction" + ], + "film_years": [ + 1992, + 1994 + ] + } + ] + +Note that the field must be selected in the spread relationship for the order to work. diff --git a/docs/references/errors.rst b/docs/references/errors.rst index 4344f67a22..1127bf043f 100644 --- a/docs/references/errors.rst +++ b/docs/references/errors.rst @@ -241,10 +241,6 @@ Related to the HTTP request elements. | | | there is no many-to-one or one-to-one relationship between | | PGRST118 | | them. | +---------------+-------------+-------------------------------------------------------------+ -| .. _pgrst119: | 400 | Could not use the spread operator on the related table | -| | | because there is no many-to-one or one-to-one relationship | -| PGRST119 | | between them. | -+---------------+-------------+-------------------------------------------------------------+ | .. _pgrst120: | 400 | An embedded resource can only be filtered using the | | | | ``is.null`` or ``not.is.null`` :ref:`operators `.| | PGRST120 | | | diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index 9bebcd2c8e..452dba6326 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -86,7 +86,6 @@ data ApiRequestError | PutLimitNotAllowedError | QueryParamError QPError | RelatedOrderNotToOne Text Text - | SpreadNotToOne Text Text | UnacceptableFilter Text | UnacceptableSchema [Text] | UnsupportedMethod ByteString diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 8f386a8000..96436fa95a 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -80,7 +80,6 @@ instance PgrstError ApiRequestError where status PutLimitNotAllowedError = HTTP.status400 status QueryParamError{} = HTTP.status400 status RelatedOrderNotToOne{} = HTTP.status400 - status SpreadNotToOne{} = HTTP.status400 status UnacceptableFilter{} = HTTP.status400 status UnacceptableSchema{} = HTTP.status406 status UnsupportedMethod{} = HTTP.status405 @@ -176,12 +175,6 @@ instance JSON.ToJSON ApiRequestError where (Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship") Nothing - toJSON (SpreadNotToOne origin target) = toJsonPgrstError - ApiRequestErrorCode19 - ("A spread operation on '" <> target <> "' is not possible") - (Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship") - Nothing - toJSON (UnacceptableFilter target) = toJsonPgrstError ApiRequestErrorCode20 ("Bad operator on the '" <> target <> "' embedded resource") @@ -628,7 +621,7 @@ data ErrorCode | ApiRequestErrorCode16 | ApiRequestErrorCode17 | ApiRequestErrorCode18 - | ApiRequestErrorCode19 + -- | ApiRequestErrorCode19 -- no longer used (used to be mapped to SpreadNotToOne) | ApiRequestErrorCode20 | ApiRequestErrorCode21 | ApiRequestErrorCode22 @@ -677,7 +670,6 @@ buildErrorCode code = case code of ApiRequestErrorCode16 -> "PGRST116" ApiRequestErrorCode17 -> "PGRST117" ApiRequestErrorCode18 -> "PGRST118" - ApiRequestErrorCode19 -> "PGRST119" ApiRequestErrorCode20 -> "PGRST120" ApiRequestErrorCode21 -> "PGRST121" ApiRequestErrorCode22 -> "PGRST122" diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index 3a31d72a75..88d42ecd81 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -29,6 +29,7 @@ import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict.InsOrd as HMI import qualified Data.List as L import qualified Data.Set as S +import qualified Data.Text as T import qualified PostgREST.SchemaCache.Routine as Routine import Data.Either.Combinators (mapLeft, mapRight) @@ -332,7 +333,6 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate validateAggFunctions configDbAggregates =<< addRelSelects =<< addNullEmbedFilters =<< - validateSpreadEmbeds =<< addRelatedOrders =<< addAliases =<< expandStars ctx =<< @@ -348,7 +348,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} [] where rootDepth = 0 - defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth + defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] rootDepth treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry depth (Node si fldForest) (Node q rForest) = let nxtDepth = succ depth in @@ -361,33 +361,42 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = SpreadRelation{..} -> Node q $ foldr (treeEntry nxtDepth) - (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} []) + (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relSpread=Just ToOneSpread} []) fldForest:rForest SelectField{..} -> Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest -- If an alias is explicitly specified, it is always respected. However, an alias may be --- determined automatically in the case of a select term with a JSON path, or in the case --- of domain representations. +-- determined automatically in these cases: +-- * A select term with a JSON path +-- * Domain representations +-- * Aggregates in spread relationships addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree addAliases = Right . fmap addAliasToPlan where - addAliasToPlan rp@ReadPlan{select=sel} = rp{select=map aliasSelectField sel} + addAliasToPlan rp@ReadPlan{select=sel, relSpread=spr} = rp{select=map (aliasSelectField $ isJust spr) sel} - aliasSelectField :: CoercibleSelectField -> CoercibleSelectField - aliasSelectField field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias} + aliasSelectField :: Bool -> CoercibleSelectField -> CoercibleSelectField + aliasSelectField isSpread field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias} | isJust alias = field - | isJust aggFun = fieldAliasForSpreadAgg field + | isJust aggFun = fieldAliasForSpreadAgg isSpread field | isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key } | isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) } | otherwise = field - -- A request like: `/top_table?select=...middle_table(...nested_table(count()))` will `SELECT` the full row instead of `*`, - -- because doing a `COUNT(*)` in `top_table` would not return the desired results. - -- So we use the "count" alias if none is present since the field name won't be selected. - fieldAliasForSpreadAgg field - | cfFullRow (csField field) = field { csAlias = Just "count" } - | otherwise = field + -- Spread relationships with non-aliased aggregates can cause problems when selecting the fields in the top level resource. + -- The top level won't know the name of the field in these cases: + -- * A nested to-one spread like `/top_table?select=...middle_table(...nested_table(count()))` + -- will do a `SELECT nested_table` instead of `SELECT *`, because doing a `COUNT(*)` in `top_table` + -- would not return the desired results. + -- * In a to-many spread, the aggregated fields will be wrapped in a `json_agg()`. + -- + -- That's why we need to use the aggregate name as an alias (e.g. COUNT(...) AS "count"). + -- Since PostgreSQL labels the columns with the aggregate name, it shouldn't be a problem to + -- apply the aliases to all the aggregates regardless if the previous conditions are met. + fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just agg} = + field { csAlias = Just (T.toLower $ show agg) } + fieldAliasForSpreadAgg _ field = field isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True isJsonKeyPath _ = False @@ -413,13 +422,14 @@ knownColumnsInContext ResolverContext{..} = -- | Expand "select *" into explicit field names of the table in the following situations: -- * When there are data representations present. -- * When there is an aggregate function in a given ReadPlan or its parent. +-- * When the ReadPlan is a to-many spread relationship expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree where expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree - expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias} children) = + expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias, relSpread=spread} children) = let - newHasAgg = hasAgg || any (isJust . csAggFunction) select + newHasAgg = hasAgg || any (isJust . csAggFunction) select || spread == Just ToManySpread newCtx = adjustContext ctx fromQI alias newRPlan = expandStarsForTable newCtx newHasAgg rp in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children) @@ -432,18 +442,18 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree adjustContext context fromQI _ = context{qi=fromQI} expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan -expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relIsSpread=isSpread} +expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread} -- We expand if either of the below are true: -- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree. -- * We have a '*' select AND the target table has at least one data representation. -- We ignore '*' selects that have an aggregate function attached, unless it's a `COUNT(*)` for a Spread Embed, -- we tag it as "full row" in that case. - | hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField isSpread knownColumns) selectFields} + | hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread) knownColumns) selectFields} | otherwise = rp where hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields filteredSelectFields = filter (shouldExpandOrTag . csAggFunction) selectFields - shouldExpandOrTag aggFunc = isNothing aggFunc || (isSpread && aggFunc == Just Count) + shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count) hasDataRepresentation = any hasOutputRep knownColumns knownColumns = knownColumnsInContext ctx @@ -468,20 +478,21 @@ treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> reque -- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064) -- also adds aliasing addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree -addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,depth} forest) = +addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,relSpread,depth} forest) = case parentNode of Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) -> let newReadPlan = (\r -> let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth) - aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth in + aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth + updSpread = if isJust relSpread && not (relIsToOne r) then Just ToManySpread else relSpread in case r of Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing - rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r} + rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r, relSpread=updSpread} ComputedRelationship{} -> - rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias} + rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias, relSpread=updSpread} _ -> - rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r} + rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r, relSpread=updSpread} ) <$> rel origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship then fromMaybe (qiName parentNodeQi) parentAlias @@ -622,9 +633,9 @@ addRelSelects node@(Node rp forest) in Right $ Node rp { relSelect = newRelSelects } newForest generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField -generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsSpread = True} _) = +generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relSpread = Just _} _) = Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias } -generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relIsSpread = False} forest) = +generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relSpread = Nothing} forest) = Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed } where rsSelName = fromMaybe relName relAlias @@ -653,7 +664,7 @@ generateSpreadSelectFields ReadPlan{select, relSelect} = relSelectToSpread (Spread{rsSpreadSel}) = rsSpreadSel --- When aggregates are present in a ReadPlan that will be spread, we "hoist" +-- When aggregates are present in a ReadPlan with a to-one spread, we "hoist" -- to the highest level possible so that their semantics make sense. For instance, -- imagine the user performs the following request: -- `GET /projects?select=client_id,...project_invoices(invoice_total.sum())` @@ -674,28 +685,32 @@ generateSpreadSelectFields ReadPlan{select, relSelect} = -- -- The second tuple contains the aggregate function to be applied, the cast, and -- the alias, if it was supplied by the user or otherwise determined. +-- +-- No hoisting is done for to-many spreads, since it makes sense to +-- apply the aggregates at the level of their respective subqueries. type HoistedAgg = ((Alias, FieldName), (AggregateFunction, Maybe Cast, Maybe Alias)) hoistSpreadAggFunctions :: ReadPlanTree -> Either ApiRequestError ReadPlanTree hoistSpreadAggFunctions tree = Right $ fst $ applySpreadAggHoistingToNode tree applySpreadAggHoistingToNode :: ReadPlanTree -> (ReadPlanTree, [HoistedAgg]) -applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relIsSpread} children) = +applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relSpread} children) = let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children allChildAggLists = concat childAggLists - (newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsSpread) + isToOneSpread = relSpread == Just ToOneSpread + (newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not isToOneSpread) then (select rp, []) else hoistFromSelectFields relAggAlias (select rp) - -- If the current `ReadPlan` is a spread rel and it has aggregates hoisted from + -- If the current `ReadPlan` is a to-one spread rel and it has aggregates hoisted from -- child relationships, then it must hoist those aggregates to its parent rel. -- So we update them with the current `relAggAlias`. hoistAgg ((_, fieldName), hoistFunc) = ((relAggAlias, fieldName), hoistFunc) - hoistedAggList = if relIsSpread + hoistedAggList = if isToOneSpread then aggList ++ map hoistAgg allChildAggLists else aggList - newRelSelects = if null children || relIsSpread + newRelSelects = if null children || isToOneSpread then relSelect rp else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, hoistedAggList) @@ -811,7 +826,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- relName = "projects", -- relToParent = Nothing, -- relJoinConds = [], --- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1, +-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relSpread = Nothing, depth = 1, -- relSelect = [] -- }, -- subForest = [] @@ -837,7 +852,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- ) -- ], -- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing, --- relJoinType = Nothing, relIsSpread = False, depth = 0, +-- relJoinType = Nothing, relSpread = Nothing, depth = 0, -- relSelect = [] -- }, -- subForest = subForst @@ -902,15 +917,6 @@ resolveLogicTree ctx (Expr b op lts) = CoercibleExpr b op (map (resolveLogicTree resolveFilter :: ResolverContext -> Filter -> CoercibleFilter resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld, opExpr=opExpr} --- 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 diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index 854cf1ffa7..edf8e23d2c 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -2,6 +2,7 @@ module PostgREST.Plan.ReadPlan ( ReadPlanTree , ReadPlan(..) , JoinCondition(..) + , SpreadType(..) ) where import Data.Tree (Tree (..)) @@ -28,6 +29,9 @@ data JoinCondition = (QualifiedIdentifier, FieldName) deriving (Eq, Show) +data SpreadType = ToOneSpread | ToManySpread + deriving (Eq, Show) + data ReadPlan = ReadPlan { select :: [CoercibleSelectField] , from :: QualifiedIdentifier @@ -42,7 +46,7 @@ data ReadPlan = ReadPlan , relAggAlias :: Alias , relHint :: Maybe Hint , relJoinType :: Maybe JoinType - , relIsSpread :: Bool + , relSpread :: Maybe SpreadType , relSelect :: [RelSelectField] , depth :: Depth -- ^ used for aliasing diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 602ae27ef1..88cf99ff2a 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -45,7 +45,7 @@ import PostgREST.RangeQuery (allRange) import Protolude readPlanToQuery :: ReadPlanTree -> SQL.Snippet -readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) = +readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect, relSpread} forest) = "SELECT " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <> " " <> fromFrag <> " " <> @@ -54,7 +54,7 @@ readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicFor then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <> groupF qi select relSelect <> " " <> - orderF qi order <> " " <> + orderFrag <> " " <> limitOffsetF readRange where fromFrag = fromF relToParent mainQi fromAlias @@ -63,6 +63,7 @@ readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicFor defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing] joins = getJoins node joinsSelects = getJoinSelects node + orderFrag = if relSpread == Just ToManySpread then mempty else orderF qi order getJoinSelects :: ReadPlanTree -> [SQL.Snippet] getJoinSelects (Node ReadPlan{relSelect} _) = @@ -80,7 +81,7 @@ getJoinSelects (Node ReadPlan{relSelect} _) = JsonEmbed{rsSelName, rsEmbedMode = JsonArray} -> Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName Spread{rsSpreadSel, rsAggAlias} -> - Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel) + Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem False rsAggAlias mempty <$> rsSpreadSel) getJoins :: ReadPlanTree -> [SQL.Snippet] getJoins (Node _ []) = [] @@ -92,23 +93,28 @@ getJoins (Node ReadPlan{relSelect} forest) = ) relSelect getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet -getJoin fld node@(Node ReadPlan{relJoinType} _) = +getJoin fld node@(Node ReadPlan{order, relJoinType, relSpread} _) = let correlatedSubquery sub al cond = (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond subquery = readPlanToQuery node aggAlias = pgFmtIdent $ rsAggAlias fld + selectJsonArray = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias + wrapSubqAlias = " FROM (" <> subquery <> " ) AS " <> aggAlias + joinCondition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE" in case fld of JsonEmbed{rsEmbedMode = JsonObject} -> correlatedSubquery subquery aggAlias "TRUE" - Spread{} -> - correlatedSubquery subquery aggAlias "TRUE" + Spread{rsSpreadSel, rsAggAlias} -> + if relSpread == Just ToManySpread then + let + selection = selectJsonArray <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadSelectItem True rsAggAlias order <$> rsSpreadSel) + in correlatedSubquery (selection <> wrapSubqAlias) aggAlias joinCondition + else + correlatedSubquery subquery aggAlias "TRUE" JsonEmbed{rsEmbedMode = JsonArray} -> - let - subq = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias <> " FROM (" <> subquery <> " ) AS " <> aggAlias - condition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE" - in correlatedSubquery subq aggAlias condition + correlatedSubquery (selectJsonArray <> wrapSubqAlias) aggAlias joinCondition mutatePlanToQuery :: MutatePlan -> SQL.Snippet mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) = diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index fa79a564b8..4df2f4a4dc 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -252,7 +252,7 @@ pgFmtCallUnary :: Text -> SQL.Snippet -> SQL.Snippet pgFmtCallUnary f x = SQL.sql (encodeUtf8 f) <> "(" <> x <> ")" pgFmtField :: QualifiedIdentifier -> CoercibleField -> SQL.Snippet -pgFmtField table CoercibleField{cfFullRow=True} = fromQi table +pgFmtField table CoercibleField{cfFullRow=True} = pgFmtIdent (qiName table) pgFmtField table CoercibleField{cfName=fn, cfJsonPath=[]} = pgFmtColumn table fn pgFmtField table CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson = "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp | otherwise = pgFmtColumn table fn <> pgFmtJsonPath jp @@ -271,9 +271,10 @@ pgFmtSelectItem :: QualifiedIdentifier -> CoercibleSelectField -> SQL.Snippet pgFmtSelectItem table CoercibleSelectField{csField=fld, csAggFunction=agg, csAggCast=aggCast, csCast=cast, csAlias=alias} = pgFmtApplyAggregate agg aggCast (pgFmtApplyCast cast (pgFmtTableCoerce table fld)) <> pgFmtAs alias -pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet -pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} = - pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias +pgFmtSpreadSelectItem :: Bool -> Alias -> [CoercibleOrderTerm] -> SpreadSelectField -> SQL.Snippet +pgFmtSpreadSelectItem applyToManySpr aggAlias order SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} + | applyToManySpr = pgFmtApplyToManySpreadAgg ssSelAggFunction ssSelAggCast aggAlias order fullSelName <> " AS " <> pgFmtIdent (fromMaybe ssSelName ssSelAlias) + | otherwise = pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias where fullSelName = case ssSelName of "*" -> pgFmtIdent aggAlias <> ".*" @@ -289,6 +290,12 @@ pgFmtApplyAggregate (Just agg) aggCast snippet = convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")" +pgFmtApplyToManySpreadAgg :: Maybe AggregateFunction -> Maybe Cast -> Alias -> [CoercibleOrderTerm] -> SQL.Snippet -> SQL.Snippet +pgFmtApplyToManySpreadAgg Nothing aggCast relAggAlias order snippet = + "COALESCE(json_agg(" <> pgFmtApplyCast aggCast snippet <> orderF (QualifiedIdentifier "" relAggAlias) order <> "),'[]')::jsonb" +pgFmtApplyToManySpreadAgg agg aggCast _ _ snippet = + pgFmtApplyAggregate agg aggCast snippet + pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet pgFmtApplyCast Nothing snippet = snippet -- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc. diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index def85cbd52..5812b5d552 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -141,156 +141,352 @@ allowed = { matchHeaders = [matchContentTypeJson] } context "performing aggregations on spreaded fields from an embedded resource" $ do - it "supports the use of aggregates on spreaded fields" $ do - get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith` - [json|[ - {"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42}, - {"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41}, - {"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do - get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith` - [json|[{"total_budget": 9501.06}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports aggregates from a spread relationships grouped by spreaded fields from other relationships" $ do - get "/processes?select=...process_costs(cost.sum()),...process_categories(name)" `shouldRespondWith` - [json|[ - {"sum": 400.00, "name": "Batch"}, - {"sum": 320.00, "name": "Mass"}]|] - { matchHeaders = [matchContentTypeJson] } - get "/processes?select=...process_costs(cost_sum:cost.sum()),...process_categories(category:name)" `shouldRespondWith` - [json|[ - {"cost_sum": 400.00, "category": "Batch"}, - {"cost_sum": 320.00, "category": "Mass"}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports aggregates on spreaded fields from nested relationships" $ do - get "/process_supervisor?select=...processes(factory_id,...process_costs(cost.sum()))" `shouldRespondWith` - [json|[ - {"factory_id": 3, "sum": 120.00}, - {"factory_id": 2, "sum": 500.00}, - {"factory_id": 1, "sum": 350.00}]|] - { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=...processes(factory_id,...process_costs(cost_sum:cost.sum()))" `shouldRespondWith` - [json|[ - {"factory_id": 3, "cost_sum": 120.00}, - {"factory_id": 2, "cost_sum": 500.00}, - {"factory_id": 1, "cost_sum": 350.00}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports aggregates on spreaded fields from nested relationships, grouped by a regular nested relationship" $ do - get "/process_supervisor?select=...processes(factories(name),...process_costs(cost.sum()))" `shouldRespondWith` - [json|[ - {"factories": {"name": "Factory A"}, "sum": 350.00}, - {"factories": {"name": "Factory B"}, "sum": 500.00}, - {"factories": {"name": "Factory C"}, "sum": 120.00}]|] - { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=...processes(factory:factories(name),...process_costs(cost_sum:cost.sum()))" `shouldRespondWith` - [json|[ - {"factory": {"name": "Factory A"}, "cost_sum": 350.00}, - {"factory": {"name": "Factory B"}, "cost_sum": 500.00}, - {"factory": {"name": "Factory C"}, "cost_sum": 120.00}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships" $ do - get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost.sum()),...process_categories(name))&order=supervisor_id" `shouldRespondWith` - [json|[ - {"supervisor_id": 1, "sum": 220.00, "name": "Batch"}, - {"supervisor_id": 2, "sum": 70.00, "name": "Batch"}, - {"supervisor_id": 2, "sum": 200.00, "name": "Mass"}, - {"supervisor_id": 3, "sum": 180.00, "name": "Batch"}, - {"supervisor_id": 3, "sum": 120.00, "name": "Mass"}, - {"supervisor_id": 4, "sum": 180.00, "name": "Batch"}]|] - { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name))&order=supervisor_id" `shouldRespondWith` - [json|[ - {"supervisor_id": 1, "cost_sum": 220.00, "category": "Batch"}, - {"supervisor_id": 2, "cost_sum": 70.00, "category": "Batch"}, - {"supervisor_id": 2, "cost_sum": 200.00, "category": "Mass"}, - {"supervisor_id": 3, "cost_sum": 180.00, "category": "Batch"}, - {"supervisor_id": 3, "cost_sum": 120.00, "category": "Mass"}, - {"supervisor_id": 4, "cost_sum": 180.00, "category": "Batch"}]|] - { matchHeaders = [matchContentTypeJson] } - it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships, using a nested relationship as top parent" $ do - get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost.sum()),...process_categories(name)))" `shouldRespondWith` - [json|[ - {"name": "Mary", "process_supervisor": [{"name": "Batch", "sum": 220.00}]}, - {"name": "John", "process_supervisor": [{"name": "Batch", "sum": 70.00}, {"name": "Mass", "sum": 200.00}]}, - {"name": "Peter", "process_supervisor": [{"name": "Batch", "sum": 180.00}, {"name": "Mass", "sum": 120.00}]}, - {"name": "Sarah", "process_supervisor": [{"name": "Batch", "sum": 180.00}]}]|] - { matchHeaders = [matchContentTypeJson] } - get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name)))" `shouldRespondWith` - [json|[ - {"name": "Mary", "process_supervisor": [{"category": "Batch", "cost_sum": 220.00}]}, - {"name": "John", "process_supervisor": [{"category": "Batch", "cost_sum": 70.00}, {"category": "Mass", "cost_sum": 200.00}]}, - {"name": "Peter", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}, {"category": "Mass", "cost_sum": 120.00}]}, - {"name": "Sarah", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}]}]|] - { matchHeaders = [matchContentTypeJson] } + context "to-one spread relationships" $ do + it "supports the use of aggregates on spreaded fields" $ do + get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith` + [json|[ + {"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42}, + {"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41}, + {"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do + get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith` + [json|[{"total_budget": 9501.06}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports aggregates from a spread relationships grouped by spreaded fields from other relationships" $ do + get "/processes?select=...process_costs(cost.sum()),...process_categories(name)" `shouldRespondWith` + [json|[ + {"sum": 400.00, "name": "Batch"}, + {"sum": 350.00, "name": "Mass"}]|] + { matchHeaders = [matchContentTypeJson] } + get "/processes?select=...process_costs(cost_sum:cost.sum()),...process_categories(category:name)" `shouldRespondWith` + [json|[ + {"cost_sum": 400.00, "category": "Batch"}, + {"cost_sum": 350.00, "category": "Mass"}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports aggregates on spreaded fields from nested relationships" $ do + get "/process_supervisor?select=...processes(factory_id,...process_costs(cost.sum()))" `shouldRespondWith` + [json|[ + {"factory_id": 3, "sum": 110.00}, + {"factory_id": 2, "sum": 500.00}, + {"factory_id": 1, "sum": 350.00}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=...processes(factory_id,...process_costs(cost_sum:cost.sum()))" `shouldRespondWith` + [json|[ + {"factory_id": 3, "cost_sum": 110.00}, + {"factory_id": 2, "cost_sum": 500.00}, + {"factory_id": 1, "cost_sum": 350.00}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports aggregates on spreaded fields from nested relationships, grouped by a regular nested relationship" $ do + get "/process_supervisor?select=...processes(factories(name),...process_costs(cost.sum()))" `shouldRespondWith` + [json|[ + {"factories": {"name": "Factory A"}, "sum": 350.00}, + {"factories": {"name": "Factory B"}, "sum": 500.00}, + {"factories": {"name": "Factory C"}, "sum": 110.00}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=...processes(factory:factories(name),...process_costs(cost_sum:cost.sum()))" `shouldRespondWith` + [json|[ + {"factory": {"name": "Factory A"}, "cost_sum": 350.00}, + {"factory": {"name": "Factory B"}, "cost_sum": 500.00}, + {"factory": {"name": "Factory C"}, "cost_sum": 110.00}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships" $ do + get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost.sum()),...process_categories(name))&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor_id": 1, "sum": 220.00, "name": "Batch"}, + {"supervisor_id": 2, "sum": 70.00, "name": "Batch"}, + {"supervisor_id": 2, "sum": 200.00, "name": "Mass"}, + {"supervisor_id": 3, "sum": 180.00, "name": "Batch"}, + {"supervisor_id": 3, "sum": 110.00, "name": "Mass"}, + {"supervisor_id": 4, "sum": 180.00, "name": "Batch"}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name))&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor_id": 1, "cost_sum": 220.00, "category": "Batch"}, + {"supervisor_id": 2, "cost_sum": 70.00, "category": "Batch"}, + {"supervisor_id": 2, "cost_sum": 200.00, "category": "Mass"}, + {"supervisor_id": 3, "cost_sum": 180.00, "category": "Batch"}, + {"supervisor_id": 3, "cost_sum": 110.00, "category": "Mass"}, + {"supervisor_id": 4, "cost_sum": 180.00, "category": "Batch"}]|] + { matchHeaders = [matchContentTypeJson] } + it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships, using a nested relationship as top parent" $ do + get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost.sum()),...process_categories(name)))" `shouldRespondWith` + [json|[ + {"name": "Mary", "process_supervisor": [{"name": "Batch", "sum": 220.00}]}, + {"name": "John", "process_supervisor": [{"name": "Batch", "sum": 70.00}, {"name": "Mass", "sum": 200.00}]}, + {"name": "Peter", "process_supervisor": [{"name": "Batch", "sum": 180.00}, {"name": "Mass", "sum": 110.00}]}, + {"name": "Sarah", "process_supervisor": [{"name": "Batch", "sum": 180.00}]}, + {"name": "Jane", "process_supervisor": []}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name)))" `shouldRespondWith` + [json|[ + {"name": "Mary", "process_supervisor": [{"category": "Batch", "cost_sum": 220.00}]}, + {"name": "John", "process_supervisor": [{"category": "Batch", "cost_sum": 70.00}, {"category": "Mass", "cost_sum": 200.00}]}, + {"name": "Peter", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}, {"category": "Mass", "cost_sum": 110.00}]}, + {"name": "Sarah", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}]}, + {"name": "Jane", "process_supervisor": []}]|] + { matchHeaders = [matchContentTypeJson] } - context "supports count() aggregate without specifying a field" $ do - it "works by itself in the embedded resource" $ do - get "/process_supervisor?select=supervisor_id,...processes(count())&order=supervisor_id" `shouldRespondWith` + context "supports count() aggregate without specifying a field" $ do + it "works by itself in the embedded resource" $ do + get "/process_supervisor?select=supervisor_id,...processes(count())&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor_id": 1, "count": 2}, + {"supervisor_id": 2, "count": 2}, + {"supervisor_id": 3, "count": 3}, + {"supervisor_id": 4, "count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=supervisor_id,...processes(processes_count:count())&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor_id": 1, "processes_count": 2}, + {"supervisor_id": 2, "processes_count": 2}, + {"supervisor_id": 3, "processes_count": 3}, + {"supervisor_id": 4, "processes_count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + it "works alongside other columns in the embedded resource" $ do + get "/process_supervisor?select=...supervisors(id,count())&order=supervisors(id)" `shouldRespondWith` + [json|[ + {"id": 1, "count": 2}, + {"id": 2, "count": 2}, + {"id": 3, "count": 3}, + {"id": 4, "count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=...supervisors(supervisor:id,supervisor_count:count())&order=supervisors(supervisor)" `shouldRespondWith` + [json|[ + {"supervisor": 1, "supervisor_count": 2}, + {"supervisor": 2, "supervisor_count": 2}, + {"supervisor": 3, "supervisor_count": 3}, + {"supervisor": 4, "supervisor_count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + it "works on nested resources" $ do + get "/process_supervisor?select=supervisor_id,...processes(...process_costs(count()))&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor_id": 1, "count": 2}, + {"supervisor_id": 2, "count": 2}, + {"supervisor_id": 3, "count": 3}, + {"supervisor_id": 4, "count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=supervisor:supervisor_id,...processes(...process_costs(process_costs_count:count()))&order=supervisor_id" `shouldRespondWith` + [json|[ + {"supervisor": 1, "process_costs_count": 2}, + {"supervisor": 2, "process_costs_count": 2}, + {"supervisor": 3, "process_costs_count": 3}, + {"supervisor": 4, "process_costs_count": 1}]|] + { matchHeaders = [matchContentTypeJson] } + it "works on nested resources grouped by spreaded fields" $ do + get "/process_supervisor?select=...processes(factory_id,...process_costs(count()))&order=processes(factory_id)" `shouldRespondWith` + [json|[ + {"factory_id": 1, "count": 2}, + {"factory_id": 2, "count": 4}, + {"factory_id": 3, "count": 2}]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_supervisor?select=...processes(factory:factory_id,...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith` + [json|[ + {"factory": 1, "process_costs_count": 2}, + {"factory": 2, "process_costs_count": 4}, + {"factory": 3, "process_costs_count": 2}]|] + { matchHeaders = [matchContentTypeJson] } + it "works on different levels of the nested resources at the same time" $ + get "/process_supervisor?select=...processes(factory:factory_id,processes_count:count(),...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith` + [json|[ + {"factory": 1, "processes_count": 2, "process_costs_count": 2}, + {"factory": 2, "processes_count": 4, "process_costs_count": 4}, + {"factory": 3, "processes_count": 2, "process_costs_count": 2}]|] + { matchHeaders = [matchContentTypeJson] } + + context "to-many spread relationships" $ do + it "supports the use of aggregates" $ do + get "/factories?select=name,...factory_buildings(type,size.sum())" `shouldRespondWith` [json|[ - {"supervisor_id": 1, "count": 2}, - {"supervisor_id": 2, "count": 2}, - {"supervisor_id": 3, "count": 3}, - {"supervisor_id": 4, "count": 1}]|] + {"name":"Factory A","type":["A"],"sum":[350]}, + {"name":"Factory B","type":["B", "C"],"sum":[50, 120]}, + {"name":"Factory C","type":["B"],"sum":[240]}, + {"name":"Factory D","type":["A"],"sum":[310]}]|] { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=supervisor_id,...processes(processes_count:count())&order=supervisor_id" `shouldRespondWith` + it "supports the use of aggregates without grouping by any fields" $ do + get "/factories?select=name,...factory_buildings(size.sum())" `shouldRespondWith` [json|[ - {"supervisor_id": 1, "processes_count": 2}, - {"supervisor_id": 2, "processes_count": 2}, - {"supervisor_id": 3, "processes_count": 3}, - {"supervisor_id": 4, "processes_count": 1}]|] + {"name":"Factory A","sum":[350]}, + {"name":"Factory B","sum":[170]}, + {"name":"Factory C","sum":[240]}, + {"name":"Factory D","sum":[310]}]|] { matchHeaders = [matchContentTypeJson] } - it "works alongside other columns in the embedded resource" $ do - get "/process_supervisor?select=...supervisors(id,count())&order=supervisors(id)" `shouldRespondWith` + it "supports many aggregates at the same time" $ do + get "/factories?select=name,...factory_buildings(size.min(),size.max(),size.sum())" `shouldRespondWith` [json|[ - {"id": 1, "count": 2}, - {"id": 2, "count": 2}, - {"id": 3, "count": 3}, - {"id": 4, "count": 1}]|] + {"name":"Factory A","min":[150],"max":[200],"sum":[350]}, + {"name":"Factory B","min":[50],"max":[120],"sum":[170]}, + {"name":"Factory C","min":[240],"max":[240],"sum":[240]}, + {"name":"Factory D","min":[310],"max":[310],"sum":[310]}]|] { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=...supervisors(supervisor:id,supervisor_count:count())&order=supervisors(supervisor)" `shouldRespondWith` + it "supports aggregates inside nested to-one spread relationships" $ do + get "/supervisors?select=name,...processes(...process_costs(cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"supervisor": 1, "supervisor_count": 2}, - {"supervisor": 2, "supervisor_count": 2}, - {"supervisor": 3, "supervisor_count": 3}, - {"supervisor": 4, "supervisor_count": 1}]|] + {"name":"Jane","sum":[null]}, + {"name":"John","sum":[270.00]}, + {"name":"Mary","sum":[220.00]}, + {"name":"Peter","sum":[290.00]}, + {"name":"Sarah","sum":[180.00]}]|] { matchHeaders = [matchContentTypeJson] } - it "works on nested resources" $ do - get "/process_supervisor?select=supervisor_id,...processes(...process_costs(count()))&order=supervisor_id" `shouldRespondWith` + get "/supervisors?select=supervisor:name,...processes(...process_costs(cost_sum:cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"supervisor_id": 1, "count": 2}, - {"supervisor_id": 2, "count": 2}, - {"supervisor_id": 3, "count": 2}, - {"supervisor_id": 4, "count": 1}]|] + {"supervisor":"Jane","cost_sum":[null]}, + {"supervisor":"John","cost_sum":[270.00]}, + {"supervisor":"Mary","cost_sum":[220.00]}, + {"supervisor":"Peter","cost_sum":[290.00]}, + {"supervisor":"Sarah","cost_sum":[180.00]}]|] { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=supervisor:supervisor_id,...processes(...process_costs(process_costs_count:count()))&order=supervisor_id" `shouldRespondWith` + it "supports aggregates alongside the aggregates nested in to-one spread relationships" $ do + get "/supervisors?select=name,...processes(id.count(),...process_costs(cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"supervisor": 1, "process_costs_count": 2}, - {"supervisor": 2, "process_costs_count": 2}, - {"supervisor": 3, "process_costs_count": 2}, - {"supervisor": 4, "process_costs_count": 1}]|] + {"name":"Jane","count":[0],"sum":[null]}, + {"name":"John","count":[2],"sum":[270.00]}, + {"name":"Mary","count":[2],"sum":[220.00]}, + {"name":"Peter","count":[3],"sum":[290.00]}, + {"name":"Sarah","count":[1],"sum":[180.00]}]|] { matchHeaders = [matchContentTypeJson] } - it "works on nested resources grouped by spreaded fields" $ do - get "/process_supervisor?select=...processes(factory_id,...process_costs(count()))&order=processes(factory_id)" `shouldRespondWith` + get "/supervisors?select=supervisor:name,...processes(process_count:count(),...process_costs(cost_sum:cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"factory_id": 1, "count": 2}, - {"factory_id": 2, "count": 4}, - {"factory_id": 3, "count": 1}]|] + {"supervisor":"Jane","process_count":[0],"cost_sum":[null]}, + {"supervisor":"John","process_count":[2],"cost_sum":[270.00]}, + {"supervisor":"Mary","process_count":[2],"cost_sum":[220.00]}, + {"supervisor":"Peter","process_count":[3],"cost_sum":[290.00]}, + {"supervisor":"Sarah","process_count":[1],"cost_sum":[180.00]}]|] { matchHeaders = [matchContentTypeJson] } - get "/process_supervisor?select=...processes(factory:factory_id,...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith` + it "supports aggregates on nested relationships" $ do + get "/operators?select=name,...processes(id,...factories(...factory_buildings(size.sum())))&order=name" `shouldRespondWith` [json|[ - {"factory": 1, "process_costs_count": 2}, - {"factory": 2, "process_costs_count": 4}, - {"factory": 3, "process_costs_count": 1}]|] + {"name":"Alfred","id":[6, 7],"sum":[[240], [240]]}, + {"name":"Anne","id":[1, 2, 4],"sum":[[350], [350], [170]]}, + {"name":"Jeff","id":[2, 3, 4, 6],"sum":[[350], [170], [170], [240]]}, + {"name":"Liz","id":[],"sum":[]}, + {"name":"Louis","id":[1, 2],"sum":[[350], [350]]}]|] { matchHeaders = [matchContentTypeJson] } - it "works on different levels of the nested resources at the same time" $ - get "/process_supervisor?select=...processes(factory:factory_id,processes_count:count(),...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith` + get "/operators?select=name,...processes(process_id:id,...factories(...factory_buildings(factory_building_size_sum:size.sum())))&order=name" `shouldRespondWith` [json|[ - {"factory": 1, "processes_count": 2, "process_costs_count": 2}, - {"factory": 2, "processes_count": 4, "process_costs_count": 4}, - {"factory": 3, "processes_count": 2, "process_costs_count": 1}]|] + {"name":"Alfred","process_id":[6, 7],"factory_building_size_sum":[[240], [240]]}, + {"name":"Anne","process_id":[1, 2, 4],"factory_building_size_sum":[[350], [350], [170]]}, + {"name":"Jeff","process_id":[2, 3, 4, 6],"factory_building_size_sum":[[350], [170], [170], [240]]}, + {"name":"Liz","process_id":[],"factory_building_size_sum":[]}, + {"name":"Louis","process_id":[1, 2],"factory_building_size_sum":[[350], [350]]}]|] { matchHeaders = [matchContentTypeJson] } + context "supports count() aggregate without specifying a field" $ do + context "one-to-many" $ do + it "works by itself in the embedded resource" $ do + get "/factories?select=name,...processes(count())&order=name" `shouldRespondWith` + [json|[ + {"name":"Factory A","count":[2]}, + {"name":"Factory B","count":[2]}, + {"name":"Factory C","count":[4]}, + {"name":"Factory D","count":[0]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/factories?select=factory:name,...processes(processes_count:count())&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","processes_count":[2]}, + {"factory":"Factory B","processes_count":[2]}, + {"factory":"Factory C","processes_count":[4]}, + {"factory":"Factory D","processes_count":[0]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works alongside other columns in the embedded resource" $ do + get "/factories?select=name,...processes(category_id,count())&order=name" `shouldRespondWith` + [json|[ + {"name":"Factory A","category_id":[1, 2],"count":[1, 1]}, + {"name":"Factory B","category_id":[1],"count":[2]}, + {"name":"Factory C","category_id":[2],"count":[4]}, + {"name":"Factory D","category_id":[],"count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/factories?select=factory:name,...processes(category:category_id,process_count:count())&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","category":[1, 2],"process_count":[1, 1]}, + {"factory":"Factory B","category":[1],"process_count":[2]}, + {"factory":"Factory C","category":[2],"process_count":[4]}, + {"factory":"Factory D","category":[],"process_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/factories?select=factory:name,...processes(*,process_count:count())&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[1,2],"name":["Process A1","Process A2"],"factory_id":[1,1],"category_id":[1,2],"process_count":[1,1]}, + {"factory":"Factory B","id":[3,4],"name":["Process B1","Process B2"],"factory_id":[2,2],"category_id":[1,1],"process_count":[1,1]}, + {"factory":"Factory C","id":[5,6,7,8],"name":["Process C1","Process C2","Process XX","Process YY"],"factory_id":[3,3,3,3],"category_id":[2,2,2,2],"process_count":[1,1,1,1]}, + {"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[],"process_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works on nested resources" $ do + get "/factories?select=id,...processes(name,...process_supervisor(count()))&order=id" `shouldRespondWith` + [json|[ + {"id":1,"name":["Process A1", "Process A2"],"count":[[1], [1]]}, + {"id":2,"name":["Process B1", "Process B2"],"count":[[2], [2]]}, + {"id":3,"name":["Process C1", "Process C2", "Process XX", "Process YY"],"count":[[1], [1], [0], [0]]}, + {"id":4,"name":[],"count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/factories?select=id,...processes(process:name,...process_supervisor(ps_count:count()))&order=id" `shouldRespondWith` + [json|[ + {"id":1,"process":["Process A1", "Process A2"],"ps_count":[[1], [1]]}, + {"id":2,"process":["Process B1", "Process B2"],"ps_count":[[2], [2]]}, + {"id":3,"process":["Process C1", "Process C2", "Process XX", "Process YY"],"ps_count":[[1], [1], [0], [0]]}, + {"id":4,"process":[],"ps_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + + context "many-to-many" $ do + it "works by itself in the embedded resource" $ do + get "/supervisors?select=name,...processes(count())&order=name" `shouldRespondWith` + [json|[ + {"name":"Jane","count":[0]}, + {"name":"John","count":[2]}, + {"name":"Mary","count":[2]}, + {"name":"Peter","count":[3]}, + {"name":"Sarah","count":[1]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=supervisor:name,...processes(processes_count:count())&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","processes_count":[0]}, + {"supervisor":"John","processes_count":[2]}, + {"supervisor":"Mary","processes_count":[2]}, + {"supervisor":"Peter","processes_count":[3]}, + {"supervisor":"Sarah","processes_count":[1]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works alongside other columns in the embedded resource" $ do + get "/supervisors?select=name,...processes(category_id,count())&order=name" `shouldRespondWith` + [json|[ + {"name":"Jane","category_id":[],"count":[]}, + {"name":"John","category_id":[1, 2],"count":[1, 1]}, + {"name":"Mary","category_id":[1],"count":[2]}, + {"name":"Peter","category_id":[1, 2],"count":[1, 2]}, + {"name":"Sarah","category_id":[1],"count":[1]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=supervisor:name,...processes(category:category_id,process_count:count())&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","category":[],"process_count":[]}, + {"supervisor":"John","category":[1, 2],"process_count":[1, 1]}, + {"supervisor":"Mary","category":[1],"process_count":[2]}, + {"supervisor":"Peter","category":[1, 2],"process_count":[1, 2]}, + {"supervisor":"Sarah","category":[1],"process_count":[1]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=supervisor:name,...processes(*,process_count:count())&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[],"process_count":[]}, + {"supervisor":"John","id":[2, 4],"name":["Process A2", "Process B2"],"factory_id":[1, 2],"category_id":[2, 1],"process_count":[1, 1]}, + {"supervisor":"Mary","id":[1, 4],"name":["Process A1", "Process B2"],"factory_id":[1, 2],"category_id":[1, 1],"process_count":[1, 1]}, + {"supervisor":"Peter","id":[3, 5, 6],"name":["Process B1", "Process C1", "Process C2"],"factory_id":[2, 3, 3],"category_id":[1, 2, 2],"process_count":[1, 1, 1]}, + {"supervisor":"Sarah","id":[3],"name":["Process B1"],"factory_id":[2],"category_id":[1],"process_count":[1]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works on nested resources" $ do + get "/supervisors?select=id,...processes(name,...operators(count()))&order=id" `shouldRespondWith` + [json|[ + {"id":1,"name":["Process A1", "Process B2"],"count":[[2], [2]]}, + {"id":2,"name":["Process A2", "Process B2"],"count":[[3], [2]]}, + {"id":3,"name":["Process B1", "Process C1", "Process C2"],"count":[[1], [0], [2]]}, + {"id":4,"name":["Process B1"],"count":[[1]]}, + {"id":5,"name":[],"count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=supervisor:id,...processes(processes:name,...operators(operators_count:count()))&order=id" `shouldRespondWith` + [json|[ + {"supervisor":1,"processes":["Process A1", "Process B2"],"operators_count":[[2], [2]]}, + {"supervisor":2,"processes":["Process A2", "Process B2"],"operators_count":[[3], [2]]}, + {"supervisor":3,"processes":["Process B1", "Process C1", "Process C2"],"operators_count":[[1], [0], [2]]}, + {"supervisor":4,"processes":["Process B1"],"operators_count":[[1]]}, + {"supervisor":5,"processes":[],"operators_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + disallowed :: SpecWith ((), Application) disallowed = describe "attempting to use an aggregate when aggregate functions are disallowed" $ do diff --git a/test/spec/Feature/Query/SpreadQueriesSpec.hs b/test/spec/Feature/Query/SpreadQueriesSpec.hs index 07a9c9d6d7..8a4e51309e 100644 --- a/test/spec/Feature/Query/SpreadQueriesSpec.hs +++ b/test/spec/Feature/Query/SpreadQueriesSpec.hs @@ -63,28 +63,6 @@ spec = , 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] - } - it "can include or exclude attributes of the junction on a m2m" $ do get "/users?select=*,tasks:users_tasks(*,...tasks(*))&limit=1" `shouldRespondWith` [json|[{ @@ -112,3 +90,443 @@ spec = { matchStatus = 200 , matchHeaders = [matchContentTypeJson] } + + context "one-to-many relationships" $ do + it "should spread a column as a json array" $ do + get "/factories?select=factory:name,...processes(name)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"]}, + {"factory":"Factory B","name":["Process B1", "Process B2"]}, + {"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]}, + {"factory":"Factory D","name":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/factories?select=factory:name,...processes(processes:name)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","processes":["Process A1", "Process A2"]}, + {"factory":"Factory B","processes":["Process B1", "Process B2"]}, + {"factory":"Factory C","processes":["Process C1", "Process C2", "Process XX", "Process YY"]}, + {"factory":"Factory D","processes":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should spread many columns as json arrays" $ do + get "/factories?select=factory:name,...processes(name,category_id)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"],"category_id":[1, 2]}, + {"factory":"Factory B","name":["Process B1", "Process B2"],"category_id":[1, 1]}, + {"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"],"category_id":[2, 2, 2, 2]}, + {"factory":"Factory D","name":[],"category_id":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/factories?select=factory:name,...processes(processes:name,categories:category_id)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","processes":["Process A1", "Process A2"],"categories":[1, 2]}, + {"factory":"Factory B","processes":["Process B1", "Process B2"],"categories":[1, 1]}, + {"factory":"Factory C","processes":["Process C1", "Process C2", "Process XX", "Process YY"],"categories":[2, 2, 2, 2]}, + {"factory":"Factory D","processes":[],"categories":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should return an empty array when no elements are found" $ + get "/factories?select=factory:name,...processes(processes:name)&processes=is.null" `shouldRespondWith` + [json|[ + {"factory":"Factory D","processes":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should return a single null element array, not an empty one, when the row exists but the value happens to be null" $ + get "/managers?select=name,...organizations(organizations:name,referees:referee)&id=eq.1" `shouldRespondWith` + [json|[ + {"name":"Referee Manager","organizations":["Referee Org"],"referees":[null]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should work when selecting all columns" $ + get "/factories?select=factory:name,...processes(*)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]}, + {"factory":"Factory B","id":[3, 4],"name":["Process B1", "Process B2"],"factory_id":[2, 2],"category_id":[1, 1]}, + {"factory":"Factory C","id":[5, 6, 7, 8],"name":["Process C1", "Process C2", "Process XX", "Process YY"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2]}, + {"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should select spread columns from a nested one-to-one relationship" $ + get "/factories?select=factory:name,...processes(process:name,...process_costs(process_costs:cost))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"process_costs":[150.00, 200.00]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"process_costs":[180.00, 70.00]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process YY", "Process XX"],"process_costs":[40.00, 70.00, 40.00, null]}, + {"factory":"Factory D","process":[],"process_costs":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should select spread columns from a nested many-to-one relationship" $ + get "/factories?select=factory:name,...processes(process:name,...process_categories(categories:name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"categories":["Batch", "Mass"]}, + {"factory":"Factory B","process":["Process B2", "Process B1"],"categories":["Batch", "Batch"]}, + {"factory":"Factory C","process":["Process YY", "Process XX", "Process C2", "Process C1"],"categories":["Mass", "Mass", "Mass", "Mass"]}, + {"factory":"Factory D","process":[],"categories":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should select spread columns from a nested one-to-many relationship" $ + get "/factories?select=factory:name,...processes(process:name,...process_supervisor(supervisor_ids:supervisor_id))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"supervisor_ids":[[1], [2]]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"supervisor_ids":[[3, 4], [1, 2]]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisor_ids":[[3], [3], [], []]}, + {"factory":"Factory D","process":[],"supervisor_ids":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should select spread columns from a nested many-to-many relationship" $ do + get "/factories?select=factory:name,...processes(process:name,...supervisors(supervisors:name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"supervisors":[["Mary"], ["John"]]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"supervisors":[["Peter", "Sarah"], ["Mary", "John"]]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisors":[["Peter"], ["Peter"], [], []]}, + {"factory":"Factory D","process":[],"supervisors":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread one-to-one relationship as an array of objects" $ do + get "/factories?select=factory:name,...processes(process:name,process_costs(cost))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"process_costs":[{"cost": 180.00}, {"cost": 70.00}]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process YY", "Process XX"],"process_costs":[{"cost": 40.00}, {"cost": 70.00}, {"cost": 40.00}, null]}, + {"factory":"Factory D","process":[],"process_costs":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread many-to-one relationship as an array of objects" $ + get "/factories?select=factory:name,...processes(process:name,process_categories(name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]}, + {"factory":"Factory B","process":["Process B2", "Process B1"],"process_categories":[{"name": "Batch"}, {"name": "Batch"}]}, + {"factory":"Factory C","process":["Process YY", "Process XX", "Process C2", "Process C1"],"process_categories":[{"name": "Mass"}, {"name": "Mass"}, {"name": "Mass"}, {"name": "Mass"}]}, + {"factory":"Factory D","process":[],"process_categories":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread one-to-many relationship as an array of arrays" $ + get "/factories?select=factory:name,...processes(process:name,process_supervisor(supervisor_id))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}]]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"process_supervisor":[[{"supervisor_id": 3}, {"supervisor_id": 4}], [{"supervisor_id": 1}, {"supervisor_id": 2}]]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"process_supervisor":[[{"supervisor_id": 3}], [{"supervisor_id": 3}], [], []]}, + {"factory":"Factory D","process":[],"process_supervisor":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread many-to-many relationship as an array of arrays" $ + get "/factories?select=factory:name,...processes(process:name,supervisors(name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process":["Process A1", "Process A2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}]]}, + {"factory":"Factory B","process":["Process B1", "Process B2"],"supervisors":[[{"name": "Peter"}, {"name": "Sarah"}], [{"name": "Mary"}, {"name": "John"}]]}, + {"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisors":[[{"name": "Peter"}], [{"name": "Peter"}], [], []]}, + {"factory":"Factory D","process":[],"supervisors":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should work when selecting all columns in a nested to-one resource" $ + get "/factories?select=factory:name,...processes(*,...process_costs(*))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2],"process_id":[1, 2],"cost":[150.00, 200.00]}, + {"factory":"Factory B","id":[3, 4],"name":["Process B1", "Process B2"],"factory_id":[2, 2],"category_id":[1, 1],"process_id":[3, 4],"cost":[180.00, 70.00]}, + {"factory":"Factory C","id":[5, 6, 8, 7],"name":["Process C1", "Process C2", "Process YY", "Process XX"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2],"process_id":[5, 6, 8, null],"cost":[40.00, 70.00, 40.00, null]}, + {"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[],"process_id":[],"cost":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "works when column filters are specified" $ + get "/factories?select=factory:name,...processes(*)&processes.name=not.like.*1&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[2],"name":["Process A2"],"factory_id":[1],"category_id":[2]}, + {"factory":"Factory B","id":[4],"name":["Process B2"],"factory_id":[2],"category_id":[1]}, + {"factory":"Factory C","id":[6, 7, 8],"name":["Process C2", "Process XX", "Process YY"],"factory_id":[3, 3, 3],"category_id":[2, 2, 2]}, + {"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "works with inner joins or not.is.null filters" $ do + get "/factories?select=factory:name,...processes!inner(name)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"]}, + {"factory":"Factory B","name":["Process B1", "Process B2"]}, + {"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/factories?select=factory:name,...processes(name)&processes=not.is.null&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"]}, + {"factory":"Factory B","name":["Process B1", "Process B2"]}, + {"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders ALL the resulting arrays according to the specified order in the spread relationship" $ + get "/factories?select=factory:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]}, + {"factory":"Factory B","id":[4, 3],"name":["Process B2", "Process B1"],"factory_id":[2, 2],"category_id":[1, 1]}, + {"factory":"Factory C","id":[8, 7, 6, 5],"name":["Process YY", "Process XX", "Process C2", "Process C1"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2]}, + {"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + context "many-to-many relationships" $ do + it "should spread a column as a json array" $ do + get "/operators?select=operator:name,...processes(name)&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","name":["Process C2", "Process XX"]}, + {"operator":"Anne","name":["Process A1", "Process A2", "Process B2"]}, + {"operator":"Jeff","name":["Process A2", "Process B1", "Process B2", "Process C2"]}, + {"operator":"Liz","name":[]}, + {"operator":"Louis","name":["Process A1", "Process A2"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/operators?select=operator:name,...processes(processes:name)&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","processes":["Process C2", "Process XX"]}, + {"operator":"Anne","processes":["Process A1", "Process A2", "Process B2"]}, + {"operator":"Jeff","processes":["Process A2", "Process B1", "Process B2", "Process C2"]}, + {"operator":"Liz","processes":[]}, + {"operator":"Louis","processes":["Process A1", "Process A2"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should spread many columns as json arrays" $ do + get "/operators?select=operator:name,...processes(name,category_id)&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","name":["Process C2", "Process XX"],"category_id":[2, 2]}, + {"operator":"Anne","name":["Process A1", "Process A2", "Process B2"],"category_id":[1, 2, 1]}, + {"operator":"Jeff","name":["Process A2", "Process B1", "Process B2", "Process C2"],"category_id":[2, 1, 1, 2]}, + {"operator":"Liz","name":[],"category_id":[]}, + {"operator":"Louis","name":["Process A1", "Process A2"],"category_id":[1, 2]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/operators?select=operator:name,...processes(processes:name,categories:category_id)&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","processes":["Process C2", "Process XX"],"categories":[2, 2]}, + {"operator":"Anne","processes":["Process A1", "Process A2", "Process B2"],"categories":[1, 2, 1]}, + {"operator":"Jeff","processes":["Process A2", "Process B1", "Process B2", "Process C2"],"categories":[2, 1, 1, 2]}, + {"operator":"Liz","processes":[],"categories":[]}, + {"operator":"Louis","processes":["Process A1", "Process A2"],"categories":[1, 2]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should return an empty array when no elements are found" $ + get "/operators?select=operator:name,...processes(processes:name)&processes=is.null" `shouldRespondWith` + [json|[ + {"operator":"Liz","processes":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should return a single null element array, not an empty one, when the row exists but the value happens to be null" $ + get "/operators?select=name,...processes(process:name,...process_costs(cost)))&id=eq.5&processes.id=eq.7" `shouldRespondWith` + [json|[ + {"name":"Alfred","process":["Process XX"],"cost":[null]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should work when selecting all columns" $ + get "/operators?select=operator:name,...processes(*)&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","id":[6, 7],"name":["Process C2", "Process XX"],"factory_id":[3, 3],"category_id":[2, 2]}, + {"operator":"Anne","id":[1, 2, 4],"name":["Process A1", "Process A2", "Process B2"],"factory_id":[1, 1, 2],"category_id":[1, 2, 1]}, + {"operator":"Jeff","id":[2, 3, 4, 6],"name":["Process A2", "Process B1", "Process B2", "Process C2"],"factory_id":[1, 2, 2, 3],"category_id":[2, 1, 1, 2]}, + {"operator":"Liz","id":[],"name":[],"factory_id":[],"category_id":[]}, + {"operator":"Louis","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show spread columns from a nested one-to-one relationship" $ + get "/operators?select=operator:name,...processes(process:name,...process_costs(process_costs:cost))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"process_costs":[70.00, null]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_costs":[150.00, 200.00, 70.00]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_costs":[200.00, 180.00, 70.00, 70.00]}, + {"operator":"Liz","process":[],"process_costs":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"process_costs":[150.00, 200.00]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show spread columns from a nested many-to-one relationship" $ + get "/operators?select=operator:name,...processes(process:name,...process_categories(categories:name))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"categories":["Mass", "Mass"]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"categories":["Batch", "Mass", "Batch"]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"categories":["Mass", "Batch", "Batch", "Mass"]}, + {"operator":"Liz","process":[],"categories":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"categories":["Batch", "Mass"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show spread columns from a nested one-to-many relationship" $ + get "/operators?select=operator:name,...processes(process:name,...process_supervisor(supervisor_ids:supervisor_id))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"supervisor_ids":[[3], []]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisor_ids":[[1], [2], [1, 2]]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisor_ids":[[2], [3, 4], [1, 2], [3]]}, + {"operator":"Liz","process":[],"supervisor_ids":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"supervisor_ids":[[1], [2]]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show spread columns from a nested many-to-many relationship" $ do + get "/operators?select=operator:name,...processes(process:name,...supervisors(supervisors:name))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"supervisors":[["Peter"], []]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisors":[["Mary"], ["John"], ["Mary", "John"]]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisors":[["John"], ["Peter", "Sarah"], ["Mary", "John"], ["Peter"]]}, + {"operator":"Liz","process":[],"supervisors":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"supervisors":[["Mary"], ["John"]]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread one-to-one relationship as an array of objects" $ do + get "/operators?select=operator:name,...processes(process:name,process_costs(cost))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"process_costs":[{"cost": 70.00}, null]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}, {"cost": 70.00}]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_costs":[{"cost": 200.00}, {"cost": 180.00}, {"cost": 70.00}, {"cost": 70.00}]}, + {"operator":"Liz","process":[],"process_costs":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread many-to-one relationship as an array of objects" $ + get "/operators?select=operator:name,...processes(process:name,process_categories(name))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"process_categories":[{"name": "Mass"}, {"name": "Mass"}]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}, {"name": "Batch"}]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_categories":[{"name": "Mass"}, {"name": "Batch"}, {"name": "Batch"}, {"name": "Mass"}]}, + {"operator":"Liz","process":[],"process_categories":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread one-to-many relationship as an array of arrays" $ + get "/operators?select=operator:name,...processes(process:name,process_supervisor(supervisor_id))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"process_supervisor":[[{"supervisor_id": 3}], []]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}], [{"supervisor_id": 1}, {"supervisor_id": 2}]]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_supervisor":[[{"supervisor_id": 2}], [{"supervisor_id": 3}, {"supervisor_id": 4}], [{"supervisor_id": 1}, {"supervisor_id": 2}], [{"supervisor_id": 3}]]}, + {"operator":"Liz","process":[],"process_supervisor":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}]]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should show a nested non-spread many-to-many relationship as an array of arrays" $ + get "/operators?select=operator:name,...processes(process:name,supervisors(name))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","process":["Process C2", "Process XX"],"supervisors":[[{"name": "Peter"}], []]}, + {"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}], [{"name": "Mary"}, {"name": "John"}]]}, + {"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisors":[[{"name": "John"}], [{"name": "Peter"}, {"name": "Sarah"}], [{"name": "Mary"}, {"name": "John"}], [{"name": "Peter"}]]}, + {"operator":"Liz","process":[],"supervisors":[]}, + {"operator":"Louis","process":["Process A1", "Process A2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}]]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "should work when selecting all columns in a nested to-one resource" $ + get "/operators?select=operator:name,...processes(*,...process_costs(*))&order=name" `shouldRespondWith` + [json|[ + {"operator":"Alfred","id":[6, 7],"name":["Process C2", "Process XX"],"factory_id":[3, 3],"category_id":[2, 2],"process_id":[6, null],"cost":[70.00, null]}, + {"operator":"Anne","id":[1, 2, 4],"name":["Process A1", "Process A2", "Process B2"],"factory_id":[1, 1, 2],"category_id":[1, 2, 1],"process_id":[1, 2, 4],"cost":[150.00, 200.00, 70.00]}, + {"operator":"Jeff","id":[2, 3, 4, 6],"name":["Process A2", "Process B1", "Process B2", "Process C2"],"factory_id":[1, 2, 2, 3],"category_id":[2, 1, 1, 2],"process_id":[2, 3, 4, 6],"cost":[200.00, 180.00, 70.00, 70.00]}, + {"operator":"Liz","id":[],"name":[],"factory_id":[],"category_id":[],"process_id":[],"cost":[]}, + {"operator":"Louis","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2],"process_id":[1, 2],"cost":[150.00, 200.00]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "works when column filters are specified" $ + get "/supervisors?select=supervisor:name,...processes(*)&processes.name=not.like.*1&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[]}, + {"supervisor":"John","id":[2, 4],"name":["Process A2", "Process B2"],"factory_id":[1, 2],"category_id":[2, 1]}, + {"supervisor":"Mary","id":[4],"name":["Process B2"],"factory_id":[2],"category_id":[1]}, + {"supervisor":"Peter","id":[6],"name":["Process C2"],"factory_id":[3],"category_id":[2]}, + {"supervisor":"Sarah","id":[],"name":[],"factory_id":[],"category_id":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "works with inner joins or not.is.null filters" $ do + get "/supervisors?select=supervisor:name,...processes!inner(name)&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"John","name":["Process A2", "Process B2"]}, + {"supervisor":"Mary","name":["Process A1", "Process B2"]}, + {"supervisor":"Peter","name":["Process B1", "Process C1", "Process C2"]}, + {"supervisor":"Sarah","name":["Process B1"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/supervisors?select=supervisor:name,...processes(name)&processes=not.is.null&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"John","name":["Process A2", "Process B2"]}, + {"supervisor":"Mary","name":["Process A1", "Process B2"]}, + {"supervisor":"Peter","name":["Process B1", "Process C1", "Process C2"]}, + {"supervisor":"Sarah","name":["Process B1"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders ALL the resulting arrays according to the specified order in the spread relationship" $ + get "/supervisors?select=supervisor:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[]}, + {"supervisor":"John","id":[4, 2],"name":["Process B2", "Process A2"],"factory_id":[2, 1],"category_id":[1, 2]}, + {"supervisor":"Mary","id":[4, 1],"name":["Process B2", "Process A1"],"factory_id":[2, 1],"category_id":[1, 1]}, + {"supervisor":"Peter","id":[3, 6, 5],"name":["Process B1", "Process C2", "Process C1"],"factory_id":[2, 3, 3],"category_id":[1, 2, 2]}, + {"supervisor":"Sarah","id":[3],"name":["Process B1"],"factory_id":[2],"category_id":[1]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } diff --git a/test/spec/fixtures/data.sql b/test/spec/fixtures/data.sql index 2f8cde4640..38b0d63f7a 100644 --- a/test/spec/fixtures/data.sql +++ b/test/spec/fixtures/data.sql @@ -901,19 +901,24 @@ INSERT INTO processes VALUES (3, 'Process B1', 2, 1); INSERT INTO processes VALUES (4, 'Process B2', 2, 1); INSERT INTO processes VALUES (5, 'Process C1', 3, 2); INSERT INTO processes VALUES (6, 'Process C2', 3, 2); +INSERT INTO processes VALUES (7, 'Process XX', 3, 2); +INSERT INTO processes VALUES (8, 'Process YY', 3, 2); TRUNCATE TABLE process_costs CASCADE; INSERT INTO process_costs VALUES (1, 150.00); INSERT INTO process_costs VALUES (2, 200.00); INSERT INTO process_costs VALUES (3, 180.00); INSERT INTO process_costs VALUES (4, 70.00); -INSERT INTO process_costs VALUES (5, 120.00); +INSERT INTO process_costs VALUES (5, 40.00); +INSERT INTO process_costs VALUES (6, 70.00); +INSERT INTO process_costs VALUES (8, 40.00); TRUNCATE TABLE supervisors CASCADE; INSERT INTO supervisors VALUES (1, 'Mary'); INSERT INTO supervisors VALUES (2, 'John'); INSERT INTO supervisors VALUES (3, 'Peter'); INSERT INTO supervisors VALUES (4, 'Sarah'); +INSERT INTO supervisors VALUES (5, 'Jane'); TRUNCATE TABLE process_supervisor CASCADE; INSERT INTO process_supervisor VALUES (1, 1); @@ -925,6 +930,34 @@ INSERT INTO process_supervisor VALUES (4, 2); INSERT INTO process_supervisor VALUES (5, 3); INSERT INTO process_supervisor VALUES (6, 3); +TRUNCATE TABLE operators CASCADE; +INSERT INTO operators VALUES (1, 'Anne'); +INSERT INTO operators VALUES (2, 'Louis'); +INSERT INTO operators VALUES (3, 'Jeff'); +INSERT INTO operators VALUES (4, 'Liz'); +INSERT INTO operators VALUES (5, 'Alfred'); + +TRUNCATE TABLE process_operator CASCADE; +INSERT INTO process_operator VALUES (1,1); +INSERT INTO process_operator VALUES (1,2); +INSERT INTO process_operator VALUES (2,1); +INSERT INTO process_operator VALUES (2,2); +INSERT INTO process_operator VALUES (2,3); +INSERT INTO process_operator VALUES (3,3); +INSERT INTO process_operator VALUES (4,1); +INSERT INTO process_operator VALUES (4,3); +INSERT INTO process_operator VALUES (6,3); +INSERT INTO process_operator VALUES (6,5); +INSERT INTO process_operator VALUES (7,5); + +TRUNCATE TABLE factory_buildings CASCADE; +INSERT INTO factory_buildings VALUES (1, 'A001', 150, 'A', 1); +INSERT INTO factory_buildings VALUES (2, 'A002', 200, 'A', 1); +INSERT INTO factory_buildings VALUES (3, 'B001', 50, 'B', 2); +INSERT INTO factory_buildings VALUES (4, 'B002', 120, 'C', 2); +INSERT INTO factory_buildings VALUES (5, 'C001', 240, 'B', 3); +INSERT INTO factory_buildings VALUES (6, 'D001', 310, 'A', 4); + TRUNCATE TABLE surr_serial_upsert CASCADE; INSERT INTO surr_serial_upsert(name, extra) VALUES ('value', 'existing value'); diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index c29a68bee1..9bd3bcbd65 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -3799,3 +3799,22 @@ create table surr_gen_default_upsert ( name text, extra text ); + +create table operators ( + id int primary key, + name text +); + +create table process_operator ( + process_id int references processes(id), + operator_id int references operators(id), + primary key (process_id, operator_id) +); + +create table factory_buildings ( + id int primary key, + code char(4), + size numeric, + "type" char(1), + factory_id int references factories(id) +); From 50926c6335e86e24d5f6074729f0c4359d15c3e4 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Wed, 27 Nov 2024 19:37:12 -0500 Subject: [PATCH 2/5] refactor --- src/PostgREST/Query/QueryBuilder.hs | 13 ++++----- src/PostgREST/Query/SqlFragment.hs | 29 ++++++++++--------- .../Feature/Query/AggregateFunctionsSpec.hs | 13 ++++++++- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 88cf99ff2a..7ab3edfc23 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -81,7 +81,7 @@ getJoinSelects (Node ReadPlan{relSelect} _) = JsonEmbed{rsSelName, rsEmbedMode = JsonArray} -> Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName Spread{rsSpreadSel, rsAggAlias} -> - Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem False rsAggAlias mempty <$> rsSpreadSel) + Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel) getJoins :: ReadPlanTree -> [SQL.Snippet] getJoins (Node _ []) = [] @@ -99,8 +99,8 @@ getJoin fld node@(Node ReadPlan{order, relJoinType, relSpread} _) = (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond subquery = readPlanToQuery node aggAlias = pgFmtIdent $ rsAggAlias fld - selectJsonArray = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias - wrapSubqAlias = " FROM (" <> subquery <> " ) AS " <> aggAlias + selectSubqAgg = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias + fromSubqAgg = " FROM (" <> subquery <> " ) AS " <> aggAlias joinCondition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE" in case fld of @@ -108,13 +108,12 @@ getJoin fld node@(Node ReadPlan{order, relJoinType, relSpread} _) = correlatedSubquery subquery aggAlias "TRUE" Spread{rsSpreadSel, rsAggAlias} -> if relSpread == Just ToManySpread then - let - selection = selectJsonArray <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadSelectItem True rsAggAlias order <$> rsSpreadSel) - in correlatedSubquery (selection <> wrapSubqAlias) aggAlias joinCondition + let selSpread = selectSubqAgg <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias order <$> rsSpreadSel) + in correlatedSubquery (selSpread <> fromSubqAgg) aggAlias joinCondition else correlatedSubquery subquery aggAlias "TRUE" JsonEmbed{rsEmbedMode = JsonArray} -> - correlatedSubquery (selectJsonArray <> wrapSubqAlias) aggAlias joinCondition + correlatedSubquery (selectSubqAgg <> fromSubqAgg) aggAlias joinCondition mutatePlanToQuery :: MutatePlan -> SQL.Snippet mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) = diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index 4df2f4a4dc..d4088c4c9b 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -23,6 +23,7 @@ module PostgREST.Query.SqlFragment , pgFmtOrderTerm , pgFmtSelectItem , pgFmtSpreadSelectItem + , pgFmtSpreadJoinSelectItem , fromJsonBodyF , responseHeadersF , responseStatusF @@ -271,14 +272,9 @@ pgFmtSelectItem :: QualifiedIdentifier -> CoercibleSelectField -> SQL.Snippet pgFmtSelectItem table CoercibleSelectField{csField=fld, csAggFunction=agg, csAggCast=aggCast, csCast=cast, csAlias=alias} = pgFmtApplyAggregate agg aggCast (pgFmtApplyCast cast (pgFmtTableCoerce table fld)) <> pgFmtAs alias -pgFmtSpreadSelectItem :: Bool -> Alias -> [CoercibleOrderTerm] -> SpreadSelectField -> SQL.Snippet -pgFmtSpreadSelectItem applyToManySpr aggAlias order SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} - | applyToManySpr = pgFmtApplyToManySpreadAgg ssSelAggFunction ssSelAggCast aggAlias order fullSelName <> " AS " <> pgFmtIdent (fromMaybe ssSelName ssSelAlias) - | otherwise = pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias - where - fullSelName = case ssSelName of - "*" -> pgFmtIdent aggAlias <> ".*" - _ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent ssSelName +pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet +pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} = + pgFmtApplyAggregate ssSelAggFunction ssSelAggCast (pgFmtFullSelName aggAlias ssSelName) <> pgFmtAs ssSelAlias pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet pgFmtApplyAggregate Nothing _ snippet = snippet @@ -290,11 +286,13 @@ pgFmtApplyAggregate (Just agg) aggCast snippet = convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")" -pgFmtApplyToManySpreadAgg :: Maybe AggregateFunction -> Maybe Cast -> Alias -> [CoercibleOrderTerm] -> SQL.Snippet -> SQL.Snippet -pgFmtApplyToManySpreadAgg Nothing aggCast relAggAlias order snippet = - "COALESCE(json_agg(" <> pgFmtApplyCast aggCast snippet <> orderF (QualifiedIdentifier "" relAggAlias) order <> "),'[]')::jsonb" -pgFmtApplyToManySpreadAgg agg aggCast _ _ snippet = - pgFmtApplyAggregate agg aggCast snippet +pgFmtSpreadJoinSelectItem :: Alias -> [CoercibleOrderTerm] -> SpreadSelectField -> SQL.Snippet +pgFmtSpreadJoinSelectItem aggAlias order SpreadSelectField{ssSelName, ssSelAlias} = + "COALESCE(json_agg(" <> fmtField <> " " <> fmtOrder <> "),'[]')::jsonb" <> " AS " <> fmtAlias + where + fmtField = pgFmtFullSelName aggAlias ssSelName + fmtOrder = orderF (QualifiedIdentifier "" aggAlias) order + fmtAlias = pgFmtIdent (fromMaybe ssSelName ssSelAlias) pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet pgFmtApplyCast Nothing snippet = snippet @@ -303,6 +301,11 @@ pgFmtApplyCast Nothing snippet = snippet -- Not quoting should be fine, we validate the input on Parsers. pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" +pgFmtFullSelName :: Alias -> FieldName -> SQL.Snippet +pgFmtFullSelName aggAlias fieldName = case fieldName of + "*" -> pgFmtIdent aggAlias <> ".*" + _ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent fieldName + -- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet fromJsonBodyF body fields includeSelect includeLimitOne includeDefaults = diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index 5812b5d552..da24f558a4 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -512,7 +512,7 @@ disallowed = { matchStatus = 400 , matchHeaders = [matchContentTypeJson] } - it "prevents the use of aggregates on spread embeds" $ + it "prevents the use of aggregates on to-one spread embeds" $ get "/project_invoices?select=...projects(id.count())" `shouldRespondWith` [json|{ "hint":null, @@ -522,3 +522,14 @@ disallowed = }|] { matchStatus = 400 , matchHeaders = [matchContentTypeJson] } + + it "prevents the use of aggregates on to-many spread embeds" $ + get "/factories?select=...processes(id.count())" `shouldRespondWith` + [json|{ + "hint":null, + "details":null, + "code":"PGRST123", + "message":"Use of aggregate functions is not allowed" + }|] + { matchStatus = 400 + , matchHeaders = [matchContentTypeJson] } From 4600a2b1a6bfcaefd873d9673db7178e88149d19 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Sun, 29 Dec 2024 10:58:23 -0500 Subject: [PATCH 3/5] add ordering on spread embeds without the need of selecting them --- docs/references/api/resource_embedding.rst | 2 - src/PostgREST/Plan.hs | 28 +++++++- src/PostgREST/Plan/ReadPlan.hs | 6 +- src/PostgREST/Plan/Types.hs | 9 +++ src/PostgREST/Query/QueryBuilder.hs | 22 +++--- test/spec/Feature/Query/SpreadQueriesSpec.hs | 74 +++++++++++++++++++- test/spec/fixtures/data.sql | 22 +++--- test/spec/fixtures/schema.sql | 6 +- 8 files changed, 137 insertions(+), 32 deletions(-) diff --git a/docs/references/api/resource_embedding.rst b/docs/references/api/resource_embedding.rst index 8436213043..15f870132d 100644 --- a/docs/references/api/resource_embedding.rst +++ b/docs/references/api/resource_embedding.rst @@ -1270,5 +1270,3 @@ You can still order all the resulting arrays explicitly. For example, to order b ] } ] - -Note that the field must be selected in the spread relationship for the order to work. diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index 88d42ecd81..503ad8a3a5 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -330,6 +330,7 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate mapLeft ApiRequestError $ treeRestrictRange configDbMaxRows (iAction apiRequest) =<< hoistSpreadAggFunctions =<< + addToManySpreadOrderSelects =<< validateAggFunctions configDbAggregates =<< addRelSelects =<< addNullEmbedFilters =<< @@ -429,7 +430,7 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias, relSpread=spread} children) = let - newHasAgg = hasAgg || any (isJust . csAggFunction) select || spread == Just ToManySpread + newHasAgg = hasAgg || any (isJust . csAggFunction) select || case spread of Just ToManySpread{} -> True; _ -> False newCtx = adjustContext ctx fromQI alias newRPlan = expandStarsForTable newCtx newHasAgg rp in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children) @@ -485,7 +486,7 @@ addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,re newReadPlan = (\r -> let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth) aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth - updSpread = if isJust relSpread && not (relIsToOne r) then Just ToManySpread else relSpread in + updSpread = if isJust relSpread && not (relIsToOne r) then Just $ ToManySpread [] [] else relSpread in case r of Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r, relSpread=updSpread} @@ -748,6 +749,29 @@ hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAl Nothing -> s hoistIntoRelSelectFields _ r = r +-- | Handle ordering in a To-Many Spread Relationship +-- In case of a To-Many Spread, it removes the ordering done in the ReadPlan and moves it to the SpreadType. +-- We also select the ordering columns and alias them to avoid collisions. This is because it would be impossible +-- to order once it's aggregated if it's not selected in the inner query beforehand. +addToManySpreadOrderSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree +addToManySpreadOrderSelects (Node rp@ReadPlan { order, relAggAlias, relSpread = Just ToManySpread {}} forest) = + Node rp { order = [], relSpread = newRelSpread } <$> addToManySpreadOrderSelects `traverse` forest + where + newRelSpread = Just ToManySpread { stExtraSelect = addSprExtraSelects, stOrder = addSprOrder} + (addSprExtraSelects, addSprOrder) = unzip $ zipWith ordToExtraSelsAndSprOrds [1..] order + ordToExtraSelsAndSprOrds i = \case + CoercibleOrderTerm fld dir ordr -> ( + (Nothing, CoercibleSelectField fld Nothing Nothing Nothing (Just $ selOrdAlias (cfName fld) i)), + CoercibleOrderTerm (unknownField (selOrdAlias (cfName fld) i) []) dir ordr + ) + CoercibleOrderRelationTerm rel (fld,jp) dir ordr -> ( + (Just rel, CoercibleSelectField (unknownField fld jp) Nothing Nothing Nothing (Just $ selOrdAlias fld i)), + CoercibleOrderTerm (unknownField (selOrdAlias fld i) []) dir ordr + ) + selOrdAlias :: Alias -> Integer -> Alias + selOrdAlias name i = relAggAlias <> "_" <> name <> "_" <> show i -- add index to avoid collisions in aliases +addToManySpreadOrderSelects (Node rp forest) = Node rp <$> addToManySpreadOrderSelects `traverse` forest + validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest) | not aggFunctionsAllowed && any (isJust . csAggFunction) select = Left AggregatesNotAllowed diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index edf8e23d2c..5563fbec8d 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -12,7 +12,8 @@ import PostgREST.ApiRequest.Types (Alias, Depth, Hint, import PostgREST.Plan.Types (CoercibleLogicTree, CoercibleOrderTerm, CoercibleSelectField (..), - RelSelectField (..)) + RelSelectField (..), + SpreadType (..)) import PostgREST.RangeQuery (NonnegRange) import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier) @@ -29,9 +30,6 @@ data JoinCondition = (QualifiedIdentifier, FieldName) deriving (Eq, Show) -data SpreadType = ToOneSpread | ToManySpread - deriving (Eq, Show) - data ReadPlan = ReadPlan { select :: [CoercibleSelectField] , from :: QualifiedIdentifier diff --git a/src/PostgREST/Plan/Types.hs b/src/PostgREST/Plan/Types.hs index 2d72c05419..15a2ff7a03 100644 --- a/src/PostgREST/Plan/Types.hs +++ b/src/PostgREST/Plan/Types.hs @@ -9,6 +9,7 @@ module PostgREST.Plan.Types , RelSelectField(..) , RelJsonEmbedMode(..) , SpreadSelectField(..) + , SpreadType(..) ) where import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast, @@ -105,3 +106,11 @@ data SpreadSelectField = , ssSelAlias :: Maybe Alias } deriving (Eq, Show) + +data SpreadType + = ToOneSpread + | ToManySpread + { stExtraSelect :: [(Maybe FieldName, CoercibleSelectField)] + , stOrder :: [CoercibleOrderTerm] + } + deriving (Eq, Show) diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 7ab3edfc23..6172796d15 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -47,14 +47,14 @@ import Protolude readPlanToQuery :: ReadPlanTree -> SQL.Snippet readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect, relSpread} forest) = "SELECT " <> - intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <> " " <> + intercalateSnippet ", " (selects ++ sprExtraSelects ++ joinsSelects) <> " " <> fromFrag <> " " <> intercalateSnippet " " joins <> " " <> (if null logicForest && null relJoinConds then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <> groupF qi select relSelect <> " " <> - orderFrag <> " " <> + orderF qi order <> " " <> limitOffsetF readRange where fromFrag = fromF relToParent mainQi fromAlias @@ -62,8 +62,11 @@ readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicFor -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing] joins = getJoins node + selects = pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select) joinsSelects = getJoinSelects node - orderFrag = if relSpread == Just ToManySpread then mempty else orderF qi order + sprExtraSelects = case relSpread of + Just (ToManySpread sels _) -> (\s -> pgFmtSelectItem (maybe qi (QualifiedIdentifier "") $ fst s) $ snd s) <$> sels + _ -> mempty getJoinSelects :: ReadPlanTree -> [SQL.Snippet] getJoinSelects (Node ReadPlan{relSelect} _) = @@ -93,7 +96,7 @@ getJoins (Node ReadPlan{relSelect} forest) = ) relSelect getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet -getJoin fld node@(Node ReadPlan{order, relJoinType, relSpread} _) = +getJoin fld node@(Node ReadPlan{relJoinType, relSpread} _) = let correlatedSubquery sub al cond = (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond @@ -107,11 +110,12 @@ getJoin fld node@(Node ReadPlan{order, relJoinType, relSpread} _) = JsonEmbed{rsEmbedMode = JsonObject} -> correlatedSubquery subquery aggAlias "TRUE" Spread{rsSpreadSel, rsAggAlias} -> - if relSpread == Just ToManySpread then - let selSpread = selectSubqAgg <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias order <$> rsSpreadSel) - in correlatedSubquery (selSpread <> fromSubqAgg) aggAlias joinCondition - else - correlatedSubquery subquery aggAlias "TRUE" + case relSpread of + Just (ToManySpread _ sprOrder) -> + let selSpread = selectSubqAgg <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias sprOrder <$> rsSpreadSel) + in correlatedSubquery (selSpread <> fromSubqAgg) aggAlias joinCondition + _ -> + correlatedSubquery subquery aggAlias "TRUE" JsonEmbed{rsEmbedMode = JsonArray} -> correlatedSubquery (selectSubqAgg <> fromSubqAgg) aggAlias joinCondition diff --git a/test/spec/Feature/Query/SpreadQueriesSpec.hs b/test/spec/Feature/Query/SpreadQueriesSpec.hs index 8a4e51309e..e584ae1b4e 100644 --- a/test/spec/Feature/Query/SpreadQueriesSpec.hs +++ b/test/spec/Feature/Query/SpreadQueriesSpec.hs @@ -290,7 +290,7 @@ spec = { matchStatus = 200 , matchHeaders = [matchContentTypeJson] } - it "orders ALL the resulting arrays according to the specified order in the spread relationship" $ + it "orders all the resulting arrays according to the spread relationship ordering columns" $ do get "/factories?select=factory:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` [json|[ {"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]}, @@ -301,6 +301,38 @@ spec = { matchStatus = 200 , matchHeaders = [matchContentTypeJson] } + get "/factories?select=factory:name,...factory_buildings(*)&factory_buildings.order=inspections->pending.asc.nullsfirst,inspections->ins.desc&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","id":[2, 1],"code":["A002", "A001"],"size":[200, 150],"type":["A", "A"],"factory_id":[1, 1],"inspections":[{"ins": "2025A", "pending": true}, {"ins": "2024C", "pending": true}]}, + {"factory":"Factory B","id":[4, 3],"code":["B002", "B001"],"size":[120, 50],"type":["C", "B"],"factory_id":[2, 2],"inspections":[{"ins": "2023A"}, {"ins": "2025A", "pending": true}]}, + {"factory":"Factory C","id":[5],"code":["C001"],"size":[240],"type":["B"],"factory_id":[3],"inspections":[{"ins": "2022B"}]}, + {"factory":"Factory D","id":[6],"code":["D001"],"size":[310],"type":["A"],"factory_id":[4],"inspections":[{"ins": "2024C", "pending": true}]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders all the resulting arrays according to the spread relationship ordering columns even if they aren't selected" $ + get "/factories?select=factory:name,...processes(name)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"]}, + {"factory":"Factory B","name":["Process B2", "Process B1"]}, + {"factory":"Factory C","name":["Process YY", "Process XX", "Process C2", "Process C1"]}, + {"factory":"Factory D","name":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders all the resulting arrays according to the related ordering columns in the spread relationship" $ + get "/factories?select=factory:name,...processes(name,...process_costs(cost))&processes.order=process_costs(cost)&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","name":["Process A1", "Process A2"],"cost":[150.00, 200.00]}, + {"factory":"Factory B","name":["Process B2", "Process B1"],"cost":[70.00, 180.00]}, + {"factory":"Factory C","name":["Process C1", "Process YY", "Process C2", "Process XX"],"cost":[40.00, 40.00, 70.00, null]}, + {"factory":"Factory D","name":[],"cost":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } context "many-to-many relationships" $ do it "should spread a column as a json array" $ do @@ -518,7 +550,7 @@ spec = { matchStatus = 200 , matchHeaders = [matchContentTypeJson] } - it "orders ALL the resulting arrays according to the specified order in the spread relationship" $ + it "orders all the resulting arrays according to the spread relationship ordering columns" $ do get "/supervisors?select=supervisor:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` [json|[ {"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[]}, @@ -530,3 +562,41 @@ spec = { matchStatus = 200 , matchHeaders = [matchContentTypeJson] } + get "/processes?select=process:name,...operators(*)&operators.order=status->afk.asc.nullsfirst,status->id.desc&order=name" `shouldRespondWith` + [json|[ + {"process":"Process A1","id":[2, 1],"name":["Louis", "Anne"],"status":[{"id": "012345"}, {"id": "543210", "afk": true}]}, + {"process":"Process A2","id":[2, 3, 1],"name":["Louis", "Jeff", "Anne"],"status":[{"id": "012345"}, {"id": "666666", "afk": true}, {"id": "543210", "afk": true}]}, + {"process":"Process B1","id":[3],"name":["Jeff"],"status":[{"id": "666666", "afk": true}]}, + {"process":"Process B2","id":[3, 1],"name":["Jeff", "Anne"],"status":[{"id": "666666", "afk": true}, {"id": "543210", "afk": true}]}, + {"process":"Process C1","id":[],"name":[],"status":[]}, + {"process":"Process C2","id":[5, 3],"name":["Alfred", "Jeff"],"status":[{"id": "000000"}, {"id": "666666", "afk": true}]}, + {"process":"Process XX","id":[5],"name":["Alfred"],"status":[{"id": "000000"}]}, + {"process":"Process YY","id":[],"name":[],"status":[]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders all the resulting arrays according to the spread relationship ordering columns even if they aren't selected" $ + get "/supervisors?select=supervisor:name,...processes(name)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","name":[]}, + {"supervisor":"John","name":["Process B2", "Process A2"]}, + {"supervisor":"Mary","name":["Process B2", "Process A1"]}, + {"supervisor":"Peter","name":["Process B1", "Process C2", "Process C1"]}, + {"supervisor":"Sarah","name":["Process B1"]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + it "orders all the resulting arrays according to the related ordering columns in the spread relationship" $ + get "/supervisors?select=supervisor:name,...processes(name,...process_costs(cost))&processes.order=process_costs(cost)&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","name":[],"cost":[]}, + {"supervisor":"John","name":["Process B2", "Process A2"],"cost":[70.00, 200.00]}, + {"supervisor":"Mary","name":["Process B2", "Process A1"],"cost":[70.00, 150.00]}, + {"supervisor":"Peter","name":["Process C1", "Process C2", "Process B1"],"cost":[40.00, 70.00, 180.00]}, + {"supervisor":"Sarah","name":["Process B1"],"cost":[180.00]} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } diff --git a/test/spec/fixtures/data.sql b/test/spec/fixtures/data.sql index 38b0d63f7a..548ecde556 100644 --- a/test/spec/fixtures/data.sql +++ b/test/spec/fixtures/data.sql @@ -931,11 +931,11 @@ INSERT INTO process_supervisor VALUES (5, 3); INSERT INTO process_supervisor VALUES (6, 3); TRUNCATE TABLE operators CASCADE; -INSERT INTO operators VALUES (1, 'Anne'); -INSERT INTO operators VALUES (2, 'Louis'); -INSERT INTO operators VALUES (3, 'Jeff'); -INSERT INTO operators VALUES (4, 'Liz'); -INSERT INTO operators VALUES (5, 'Alfred'); +INSERT INTO operators VALUES (1, 'Anne', '{"id": "543210", "afk": true}'); +INSERT INTO operators VALUES (2, 'Louis', '{"id": "012345"}'); +INSERT INTO operators VALUES (3, 'Jeff', '{"id": "666666", "afk": true}'); +INSERT INTO operators VALUES (4, 'Liz', '{"id": "999999"}'); +INSERT INTO operators VALUES (5, 'Alfred', '{"id": "000000"}'); TRUNCATE TABLE process_operator CASCADE; INSERT INTO process_operator VALUES (1,1); @@ -951,12 +951,12 @@ INSERT INTO process_operator VALUES (6,5); INSERT INTO process_operator VALUES (7,5); TRUNCATE TABLE factory_buildings CASCADE; -INSERT INTO factory_buildings VALUES (1, 'A001', 150, 'A', 1); -INSERT INTO factory_buildings VALUES (2, 'A002', 200, 'A', 1); -INSERT INTO factory_buildings VALUES (3, 'B001', 50, 'B', 2); -INSERT INTO factory_buildings VALUES (4, 'B002', 120, 'C', 2); -INSERT INTO factory_buildings VALUES (5, 'C001', 240, 'B', 3); -INSERT INTO factory_buildings VALUES (6, 'D001', 310, 'A', 4); +INSERT INTO factory_buildings VALUES (1, 'A001', 150, 'A', 1, '{"ins": "2024C", "pending": true}'); +INSERT INTO factory_buildings VALUES (2, 'A002', 200, 'A', 1, '{"ins": "2025A", "pending": true}'); +INSERT INTO factory_buildings VALUES (3, 'B001', 50, 'B', 2, '{"ins": "2025A", "pending": true}'); +INSERT INTO factory_buildings VALUES (4, 'B002', 120, 'C', 2, '{"ins": "2023A"}'); +INSERT INTO factory_buildings VALUES (5, 'C001', 240, 'B', 3, '{"ins": "2022B"}' ); +INSERT INTO factory_buildings VALUES (6, 'D001', 310, 'A', 4, '{"ins": "2024C", "pending": true}'); TRUNCATE TABLE surr_serial_upsert CASCADE; INSERT INTO surr_serial_upsert(name, extra) VALUES ('value', 'existing value'); diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index 9bd3bcbd65..797c181b85 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -3802,7 +3802,8 @@ create table surr_gen_default_upsert ( create table operators ( id int primary key, - name text + name text, + status jsonb ); create table process_operator ( @@ -3816,5 +3817,6 @@ create table factory_buildings ( code char(4), size numeric, "type" char(1), - factory_id int references factories(id) + factory_id int references factories(id), + inspections jsonb ); From 1ebc7e0f75a1b955b46c2b038b84013aa0bc96a8 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Thu, 2 Jan 2025 14:04:30 -0500 Subject: [PATCH 4/5] when a to-many spread has only aggregates selected, then it's not wrapped in an array (treated as a to-one spread) --- docs/references/api/aggregate_functions.rst | 25 +++ src/PostgREST/Plan.hs | 23 ++- .../Feature/Query/AggregateFunctionsSpec.hs | 159 ++++++++++-------- 3 files changed, 131 insertions(+), 76 deletions(-) diff --git a/docs/references/api/aggregate_functions.rst b/docs/references/api/aggregate_functions.rst index 27b9c3b449..5bf5ac4a65 100644 --- a/docs/references/api/aggregate_functions.rst +++ b/docs/references/api/aggregate_functions.rst @@ -318,3 +318,28 @@ For example: "order_date": ["2023-09-01", "2023-09-03"] } ] + +However, there is an exception. If only aggregates are selected, i. e. if there is no other columns to group by, then we can always expect a single row to be returned. +So, we don't wrap the single result in an array and show it directly. +For example, if we do not group by the ``order_date`` anymore: + +.. code-block:: bash + + curl "http://localhost:3000/customers?select=name,city,state,...orders(amount.sum())" + +.. code-block:: json + + [ + { + "name": "Customer A", + "city": "New York", + "state": "NY", + "sum": 1120.95 + }, + { + "name": "Customer B", + "city": "Los Angeles", + "state": "CA", + "sum": 755.58 + } + ] diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index 503ad8a3a5..184e18f0da 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -329,8 +329,8 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate in mapLeft ApiRequestError $ treeRestrictRange configDbMaxRows (iAction apiRequest) =<< - hoistSpreadAggFunctions =<< addToManySpreadOrderSelects =<< + hoistSpreadAggFunctions =<< validateAggFunctions configDbAggregates =<< addRelSelects =<< addNullEmbedFilters =<< @@ -749,15 +749,22 @@ hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAl Nothing -> s hoistIntoRelSelectFields _ r = r --- | Handle ordering in a To-Many Spread Relationship --- In case of a To-Many Spread, it removes the ordering done in the ReadPlan and moves it to the SpreadType. --- We also select the ordering columns and alias them to avoid collisions. This is because it would be impossible --- to order once it's aggregated if it's not selected in the inner query beforehand. +-- | Handle aggregates and ordering in a To-Many Spread Relationship +-- It does the following in case of a To-Many Spread +-- * When only aggregates are selected (no column to group by), it's always expected to return a single row. +-- That's why we treat these cases as a To-One Spread and they won't be wrapped in an array. +-- * It removes the ordering done in the ReadPlan and moves it to the SpreadType. +-- We also select the ordering columns and alias them to avoid collisions. This is because it would be impossible +-- to order once it's aggregated if it's not selected in the inner query beforehand. addToManySpreadOrderSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree -addToManySpreadOrderSelects (Node rp@ReadPlan { order, relAggAlias, relSpread = Just ToManySpread {}} forest) = - Node rp { order = [], relSpread = newRelSpread } <$> addToManySpreadOrderSelects `traverse` forest +addToManySpreadOrderSelects (Node rp@ReadPlan{order, select, relAggAlias, relSelect, relSpread = Just ToManySpread {}} forest) = + Node rp { order = newOrder, relSpread = newRelSpread } <$> addToManySpreadOrderSelects `traverse` forest where - newRelSpread = Just ToManySpread { stExtraSelect = addSprExtraSelects, stOrder = addSprOrder} + (newOrder, newRelSpread) + | allAggsSel && allAggsRelSel = (order, Just ToOneSpread) + | otherwise = ([], Just ToManySpread { stExtraSelect = addSprExtraSelects, stOrder = addSprOrder}) + allAggsSel = all (isJust . csAggFunction) select + allAggsRelSel = all (\case Spread sels _ -> all (isJust . ssSelAggFunction) sels; _ -> False) relSelect (addSprExtraSelects, addSprOrder) = unzip $ zipWith ordToExtraSelsAndSprOrds [1..] order ordToExtraSelsAndSprOrds i = \case CoercibleOrderTerm fld dir ordr -> ( diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index da24f558a4..33c3a482b2 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -306,69 +306,77 @@ allowed = it "supports the use of aggregates without grouping by any fields" $ do get "/factories?select=name,...factory_buildings(size.sum())" `shouldRespondWith` [json|[ - {"name":"Factory A","sum":[350]}, - {"name":"Factory B","sum":[170]}, - {"name":"Factory C","sum":[240]}, - {"name":"Factory D","sum":[310]}]|] + {"name":"Factory A","sum":350}, + {"name":"Factory B","sum":170}, + {"name":"Factory C","sum":240}, + {"name":"Factory D","sum":310}]|] { matchHeaders = [matchContentTypeJson] } it "supports many aggregates at the same time" $ do get "/factories?select=name,...factory_buildings(size.min(),size.max(),size.sum())" `shouldRespondWith` [json|[ - {"name":"Factory A","min":[150],"max":[200],"sum":[350]}, - {"name":"Factory B","min":[50],"max":[120],"sum":[170]}, - {"name":"Factory C","min":[240],"max":[240],"sum":[240]}, - {"name":"Factory D","min":[310],"max":[310],"sum":[310]}]|] + {"name":"Factory A","min":150,"max":200,"sum":350}, + {"name":"Factory B","min":50,"max":120,"sum":170}, + {"name":"Factory C","min":240,"max":240,"sum":240}, + {"name":"Factory D","min":310,"max":310,"sum":310}]|] { matchHeaders = [matchContentTypeJson] } it "supports aggregates inside nested to-one spread relationships" $ do get "/supervisors?select=name,...processes(...process_costs(cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"name":"Jane","sum":[null]}, - {"name":"John","sum":[270.00]}, - {"name":"Mary","sum":[220.00]}, - {"name":"Peter","sum":[290.00]}, - {"name":"Sarah","sum":[180.00]}]|] + {"name":"Jane","sum":null}, + {"name":"John","sum":270.00}, + {"name":"Mary","sum":220.00}, + {"name":"Peter","sum":290.00}, + {"name":"Sarah","sum":180.00}]|] { matchHeaders = [matchContentTypeJson] } get "/supervisors?select=supervisor:name,...processes(...process_costs(cost_sum:cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"supervisor":"Jane","cost_sum":[null]}, - {"supervisor":"John","cost_sum":[270.00]}, - {"supervisor":"Mary","cost_sum":[220.00]}, - {"supervisor":"Peter","cost_sum":[290.00]}, - {"supervisor":"Sarah","cost_sum":[180.00]}]|] + {"supervisor":"Jane","cost_sum":null}, + {"supervisor":"John","cost_sum":270.00}, + {"supervisor":"Mary","cost_sum":220.00}, + {"supervisor":"Peter","cost_sum":290.00}, + {"supervisor":"Sarah","cost_sum":180.00}]|] { matchHeaders = [matchContentTypeJson] } it "supports aggregates alongside the aggregates nested in to-one spread relationships" $ do get "/supervisors?select=name,...processes(id.count(),...process_costs(cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"name":"Jane","count":[0],"sum":[null]}, - {"name":"John","count":[2],"sum":[270.00]}, - {"name":"Mary","count":[2],"sum":[220.00]}, - {"name":"Peter","count":[3],"sum":[290.00]}, - {"name":"Sarah","count":[1],"sum":[180.00]}]|] + {"name":"Jane","count":0,"sum":null}, + {"name":"John","count":2,"sum":270.00}, + {"name":"Mary","count":2,"sum":220.00}, + {"name":"Peter","count":3,"sum":290.00}, + {"name":"Sarah","count":1,"sum":180.00}]|] { matchHeaders = [matchContentTypeJson] } get "/supervisors?select=supervisor:name,...processes(process_count:count(),...process_costs(cost_sum:cost.sum()))&order=name" `shouldRespondWith` [json|[ - {"supervisor":"Jane","process_count":[0],"cost_sum":[null]}, - {"supervisor":"John","process_count":[2],"cost_sum":[270.00]}, - {"supervisor":"Mary","process_count":[2],"cost_sum":[220.00]}, - {"supervisor":"Peter","process_count":[3],"cost_sum":[290.00]}, - {"supervisor":"Sarah","process_count":[1],"cost_sum":[180.00]}]|] + {"supervisor":"Jane","process_count":0,"cost_sum":null}, + {"supervisor":"John","process_count":2,"cost_sum":270.00}, + {"supervisor":"Mary","process_count":2,"cost_sum":220.00}, + {"supervisor":"Peter","process_count":3,"cost_sum":290.00}, + {"supervisor":"Sarah","process_count":1,"cost_sum":180.00}]|] { matchHeaders = [matchContentTypeJson] } it "supports aggregates on nested relationships" $ do get "/operators?select=name,...processes(id,...factories(...factory_buildings(size.sum())))&order=name" `shouldRespondWith` [json|[ - {"name":"Alfred","id":[6, 7],"sum":[[240], [240]]}, - {"name":"Anne","id":[1, 2, 4],"sum":[[350], [350], [170]]}, - {"name":"Jeff","id":[2, 3, 4, 6],"sum":[[350], [170], [170], [240]]}, + {"name":"Alfred","id":[6, 7],"sum":[240, 240]}, + {"name":"Anne","id":[1, 2, 4],"sum":[350, 350, 170]}, + {"name":"Jeff","id":[2, 3, 4, 6],"sum":[350, 170, 170, 240]}, {"name":"Liz","id":[],"sum":[]}, - {"name":"Louis","id":[1, 2],"sum":[[350], [350]]}]|] + {"name":"Louis","id":[1, 2],"sum":[350, 350]}]|] { matchHeaders = [matchContentTypeJson] } get "/operators?select=name,...processes(process_id:id,...factories(...factory_buildings(factory_building_size_sum:size.sum())))&order=name" `shouldRespondWith` [json|[ - {"name":"Alfred","process_id":[6, 7],"factory_building_size_sum":[[240], [240]]}, - {"name":"Anne","process_id":[1, 2, 4],"factory_building_size_sum":[[350], [350], [170]]}, - {"name":"Jeff","process_id":[2, 3, 4, 6],"factory_building_size_sum":[[350], [170], [170], [240]]}, + {"name":"Alfred","process_id":[6, 7],"factory_building_size_sum":[240, 240]}, + {"name":"Anne","process_id":[1, 2, 4],"factory_building_size_sum":[350, 350, 170]}, + {"name":"Jeff","process_id":[2, 3, 4, 6],"factory_building_size_sum":[350, 170, 170, 240]}, {"name":"Liz","process_id":[],"factory_building_size_sum":[]}, - {"name":"Louis","process_id":[1, 2],"factory_building_size_sum":[[350], [350]]}]|] + {"name":"Louis","process_id":[1, 2],"factory_building_size_sum":[350, 350]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/operators?select=name,...processes(process_id:id,...factories(...factory_buildings(factory_building_size_sum:size.sum())))&processes.order=id.desc&order=name" `shouldRespondWith` + [json|[ + {"name":"Alfred","process_id":[7, 6],"factory_building_size_sum":[240, 240]}, + {"name":"Anne","process_id":[4, 2, 1],"factory_building_size_sum":[170, 350, 350]}, + {"name":"Jeff","process_id":[6, 4, 3, 2],"factory_building_size_sum":[240, 170, 170, 350]}, + {"name":"Liz","process_id":[],"factory_building_size_sum":[]}, + {"name":"Louis","process_id":[2, 1],"factory_building_size_sum":[350, 350]}]|] { matchHeaders = [matchContentTypeJson] } context "supports count() aggregate without specifying a field" $ do @@ -376,17 +384,17 @@ allowed = it "works by itself in the embedded resource" $ do get "/factories?select=name,...processes(count())&order=name" `shouldRespondWith` [json|[ - {"name":"Factory A","count":[2]}, - {"name":"Factory B","count":[2]}, - {"name":"Factory C","count":[4]}, - {"name":"Factory D","count":[0]}]|] + {"name":"Factory A","count":2}, + {"name":"Factory B","count":2}, + {"name":"Factory C","count":4}, + {"name":"Factory D","count":0}]|] { matchHeaders = [matchContentTypeJson] } get "/factories?select=factory:name,...processes(processes_count:count())&order=name" `shouldRespondWith` [json|[ - {"factory":"Factory A","processes_count":[2]}, - {"factory":"Factory B","processes_count":[2]}, - {"factory":"Factory C","processes_count":[4]}, - {"factory":"Factory D","processes_count":[0]}]|] + {"factory":"Factory A","processes_count":2}, + {"factory":"Factory B","processes_count":2}, + {"factory":"Factory C","processes_count":4}, + {"factory":"Factory D","processes_count":0}]|] { matchHeaders = [matchContentTypeJson] } it "works alongside other columns in the embedded resource" $ do get "/factories?select=name,...processes(category_id,count())&order=name" `shouldRespondWith` @@ -413,16 +421,23 @@ allowed = it "works on nested resources" $ do get "/factories?select=id,...processes(name,...process_supervisor(count()))&order=id" `shouldRespondWith` [json|[ - {"id":1,"name":["Process A1", "Process A2"],"count":[[1], [1]]}, - {"id":2,"name":["Process B1", "Process B2"],"count":[[2], [2]]}, - {"id":3,"name":["Process C1", "Process C2", "Process XX", "Process YY"],"count":[[1], [1], [0], [0]]}, + {"id":1,"name":["Process A1", "Process A2"],"count":[1, 1]}, + {"id":2,"name":["Process B1", "Process B2"],"count":[2, 2]}, + {"id":3,"name":["Process C1", "Process C2", "Process XX", "Process YY"],"count":[1, 1, 0, 0]}, {"id":4,"name":[],"count":[]}]|] { matchHeaders = [matchContentTypeJson] } get "/factories?select=id,...processes(process:name,...process_supervisor(ps_count:count()))&order=id" `shouldRespondWith` [json|[ - {"id":1,"process":["Process A1", "Process A2"],"ps_count":[[1], [1]]}, - {"id":2,"process":["Process B1", "Process B2"],"ps_count":[[2], [2]]}, - {"id":3,"process":["Process C1", "Process C2", "Process XX", "Process YY"],"ps_count":[[1], [1], [0], [0]]}, + {"id":1,"process":["Process A1", "Process A2"],"ps_count":[1, 1]}, + {"id":2,"process":["Process B1", "Process B2"],"ps_count":[2, 2]}, + {"id":3,"process":["Process C1", "Process C2", "Process XX", "Process YY"],"ps_count":[1, 1, 0, 0]}, + {"id":4,"process":[],"ps_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/factories?select=id,...processes(process:name,...process_supervisor(ps_count:count()))&processes.order=name.desc&order=id" `shouldRespondWith` + [json|[ + {"id":1,"process":["Process A2", "Process A1"],"ps_count":[1, 1]}, + {"id":2,"process":["Process B2", "Process B1"],"ps_count":[2, 2]}, + {"id":3,"process":["Process YY", "Process XX", "Process C2", "Process C1"],"ps_count":[0, 0, 1, 1]}, {"id":4,"process":[],"ps_count":[]}]|] { matchHeaders = [matchContentTypeJson] } @@ -430,19 +445,19 @@ allowed = it "works by itself in the embedded resource" $ do get "/supervisors?select=name,...processes(count())&order=name" `shouldRespondWith` [json|[ - {"name":"Jane","count":[0]}, - {"name":"John","count":[2]}, - {"name":"Mary","count":[2]}, - {"name":"Peter","count":[3]}, - {"name":"Sarah","count":[1]}]|] + {"name":"Jane","count":0}, + {"name":"John","count":2}, + {"name":"Mary","count":2}, + {"name":"Peter","count":3}, + {"name":"Sarah","count":1}]|] { matchHeaders = [matchContentTypeJson] } get "/supervisors?select=supervisor:name,...processes(processes_count:count())&order=name" `shouldRespondWith` [json|[ - {"supervisor":"Jane","processes_count":[0]}, - {"supervisor":"John","processes_count":[2]}, - {"supervisor":"Mary","processes_count":[2]}, - {"supervisor":"Peter","processes_count":[3]}, - {"supervisor":"Sarah","processes_count":[1]}]|] + {"supervisor":"Jane","processes_count":0}, + {"supervisor":"John","processes_count":2}, + {"supervisor":"Mary","processes_count":2}, + {"supervisor":"Peter","processes_count":3}, + {"supervisor":"Sarah","processes_count":1}]|] { matchHeaders = [matchContentTypeJson] } it "works alongside other columns in the embedded resource" $ do get "/supervisors?select=name,...processes(category_id,count())&order=name" `shouldRespondWith` @@ -472,18 +487,26 @@ allowed = it "works on nested resources" $ do get "/supervisors?select=id,...processes(name,...operators(count()))&order=id" `shouldRespondWith` [json|[ - {"id":1,"name":["Process A1", "Process B2"],"count":[[2], [2]]}, - {"id":2,"name":["Process A2", "Process B2"],"count":[[3], [2]]}, - {"id":3,"name":["Process B1", "Process C1", "Process C2"],"count":[[1], [0], [2]]}, - {"id":4,"name":["Process B1"],"count":[[1]]}, + {"id":1,"name":["Process A1", "Process B2"],"count":[2, 2]}, + {"id":2,"name":["Process A2", "Process B2"],"count":[3, 2]}, + {"id":3,"name":["Process B1", "Process C1", "Process C2"],"count":[1, 0, 2]}, + {"id":4,"name":["Process B1"],"count":[1]}, {"id":5,"name":[],"count":[]}]|] { matchHeaders = [matchContentTypeJson] } get "/supervisors?select=supervisor:id,...processes(processes:name,...operators(operators_count:count()))&order=id" `shouldRespondWith` [json|[ - {"supervisor":1,"processes":["Process A1", "Process B2"],"operators_count":[[2], [2]]}, - {"supervisor":2,"processes":["Process A2", "Process B2"],"operators_count":[[3], [2]]}, - {"supervisor":3,"processes":["Process B1", "Process C1", "Process C2"],"operators_count":[[1], [0], [2]]}, - {"supervisor":4,"processes":["Process B1"],"operators_count":[[1]]}, + {"supervisor":1,"processes":["Process A1", "Process B2"],"operators_count":[2, 2]}, + {"supervisor":2,"processes":["Process A2", "Process B2"],"operators_count":[3, 2]}, + {"supervisor":3,"processes":["Process B1", "Process C1", "Process C2"],"operators_count":[1, 0, 2]}, + {"supervisor":4,"processes":["Process B1"],"operators_count":[1]}, + {"supervisor":5,"processes":[],"operators_count":[]}]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=supervisor:id,...processes(processes:name,...operators(operators_count:count()))&processes.order=name.desc&order=id" `shouldRespondWith` + [json|[ + {"supervisor":1,"processes":["Process B2", "Process A1"],"operators_count":[2, 2]}, + {"supervisor":2,"processes":["Process B2", "Process A2"],"operators_count":[2, 3]}, + {"supervisor":3,"processes":["Process C2", "Process C1", "Process B1"],"operators_count":[2, 0, 1]}, + {"supervisor":4,"processes":["Process B1"],"operators_count":[1]}, {"supervisor":5,"processes":[],"operators_count":[]}]|] { matchHeaders = [matchContentTypeJson] } From a8ecc056f6b3376d012367e892f4d59a5f2f7e17 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Fri, 3 Jan 2025 12:34:20 -0500 Subject: [PATCH 5/5] add missing tests --- .../Feature/Query/AggregateFunctionsSpec.hs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index 33c3a482b2..30c6effdba 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -440,6 +440,23 @@ allowed = {"id":3,"process":["Process YY", "Process XX", "Process C2", "Process C1"],"ps_count":[0, 0, 1, 1]}, {"id":4,"process":[],"ps_count":[]}]|] { matchHeaders = [matchContentTypeJson] } + it "works alongside to-one spread columns in the embedded resource" $ + get "/factories?select=factory:name,...processes(process_count:count(),...process_categories(category:name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process_count":[1, 1],"category":["Batch", "Mass"]}, + {"factory":"Factory B","process_count":[2],"category":["Batch"]}, + {"factory":"Factory C","process_count":[4],"category":["Mass"]}, + {"factory":"Factory D","process_count":[],"category":[]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works alongside non-spread embedded resources" $ + get "/factories?select=factory:name,...processes(process_count:count(),process_categories(category:name))&order=name" `shouldRespondWith` + [json|[ + {"factory":"Factory A","process_count":[1, 1],"process_categories":[{"category": "Batch"}, {"category": "Mass"}]}, + {"factory":"Factory B","process_count":[2],"process_categories":[{"category": "Batch"}]}, + {"factory":"Factory C","process_count":[4],"process_categories":[{"category": "Mass"}]}, + {"factory":"Factory D","process_count":[],"process_categories":[]}]|] + { matchHeaders = [matchContentTypeJson] } + context "many-to-many" $ do it "works by itself in the embedded resource" $ do @@ -509,6 +526,24 @@ allowed = {"supervisor":4,"processes":["Process B1"],"operators_count":[1]}, {"supervisor":5,"processes":[],"operators_count":[]}]|] { matchHeaders = [matchContentTypeJson] } + it "works alongside to-one spread columns in the embedded resource" $ do + get "/supervisors?select=supervisor:name,...processes(process_count:count(),...process_categories(name))&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","process_count":[],"name":[]}, + {"supervisor":"John","process_count":[1, 1],"name":["Batch", "Mass"]}, + {"supervisor":"Mary","process_count":[2],"name":["Batch"]}, + {"supervisor":"Peter","process_count":[1, 2],"name":["Batch", "Mass"]}, + {"supervisor":"Sarah","process_count":[1],"name":["Batch"]}]|] + { matchHeaders = [matchContentTypeJson] } + it "works alongside non-spread embedded resources" $ + get "/supervisors?select=supervisor:name,...processes(process_count:count(),process_categories(name))&order=name" `shouldRespondWith` + [json|[ + {"supervisor":"Jane","process_count":[],"process_categories":[]}, + {"supervisor":"John","process_count":[1, 1],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]}, + {"supervisor":"Mary","process_count":[2],"process_categories":[{"name": "Batch"}]}, + {"supervisor":"Peter","process_count":[1, 2],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]}, + {"supervisor":"Sarah","process_count":[1],"process_categories":[{"name": "Batch"}]}]|] + { matchHeaders = [matchContentTypeJson] } disallowed :: SpecWith ((), Application) disallowed =