diff --git a/src/PostgREST/ApiRequest/QueryParams.hs b/src/PostgREST/ApiRequest/QueryParams.hs index b8f21fd181..99f61078ea 100644 --- a/src/PostgREST/ApiRequest/QueryParams.hs +++ b/src/PostgREST/ApiRequest/QueryParams.hs @@ -310,7 +310,8 @@ pTreePath = do pFieldForest :: Parser [Tree SelectItem] pFieldForest = pFieldTree `sepBy` lexeme (char ',') where - pFieldTree = Node <$> try pSpreadRelationSelect <*> between (char '(') (char ')') pFieldForest <|> + pFieldTree = Node <$> try pHoistedRelationSelect <*> between (char '(') (char ')') pFieldForest <|> + Node <$> try pSpreadRelationSelect <*> between (char '(') (char ')') pFieldForest <|> Node <$> try pRelationSelect <*> between (char '(') (char ')') pFieldForest <|> Node <$> pFieldSelect <*> pure [] @@ -582,6 +583,13 @@ pSpreadRelationSelect = lexeme $ do try (void $ lookAhead (string "(")) return $ SpreadRelation name hint jType +pHoistedRelationSelect :: Parser SelectItem +pHoistedRelationSelect = lexeme $ do + name <- string "^" >> pFieldName + (hint, jType) <- pEmbedParams + try (void $ lookAhead (string "(")) + return $ HoistedRelation name hint jType + pEmbedParams :: Parser (Maybe Hint, Maybe JoinType) pEmbedParams = do prm1 <- optionMaybe pEmbedParam diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index a53d008d42..723170caf7 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -58,6 +58,12 @@ data SelectItem , selHint :: Maybe Hint , selJoinType :: Maybe JoinType } +-- | The value in `/tbl?select=^another_tbl(*)` + | HoistedRelation + { selRelation :: FieldName + , selHint :: Maybe Hint + , selJoinType :: Maybe JoinType + } deriving (Eq, Show) type NodeName = Text diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index 38b7d1dcb5..7b37db8589 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -15,6 +15,9 @@ resource. {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE RecordWildCards #-} +-- TODO(draft): Remove and handle +{-# OPTIONS_GHC -Wno-incomplete-patterns #-} + module PostgREST.Plan ( actionPlan , ActionPlan(..) @@ -339,7 +342,7 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate in mapLeft ApiRequestError $ treeRestrictRange configDbMaxRows (iAction apiRequest) =<< - addToManyOrderSelects =<< + addToManySpreadOrderSelects =<< hoistSpreadAggFunctions =<< validateAggFunctions configDbAggregates =<< addRelSelects =<< @@ -359,7 +362,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 Nothing [] rootDepth + defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing False Nothing Nothing [] rootDepth treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry depth (Node si fldForest) (Node q rForest) = let nxtDepth = succ depth in @@ -374,6 +377,11 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = foldr (treeEntry nxtDepth) (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relSpread=Just ToOneSpread} []) fldForest:rForest + HoistedRelation{..} -> + Node q $ + foldr (treeEntry nxtDepth) + (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsHoisted=True} []) + fldForest:rForest SelectField{..} -> Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest @@ -381,32 +389,33 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = -- determined automatically in these cases: -- * A select term with a JSON path -- * Domain representations --- * Aggregates in spread relationships +-- * Aggregates in spread or hoisted relationships addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree addAliases = Right . fmap addAliasToPlan where - addAliasToPlan rp@ReadPlan{select=sel, relSpread=spr} = rp{select=map (aliasSelectField $ isJust spr) sel} + addAliasToPlan rp@ReadPlan{select=sel, relSpread=spr, relIsHoisted=hoi} = rp{select=map (aliasSelectField (isJust spr || hoi)) sel} aliasSelectField :: Bool -> CoercibleSelectField -> CoercibleSelectField - aliasSelectField isSpread field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias} + aliasSelectField shouldAlias field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias} | isJust alias = field - | isJust aggFun = fieldAliasForSpreadAgg isSpread field + | isJust aggFun = fieldAliasForSpreadOrHoistedAgg shouldAlias field | isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key } | isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) } | 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 this case: - -- 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. + -- Hoisted 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} = + fieldAliasForSpreadOrHoistedAgg True field@CoercibleSelectField{csAggFunction=Just agg} = field { csAlias = Just (T.toLower $ show agg) } - fieldAliasForSpreadAgg _ field = field + fieldAliasForSpreadOrHoistedAgg _ field = field isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True isJsonKeyPath _ = False @@ -452,18 +461,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, relSpread=spread} +expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread, relIsHoisted=hoisted} -- 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 (isJust spread) knownColumns) selectFields} + | hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread || hoisted) knownColumns) selectFields} | otherwise = rp where hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields filteredSelectFields = filter (shouldExpandOrTag . csAggFunction) selectFields - shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count) + shouldExpandOrTag aggFunc = isNothing aggFunc || ((isJust spread || hoisted) && aggFunc == Just Count) hasDataRepresentation = any hasOutputRep knownColumns knownColumns = knownColumnsInContext ctx @@ -643,6 +652,8 @@ addRelSelects node@(Node rp forest) in Right $ Node rp { relSelect = newRelSelects } newForest generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField +generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsHoisted=True} _) = + Just $ Hoisted { rsHoistedSel = generateHoistedSelectFields rp, rsAggAlias = relAggAlias } 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, relSpread = Nothing} forest) = @@ -674,54 +685,59 @@ generateSpreadSelectFields ReadPlan{select, relSelect} = relSelectToSpread (Spread{rsSpreadSel}) = rsSpreadSel --- 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())` --- --- In this case, it is sensible that we would expect to receive the sum of the --- `invoice_total`, grouped by the `client_id`. Without hoisting, the sum would --- be performed in the sub-query for the joined table `project_invoices`, thus --- making it essentially a no-op. With hoisting, we hoist the aggregate function --- so that the aggregate function is performed in a more sensible context. --- --- We will try to hoist the aggregate function to the highest possible level, --- which means that we hoist until we reach the root node, or until we reach a --- ReadPlan that will be embedded a JSON object or JSON array. +-- TODO(draft): DRY +generateHoistedSelectFields :: ReadPlan -> [HoistedSelectField] +generateHoistedSelectFields ReadPlan{select, relSelect} = + -- We combine the select and relSelect fields into a single list of HoistedSelectField. + selectSpread ++ relSelectSpread + where + selectSpread = map selectToSpread select + selectToSpread :: CoercibleSelectField -> HoistedSelectField + selectToSpread CoercibleSelectField{csField = CoercibleField{cfName}, csAlias} = + HoistedSelectField { hsSelName = fromMaybe cfName csAlias, hsSelAggFunction = Nothing, hsSelAggCast = Nothing, hsSelAlias = Nothing } + relSelectSpread = concatMap relSelectToSpread relSelect + relSelectToSpread :: RelSelectField -> [HoistedSelectField] + relSelectToSpread (JsonEmbed{rsSelName}) = + [HoistedSelectField { hsSelName = rsSelName, hsSelAggFunction = Nothing, hsSelAggCast = Nothing, hsSelAlias = Nothing }] + relSelectToSpread (Hoisted{rsHoistedSel}) = + rsHoistedSel + +-- When an aggregate is present, it hoists the aggregate and columns to group by to the +-- containing relationship. It keeps hoisting until it's no longer contained by a +-- HoistedRelation. +-- -- This type alias represents an aggregate that is to be hoisted to the next -- level up. The first tuple of `Alias` and `FieldName` contain the alias for -- the joined table and the original field name for the hoisted field. -- -- 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 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, relSpread} children) = +applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relIsHoisted} children) = let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children allChildAggLists = concat childAggLists - isToOneSpread = relSpread == Just ToOneSpread - (newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not isToOneSpread) + (newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsHoisted) then (select rp, []) else hoistFromSelectFields relAggAlias (select rp) - -- If the current `ReadPlan` is a to-one spread rel and it has aggregates hoisted from + -- If the current `ReadPlan` is a hoisted relationship 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 isToOneSpread + hoistedAggList = if relIsHoisted then aggList ++ map hoistAgg allChildAggLists else aggList - newRelSelects = if null children || isToOneSpread + newRelSelects = if null children || relIsHoisted then relSelect rp else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp + -- TODO(draft): handle to-many hoisting as NotImplemented in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, hoistedAggList) -- Hoist aggregate functions from the select list of a ReadPlan, and return the @@ -745,30 +761,34 @@ hoistFromSelectFields relAggAlias fields = -- Taking the hoisted aggregates, modify the rel selects to apply the aggregates, -- and any applicable casts or aliases. hoistIntoRelSelectFields :: [HoistedAgg] -> RelSelectField -> RelSelectField -hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAlias = relAggAlias}) = - r { rsSpreadSel = map updateSelect spreadSelects } +hoistIntoRelSelectFields aggList r@(Hoisted {rsHoistedSel = hoistedSelects, rsAggAlias = relAggAlias}) = + r { rsHoistedSel = map updateSelect hoistedSelects } where - updateSelect s = - case lookup (relAggAlias, ssSelName s) aggList of + updateSelect h = + case lookup (relAggAlias, hsSelName h) aggList of Just (aggFunc, aggCast, fldAlias) -> - s { ssSelAggFunction = Just aggFunc, - ssSelAggCast = aggCast, - ssSelAlias = fldAlias } - Nothing -> s + h { hsSelAggFunction = Just aggFunc, + hsSelAggCast = aggCast, + hsSelAlias = fldAlias } + Nothing -> h hoistIntoRelSelectFields _ r = r --- | Handle ordering in a To-Many Spread Relationship +-- | 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. -addToManyOrderSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree -addToManyOrderSelects (Node rp@ReadPlan{order, select, relAggAlias, relSelect, relSpread = Just ToManySpread {}} forest) - | anyAggSel || anyAggRelSel = Left $ NotImplemented "Aggregates are not implemented for one-to-many or many-to-many spreads." - | otherwise = Node rp { order = [], relSpread = newRelSpread } <$> addToManyOrderSelects `traverse` forest +addToManySpreadOrderSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree +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} - anyAggSel = any (isJust . csAggFunction) select - anyAggRelSel = any (\case Spread sels _ -> any (isJust . ssSelAggFunction) sels; _ -> False) relSelect + (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 -> ( @@ -781,7 +801,7 @@ addToManyOrderSelects (Node rp@ReadPlan{order, select, relAggAlias, relSelect, r ) selOrdAlias :: Alias -> Integer -> Alias selOrdAlias name i = relAggAlias <> "_" <> name <> "_" <> show i -- add index to avoid collisions in aliases -addToManyOrderSelects (Node rp forest) = Node rp <$> addToManyOrderSelects `traverse` forest +addToManySpreadOrderSelects (Node rp forest) = Node rp <$> addToManySpreadOrderSelects `traverse` forest validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest) diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index 5563fbec8d..4e171bf194 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -43,6 +43,7 @@ data ReadPlan = ReadPlan , relAlias :: Maybe Alias , relAggAlias :: Alias , relHint :: Maybe Hint + , relIsHoisted :: Bool , relJoinType :: Maybe JoinType , relSpread :: Maybe SpreadType , relSelect :: [RelSelectField] diff --git a/src/PostgREST/Plan/Types.hs b/src/PostgREST/Plan/Types.hs index 59bf52e36c..21d867949f 100644 --- a/src/PostgREST/Plan/Types.hs +++ b/src/PostgREST/Plan/Types.hs @@ -10,6 +10,7 @@ module PostgREST.Plan.Types , RelSelectField(..) , RelJsonEmbedMode(..) , SpreadSelectField(..) + , HoistedSelectField(..) , SpreadType(..) ) where @@ -102,6 +103,10 @@ data RelSelectField { rsSpreadSel :: [SpreadSelectField] , rsAggAlias :: Alias } + | Hoisted + { rsHoistedSel :: [HoistedSelectField] + , rsAggAlias :: Alias + } deriving (Eq, Show) data SpreadSelectField = @@ -113,6 +118,16 @@ data SpreadSelectField = } deriving (Eq, Show) +-- TODO(draft): DRY +data HoistedSelectField = + HoistedSelectField + { hsSelName :: FieldName + , hsSelAggFunction :: Maybe AggregateFunction + , hsSelAggCast :: Maybe Cast + , hsSelAlias :: Maybe Alias + } + deriving (Eq, Show) + data SpreadType = ToOneSpread | ToManySpread diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 33193184cf..1222169dd4 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -82,6 +82,8 @@ getJoinSelects (Node ReadPlan{relSelect} _) = Just $ "row_to_json(" <> aggAlias <> ".*)::jsonb AS " <> pgFmtIdent rsSelName JsonEmbed{rsSelName, rsEmbedMode = JsonArray} -> Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName + Hoisted{rsHoistedSel, rsAggAlias} -> + Just $ intercalateSnippet ", " (pgFmtHoistedSelectItem rsAggAlias <$> rsHoistedSel) Spread{rsSpreadSel, rsAggAlias} -> Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel) @@ -108,6 +110,8 @@ getJoin fld node@(Node ReadPlan{relJoinType, relSpread} _) = case fld of JsonEmbed{rsEmbedMode = JsonObject} -> correlatedSubquery subquery aggAlias "TRUE" + Hoisted{} -> + correlatedSubquery subquery aggAlias "TRUE" Spread{rsSpreadSel, rsAggAlias} -> case relSpread of Just (ToManySpread _ sprOrder) -> diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index 0a36365b78..306659ad81 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -16,6 +16,7 @@ module PostgREST.Query.SqlFragment , orderF , pgFmtColumn , pgFmtFilter + , pgFmtHoistedSelectItem , pgFmtIdent , pgFmtJoinCondition , pgFmtLogicTree @@ -79,6 +80,7 @@ import PostgREST.Plan.Types (CoercibleField (..), CoercibleLogicTree (..), CoercibleOrderTerm (..), CoercibleSelectField (..), + HoistedSelectField (..), RelSelectField (..), SpreadSelectField (..), ToTsVector (..), @@ -281,8 +283,13 @@ pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} = pgFmtApplyAggregate ssSelAggFunction ssSelAggCast (pgFmtFullSelName aggAlias ssSelName) <> pgFmtAs ssSelAlias +-- TODO(draft): DRY +pgFmtHoistedSelectItem :: Alias -> HoistedSelectField -> SQL.Snippet +pgFmtHoistedSelectItem aggAlias HoistedSelectField{hsSelName, hsSelAggFunction, hsSelAggCast, hsSelAlias} = + pgFmtApplyAggregate hsSelAggFunction hsSelAggCast (pgFmtFullSelName aggAlias hsSelName) <> pgFmtAs hsSelAlias + pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet -pgFmtApplyAggregate Nothing _ snippet = snippet +pgFmtApplyAggregate Nothing _ snippet =snippet pgFmtApplyAggregate (Just agg) aggCast snippet = pgFmtApplyCast aggCast aggregatedSnippet where @@ -458,7 +465,7 @@ groupF qi select relSelect | otherwise = " GROUP BY " <> intercalateSnippet ", " groupTerms where noSelectsAreAggregated = null $ [s | s@(CoercibleSelectField { csAggFunction = Just _ }) <- select] - noRelSelectsAreAggregated = all (\case Spread sels _ -> all (isNothing . ssSelAggFunction) sels; _ -> True) relSelect + noRelSelectsAreAggregated = all (\case Hoisted sels _ -> all (isNothing . hsSelAggFunction) sels; _ -> True) relSelect groupTermsFromSelect = mapMaybe (pgFmtGroup qi) select groupTermsFromRelSelect = mapMaybe groupTermFromRelSelectField relSelect groupTerms = groupTermsFromSelect ++ groupTermsFromRelSelect @@ -477,6 +484,19 @@ groupTermFromRelSelectField (Spread { rsSpreadSel, rsAggAlias }) = processField SpreadSelectField{ssSelName, ssSelAlias} = Just $ pgFmtIdent rsAggAlias <> "." <> pgFmtIdent (fromMaybe ssSelName ssSelAlias) groupTerms = mapMaybe processField rsSpreadSel +-- TODO(draft): DRY +groupTermFromRelSelectField (Hoisted { rsHoistedSel, rsAggAlias }) = + if null groupTerms + then Nothing + else + Just $ intercalateSnippet ", " groupTerms + where + processField :: HoistedSelectField -> Maybe SQL.Snippet + processField HoistedSelectField{hsSelAggFunction = Just _} = Nothing + processField HoistedSelectField{hsSelName, hsSelAlias} = + Just $ pgFmtIdent rsAggAlias <> "." <> pgFmtIdent (fromMaybe hsSelName hsSelAlias) + groupTerms = mapMaybe processField rsHoistedSel + pgFmtGroup :: QualifiedIdentifier -> CoercibleSelectField -> Maybe SQL.Snippet pgFmtGroup _ CoercibleSelectField{csAggFunction=Just _} = Nothing diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index 2d0d4d9d98..8dc5b82b25 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -140,58 +140,58 @@ allowed = {"name":"Orphan","project_invoices":[{"avg": null, "max": null, "min": null, "sum": null, "count": 0}]}]|] { matchHeaders = [matchContentTypeJson] } - context "performing aggregations on spreaded fields from an embedded resource" $ do - 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` + context "performing aggregations on hoisted relationships" $ do + context "to-one hoisted relationships" $ do + it "supports hoisting 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` + 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` + 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` + 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` + 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` + 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` + 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` + 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` + 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"}, @@ -200,7 +200,7 @@ allowed = {"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` + 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"}, @@ -210,7 +210,7 @@ allowed = {"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` + 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}]}, @@ -218,7 +218,7 @@ allowed = {"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` + 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}]}, @@ -229,14 +229,14 @@ allowed = 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` + 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` + 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}, @@ -244,14 +244,14 @@ allowed = {"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` + 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` + 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}, @@ -259,14 +259,14 @@ allowed = {"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` + 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` + 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}, @@ -274,37 +274,338 @@ allowed = {"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` + 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` + 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` + 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 "performing aggregations on spreaded fields from an embedded resource" $ do + context "to-one spread relationships" $ + -- TODO(draft): maybe remove or rename. Just an informative example that it no longer hoists the aggregate. + it "supports the use of aggregates but does not hoist them to the top" $ + get "/process_supervisor?select=...processes(factory_id,...process_costs(cost.sum()))" `shouldRespondWith` + [json|[{"factory_id": 1, "sum": 150.00}, + {"factory_id": 1, "sum": 200.00}, + {"factory_id": 2, "sum": 180.00}, + {"factory_id": 2, "sum": 180.00}, + {"factory_id": 2, "sum": 70.00}, + {"factory_id": 2, "sum": 70.00}, + {"factory_id": 3, "sum": 40.00}, + {"factory_id": 3, "sum": 70.00}]|] + { matchHeaders = [matchContentTypeJson] } + context "to-many spread relationships" $ do - it "does not support the use of aggregates" $ do + it "supports the use of aggregates" $ do get "/factories?select=name,...factory_buildings(type,size.sum())" `shouldRespondWith` - [json|{ - "code": "PGRST127", - "message":"Feature not implemented", - "details":"Aggregates are not implemented for one-to-many or many-to-many spreads.", - "hint":null - }|] - { matchStatus = 400 - , matchHeaders = [matchContentTypeJson] } + [json|[ + {"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] } + 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}]|] + { 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}]|] + { matchHeaders = [matchContentTypeJson] } + {-- TODO(draft): fix + 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}]|] + { 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}]|] + { 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}]|] + { 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}]|] + { 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":"Liz","id":[],"sum":[]}, + {"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":"Liz","process_id":[],"factory_building_size_sum":[]}, + {"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 + 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] } + 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] } + 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] } + it "works inside non-spread embedded resources" $ do + get "/process_categories?select=...processes(name,factories(name,...factory_buildings(buildings:count())))" `shouldRespondWith` + [json|[ + { + "name":["Process A1", "Process B1", "Process B2"], + "factories":[{"name": "Factory A", "buildings": 2}, {"name": "Factory B", "buildings": 2}, {"name": "Factory B", "buildings": 2}] + }, + { + "name":["Process A2", "Process C1", "Process C2", "Process XX", "Process YY"], + "factories":[{"name": "Factory A", "buildings": 2}, {"name": "Factory C", "buildings": 1}, {"name": "Factory C", "buildings": 1}, {"name": "Factory C", "buildings": 1}, {"name": "Factory C", "buildings": 1}] + } + ]|] + { matchHeaders = [matchContentTypeJson] } + get "/process_categories?select=...processes(name,operators(count()))" `shouldRespondWith` + [json|[ + { + "name":["Process A1", "Process B1", "Process B2"], + "operators":[[{"count": 2}], [{"count": 1}], [{"count": 2}]] + }, + { + "name":["Process A2", "Process C1", "Process C2", "Process XX", "Process YY"], + "operators":[[{"count": 3}], [{"count": 0}], [{"count": 2}], [{"count": 1}], [{"count": 0}]] + } + ]|] + { 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] } + 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] } + 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] } + it "works inside non-spread embedded resources" $ do + get "/supervisors?select=...processes(name,factories(name,...factory_buildings(buildings:count())))" `shouldRespondWith` + [json|[ + {"name":["Process A1", "Process B2"],"factories":[{"name": "Factory A", "buildings": 2}, {"name": "Factory B", "buildings": 2}]}, + {"name":["Process A2", "Process B2"],"factories":[{"name": "Factory A", "buildings": 2}, {"name": "Factory B", "buildings": 2}]}, + {"name":["Process B1", "Process C1", "Process C2"],"factories":[{"name": "Factory B", "buildings": 2}, {"name": "Factory C", "buildings": 1}, {"name": "Factory C", "buildings": 1}]}, + {"name":["Process B1"],"factories":[{"name": "Factory B", "buildings": 2}]}, + {"name":[],"factories":[]} + ]|] + { matchHeaders = [matchContentTypeJson] } + get "/supervisors?select=...processes(name,operators(count()))" `shouldRespondWith` + [json|[ + {"name":["Process A1", "Process B2"],"operators":[[{"count": 2}], [{"count": 2}]]}, + {"name":["Process A2", "Process B2"],"operators":[[{"count": 3}], [{"count": 2}]]}, + {"name":["Process B1", "Process C1", "Process C2"],"operators":[[{"count": 1}], [{"count": 0}], [{"count": 2}]]}, + {"name":["Process B1"],"operators":[[{"count": 1}]]}, + {"name":[],"operators":[]} + ]|] + { matchHeaders = [matchContentTypeJson] } disallowed :: SpecWith ((), Application) disallowed =