From 78fe42aa7f364eec8529b15d5e6c2a134d879ccc Mon Sep 17 00:00:00 2001 From: Tim Abdulla Date: Tue, 17 Oct 2023 20:48:33 +0200 Subject: [PATCH] Add support for aggregate functions The aggregate functions SUM(), MAX(), MIN(), AVG(), and COUNT() are now supported. --- postgrest.cabal | 1 + src/PostgREST/ApiRequest/QueryParams.hs | 39 +++- src/PostgREST/ApiRequest/Types.hs | 16 +- src/PostgREST/Plan.hs | 219 +++++++++++++++--- src/PostgREST/Plan/ReadPlan.hs | 12 +- src/PostgREST/Plan/Types.hs | 41 +++- src/PostgREST/Query/QueryBuilder.hs | 77 +++--- src/PostgREST/Query/SqlFragment.hs | 83 +++++-- .../Feature/Query/AggregateFunctionsSpec.hs | 70 ++++++ test/spec/Main.hs | 4 +- 10 files changed, 464 insertions(+), 98 deletions(-) create mode 100644 test/spec/Feature/Query/AggregateFunctionsSpec.hs diff --git a/postgrest.cabal b/postgrest.cabal index d2563c9ad0..6831211466 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -202,6 +202,7 @@ test-suite spec Feature.OpenApi.RootSpec Feature.OpenApi.SecurityOpenApiSpec Feature.OptionsSpec + Feature.Query.AggregateFunctionsSpec Feature.Query.AndOrParamsSpec Feature.Query.ComputedRelsSpec Feature.Query.DeleteSpec diff --git a/src/PostgREST/ApiRequest/QueryParams.hs b/src/PostgREST/ApiRequest/QueryParams.hs index d6515068f8..2b0829401a 100644 --- a/src/PostgREST/ApiRequest/QueryParams.hs +++ b/src/PostgREST/ApiRequest/QueryParams.hs @@ -31,8 +31,8 @@ import Data.Tree (Tree (..)) import Text.Parsec.Error (errorMessages, showErrorMessages) import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser, - anyChar, between, char, digit, - eof, errorPos, letter, + anyChar, between, char, choice, + digit, eof, errorPos, letter, lookAhead, many1, noneOf, notFollowedBy, oneOf, optionMaybe, sepBy, sepBy1, @@ -43,7 +43,8 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeOffset, restrictRange) import PostgREST.SchemaCache.Identifiers (FieldName) -import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field, +import PostgREST.ApiRequest.Types (AggregateFunction (..), + EmbedParam (..), EmbedPath, Field, Filter (..), FtsOperator (..), Hint, JoinType (..), JsonOperand (..), @@ -58,7 +59,7 @@ import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field, SimpleOperator (..), SingleVal, TrileanVal (..)) -import Protolude hiding (try) +import Protolude hiding (Sum, try) data QueryParams = QueryParams @@ -452,10 +453,12 @@ pRelationSelect :: Parser SelectItem pRelationSelect = lexeme $ do alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) name <- pFieldName + guard (name /= "count") (hint, jType) <- pEmbedParams try (void $ lookAhead (string "(")) return $ SelectRelation name alias hint jType + -- | -- Parse regular fields in select -- @@ -495,18 +498,36 @@ pFieldSelect :: Parser SelectItem pFieldSelect = lexeme $ try (do s <- pStar pEnd - return $ SelectField (s, []) Nothing Nothing) + return $ SelectField (s, []) Nothing Nothing Nothing Nothing) + <|> try (do + alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) + _ <- string "count()" + aggCast' <- optionMaybe (string "::" *> pIdentifier) + pEnd + return $ SelectField ("*", []) (Just Count) (toS <$> aggCast') Nothing alias) <|> do - alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) - fld <- pField - cast' <- optionMaybe (string "::" *> pIdentifier) + alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) + fld <- pField + cast' <- optionMaybe (string "::" *> pIdentifier) + agg <- optionMaybe (try (char '.' *> pAggregation <* string "()")) + aggCast' <- optionMaybe (string "::" *> pIdentifier) pEnd - return $ SelectField fld (toS <$> cast') alias + return $ SelectField fld agg (toS <$> aggCast') (toS <$> cast') alias where pEnd = try (void $ lookAhead (string ")")) <|> try (void $ lookAhead (string ",")) <|> try eof pStar = string "*" $> "*" + pAggregation = choice + [ string "sum" $> Sum + , string "avg" $> Avg + , string "count" $> Count + -- Using 'try' for "min" and "max" to allow backtracking. + -- This is necessary because both start with the same character 'm', + -- and without 'try', a partial match on "max" would prevent "min" from being tried. + , try (string "max") $> Max + , try (string "min") $> Min + ] -- | diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index e09d57db8d..e35bd89db0 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} module PostgREST.ApiRequest.Types - ( Alias + ( AggregateFunction(..) + , Alias , Cast , Depth , EmbedParam(..) @@ -42,12 +43,14 @@ import PostgREST.SchemaCache.Routine (Routine (..)) import Protolude --- | The value in `/tbl?select=alias:field::cast` +-- | The value in `/tbl?select=alias:field.aggregateFunction()::cast` data SelectItem = SelectField - { selField :: Field - , selCast :: Maybe Cast - , selAlias :: Maybe Alias + { selField :: Field + , selAggregateFunction :: Maybe AggregateFunction + , selAggregateCast :: Maybe Cast + , selCast :: Maybe Cast + , selAlias :: Maybe Alias } -- | The value in `/tbl?select=alias:another_tbl(*)` | SelectRelation @@ -135,6 +138,9 @@ type Cast = Text type Alias = Text type Hint = Text +data AggregateFunction = Sum | Avg | Max | Min | Count + deriving (Show, Eq) + data EmbedParam -- | Disambiguates an embedding operation when there's multiple relationships -- between two tables. Can be the name of a foreign key constraint, column diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index a30114be05..f0fe1e5e60 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -34,7 +34,7 @@ import qualified Data.Set as S import qualified PostgREST.SchemaCache.Routine as Routine import Data.Either.Combinators (mapLeft, mapRight) -import Data.List (delete) +import Data.List (delete, lookup) import Data.Tree (Tree (..)) import PostgREST.ApiRequest (Action (..), @@ -300,11 +300,13 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbTab in mapLeft ApiRequestError $ treeRestrictRange configDbMaxRows (iAction apiRequest) =<< + hoistSpreadAggFunctions =<< + addRelSelects =<< addNullEmbedFilters =<< validateSpreadEmbeds =<< addRelatedOrders =<< - addDataRepresentationAliases =<< - expandStarsForDataRepresentations ctx =<< + addAliases =<< + expandStars ctx =<< addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<< addLogicTrees ctx apiRequest =<< addRanges apiRequest =<< @@ -317,7 +319,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 False [] rootDepth treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry depth (Node si fldForest) (Node q rForest) = let nxtDepth = succ depth in @@ -333,49 +335,86 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} []) fldForest:rForest SelectField{..} -> - Node q{select=(resolveOutputField ctx{qi=from q} selField, selCast, selAlias):select q} rForest + Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest --- | Preserve the original field name if data representation is used to coerce the value. -addDataRepresentationAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree -addDataRepresentationAliases rPlanTree = Right $ fmap (\rPlan@ReadPlan{select=sel} -> rPlan{select=map aliasSelectItem sel}) rPlanTree +-- 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. +addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree +addAliases = Right . fmap addAliasToPlan where - aliasSelectItem :: (CoercibleField, Maybe Cast, Maybe Alias) -> (CoercibleField, Maybe Cast, Maybe Alias) - -- If there already is an alias, don't overwrite it. - aliasSelectItem (fld@(CoercibleField{cfName=fieldName, cfTransform=(Just _)}), Nothing, Nothing) = (fld, Nothing, Just fieldName) - aliasSelectItem fld = fld + addAliasToPlan rp@ReadPlan{select=sel} = rp{select=map aliasSelectField sel} + + aliasSelectField :: CoercibleSelectField -> CoercibleSelectField + aliasSelectField field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias} + | isJust alias || isJust aggFun = field + | isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key } + | isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) } + | otherwise = field + + isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True + isJsonKeyPath _ = False + + isTransformPath CoercibleField{cfTransform=(Just _), cfName=_} = True + isTransformPath _ = False + + lastJsonKey CoercibleField{cfName=fieldName, cfJsonPath=jsonPath} = + case jOp <$> lastMay jsonPath of + Just (JKey key) -> Just key + Just (JIdx _) -> Just $ fromMaybe fieldName lastKey + -- We get the lastKey because on: + -- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ] + -- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ] + where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jsonPath) + Nothing -> Nothing knownColumnsInContext :: ResolverContext -> [Column] knownColumnsInContext ResolverContext{..} = fromMaybe [] $ HM.lookup qi tables >>= Just . tableColumnsList --- | Expand "select *" into explicit field names of the table, if necessary to apply data representations. -expandStarsForDataRepresentations :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree -expandStarsForDataRepresentations ctx@ResolverContext{qi} rPlanTree = Right $ fmap expandStars rPlanTree +-- | 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. +expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree +expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree where - expandStars :: ReadPlan -> ReadPlan + expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree + expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias} children) = + let + newHasAgg = hasAgg || any (isJust . csAggFunction) select + newCtx = adjustContext ctx fromQI alias + newRPlan = expandStarsForTable newCtx newHasAgg rp + in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children) + + -- Choose the appropriate context based on whether we're dealing with "pgrst_source" + adjustContext :: ResolverContext -> QualifiedIdentifier -> Maybe Text -> ResolverContext -- When the schema is "" and the table is the source CTE, we assume the true source table is given in the from -- alias and belongs to the request schema. See the bit in `addRels` with `newFrom = ...`. - expandStars rPlan@ReadPlan{from=(QualifiedIdentifier "" "pgrst_source"), fromAlias=(Just tblAlias)} = - expandStarsForTable ctx{qi=qi{qiName=tblAlias}} rPlan - expandStars rPlan@ReadPlan{from=fromTable} = - expandStarsForTable ctx{qi=fromTable} rPlan - -expandStarsForTable :: ResolverContext -> ReadPlan -> ReadPlan -expandStarsForTable ctx@ResolverContext{representations, outputType} rplan@ReadPlan{select=selectItems} = - -- If we have a '*' select AND the target table has at least one data representation, expand. - if ("*" `elem` map (\(field, _, _) -> cfName field) selectItems) && any hasOutputRep knownColumns - then rplan{select=concatMap (expandStarSelectItem knownColumns) selectItems} - else rplan + adjustContext context@ResolverContext{qi=ctxQI} (QualifiedIdentifier "" "pgrst_source") (Just a) = context{qi=ctxQI{qiName=a}} + adjustContext context fromQI _ = context{qi=fromQI} + +expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan +expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields} + -- 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 any '*' selects that have an aggregate function attached (i.e for COUNT(*)). + | hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField knownColumns) selectFields} + | otherwise = rp where + hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields + filteredSelectFields = filter (isNothing . csAggFunction) selectFields + hasDataRepresentation = any hasOutputRep knownColumns knownColumns = knownColumnsInContext ctx hasOutputRep :: Column -> Bool hasOutputRep col = HM.member (colNominalType col, outputType) representations - expandStarSelectItem :: [Column] -> (CoercibleField, Maybe Cast, Maybe Alias) -> [(CoercibleField, Maybe Cast, Maybe Alias)] - expandStarSelectItem columns (CoercibleField{cfName="*", cfJsonPath=[]}, b, c) = map (\col -> (withOutputFormat ctx $ resolveColumnField col, b, c)) columns - expandStarSelectItem _ selectItem = [selectItem] + expandStarSelectField :: [Column] -> CoercibleSelectField -> [CoercibleSelectField] + expandStarSelectField columns sel@CoercibleSelectField{csField=CoercibleField{cfName="*", cfJsonPath=[]}, csAggFunction=Nothing} = + map (\col -> sel { csField = withOutputFormat ctx $ resolveColumnField col }) columns + expandStarSelectField _ selectField = [selectField] -- | Enforces the `max-rows` config on the result treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree @@ -532,6 +571,117 @@ findRel schema allRels origin target hint = ) ) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels + +addRelSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree +addRelSelects node@(Node rp forest) + | null forest = Right node + | otherwise = + let newForest = rights $ addRelSelects <$> forest + newRelSelects = mapMaybe generateRelSelectField newForest + in Right $ Node rp { relSelect = newRelSelects } newForest + +generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField +generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsSpread = True} _) = + Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias } +generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relIsSpread = False} forest) = + Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed } + where + rsSelName = fromMaybe relName relAlias + rsEmbedMode = if relIsToOne rel then JsonObject else JsonArray + rsEmptyEmbed = null select && null forest +generateRelSelectField _ = Nothing + +generateSpreadSelectFields :: ReadPlan -> [SpreadSelectField] +generateSpreadSelectFields ReadPlan{select, relSelect} = + -- We combine the select and relSelect fields into a single list of SpreadSelectField. + selectSpread ++ relSelectSpread + where + selectSpread = map selectToSpread select + selectToSpread :: CoercibleSelectField -> SpreadSelectField + selectToSpread CoercibleSelectField{csField = CoercibleField{cfName}, csAlias} = + SpreadSelectField { ssSelName = fromMaybe cfName csAlias, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing } + + relSelectSpread = concatMap relSelectToSpread relSelect + relSelectToSpread :: RelSelectField -> [SpreadSelectField] + relSelectToSpread (JsonEmbed{rsSelName}) = + [SpreadSelectField { ssSelName = rsSelName, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing }] + relSelectToSpread (Spread{rsSpreadSel}) = + rsSpreadSel + +-- When aggregates are present in a ReadPlan that will be 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. + +-- 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. +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) = + let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children + allChildAggLists = concat childAggLists + (newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsSpread) + then (select rp, []) + else hoistFromSelectFields relAggAlias (select rp) + + newRelSelects = if null children + then relSelect rp + else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp + in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, aggList) + +-- Hoist aggregate functions from the select list of a ReadPlan, and return the +-- updated select list and the list of hoisted aggregates. +hoistFromSelectFields :: Alias -> [CoercibleSelectField] -> ([CoercibleSelectField], [HoistedAgg]) +hoistFromSelectFields aggAlias fields = + let (newFields, maybeAggs) = foldr processField ([], []) fields + in (newFields, catMaybes maybeAggs) + where + processField field (newFields, aggList) = + let (modifiedField, maybeAgg) = modifyField field + in (modifiedField : newFields, maybeAgg : aggList) + + modifyField field = + case csAggFunction field of + Just aggFunc -> + ( field { csAggFunction = Nothing, csAggCast = Nothing }, + Just ((aggAlias, determineFieldName field), (aggFunc, csAggCast field, csAlias field))) + Nothing -> (field, Nothing) + + determineFieldName field = fromMaybe (cfName $ csField field) (csAlias field) + +-- 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 = aggAlias}) = + r { rsSpreadSel = map updateSelect spreadSelects } + where + updateSelect s = + case lookup (aggAlias, ssSelName s) aggList of + Just (aggFunc, aggCast, fldAlias) -> + s { ssSelAggFunction = Just aggFunc, + ssSelAggCast = aggCast, + ssSelAlias = fldAlias } + Nothing -> s +hoistIntoRelSelectFields _ r = r + addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree addFilters ctx ApiRequest{..} rReq = foldr addFilterToNode (Right rReq) flts @@ -786,7 +936,7 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols | "*" `elem` fldNames = ["*"] | otherwise = returnings where - fldNames = cfName . (\(f, _, _) -> f) <$> select + fldNames = cfName . csField <$> select -- Without fkCols, when a mutatePlan to -- /projects?select=name,clients(name) occurs, the RETURNING SQL part would -- be `RETURNING name`(see QueryBuilder). This would make the embedding @@ -855,10 +1005,9 @@ binaryField AppConfig{configRawMediaTypes} acceptMediaType proc rpTree _ -> False fstFieldName :: ReadPlanTree -> Maybe FieldName - fstFieldName (Node ReadPlan{select=(CoercibleField{cfName="*", cfJsonPath=[]}, _, _):_} []) = Nothing - fstFieldName (Node ReadPlan{select=[(CoercibleField{cfName=fld, cfJsonPath=[]}, _, _)]} []) = Just fld - fstFieldName _ = Nothing - + fstFieldName (Node ReadPlan{select=[CoercibleSelectField{csField=CoercibleField{cfName="*", cfJsonPath=[]}}]} []) = Nothing + fstFieldName (Node ReadPlan{select=[CoercibleSelectField{csField=CoercibleField{cfName=fld, cfJsonPath=[]}}]} []) = Just fld + fstFieldName _ = Nothing mediaToAggregate :: MediaType -> Maybe FieldName -> ApiRequest -> ResultAggregate mediaToAggregate mt binField apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} = diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index f0de4430a4..854cf1ffa7 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -6,11 +6,12 @@ module PostgREST.Plan.ReadPlan import Data.Tree (Tree (..)) -import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Hint, +import PostgREST.ApiRequest.Types (Alias, Depth, Hint, JoinType, NodeName) -import PostgREST.Plan.Types (CoercibleField (..), - CoercibleLogicTree, - CoercibleOrderTerm) +import PostgREST.Plan.Types (CoercibleLogicTree, + CoercibleOrderTerm, + CoercibleSelectField (..), + RelSelectField (..)) import PostgREST.RangeQuery (NonnegRange) import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier) @@ -28,7 +29,7 @@ data JoinCondition = deriving (Eq, Show) data ReadPlan = ReadPlan - { select :: [(CoercibleField, Maybe Cast, Maybe Alias)] + { select :: [CoercibleSelectField] , from :: QualifiedIdentifier , fromAlias :: Maybe Alias , where_ :: [CoercibleLogicTree] @@ -42,6 +43,7 @@ data ReadPlan = ReadPlan , relHint :: Maybe Hint , relJoinType :: Maybe JoinType , relIsSpread :: Bool + , relSelect :: [RelSelectField] , depth :: Depth -- ^ used for aliasing } diff --git a/src/PostgREST/Plan/Types.hs b/src/PostgREST/Plan/Types.hs index c9267e3d90..97de469952 100644 --- a/src/PostgREST/Plan/Types.hs +++ b/src/PostgREST/Plan/Types.hs @@ -1,13 +1,18 @@ module PostgREST.Plan.Types ( CoercibleField(..) + , CoercibleSelectField(..) , unknownField , CoercibleLogicTree(..) , CoercibleFilter(..) , TransformerProc , CoercibleOrderTerm(..) + , RelSelectField(..) + , RelJsonEmbedMode(..) + , SpreadSelectField(..) ) where -import PostgREST.ApiRequest.Types (Field, JsonPath, LogicOperator, +import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast, + Field, JsonPath, LogicOperator, OpExpr, OrderDirection, OrderNulls) import PostgREST.SchemaCache.Identifiers (FieldName) @@ -65,3 +70,37 @@ data CoercibleOrderTerm , coNullOrder :: Maybe OrderNulls } deriving (Eq, Show) + +data CoercibleSelectField = CoercibleSelectField + { csField :: CoercibleField + , csAggFunction :: Maybe AggregateFunction + , csAggCast :: Maybe Cast + , csCast :: Maybe Cast + , csAlias :: Maybe Alias + } + deriving (Eq, Show) + +data RelJsonEmbedMode = JsonObject | JsonArray + deriving (Show, Eq) + +data RelSelectField + = JsonEmbed + { rsSelName :: FieldName + , rsAggAlias :: Alias + , rsEmbedMode :: RelJsonEmbedMode + , rsEmptyEmbed :: Bool + } + | Spread + { rsSpreadSel :: [SpreadSelectField] + , rsAggAlias :: Alias + } + deriving (Eq, Show) + +data SpreadSelectField = + SpreadSelectField + { ssSelName :: FieldName + , ssSelAggFunction :: Maybe AggregateFunction + , ssSelAggCast :: Maybe Cast + , ssSelAlias :: Maybe Alias + } + deriving (Eq, Show) diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 06e4ad1f6b..f0dd10138c 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -19,7 +19,8 @@ module PostgREST.Query.QueryBuilder import qualified Data.ByteString.Char8 as BS import qualified Hasql.DynamicStatements.Snippet as SQL -import Data.Tree (Tree (..)) +import Data.Maybe (fromJust) +import Data.Tree (Tree (..)) import PostgREST.ApiRequest.Preferences (PreferResolution (..)) import PostgREST.Config.PgVersion (PgVersion, pgVersion110, @@ -27,8 +28,7 @@ import PostgREST.Config.PgVersion (PgVersion, pgVersion110, import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..), - Relationship (..), - relIsToOne) + Relationship (..)) import PostgREST.SchemaCache.Routine (RoutineParam (..)) import PostgREST.ApiRequest.Types @@ -42,45 +42,70 @@ import PostgREST.RangeQuery (allRange) import Protolude readPlanToQuery :: ReadPlanTree -> SQL.Snippet -readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds} forest) = +readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) = "SELECT " <> - intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ selects) <> " " <> + intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ 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 <> " " <> orderF qi order <> " " <> limitOffsetF readRange where fromFrag = fromF relToParent mainQi fromAlias qi = getQualifiedIdentifier relToParent mainQi fromAlias - defSelect = [(unknownField "*" [], Nothing, Nothing)] -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage - (selects, joins) = foldr getSelectsJoins ([],[]) forest + -- 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 + joinsSelects = getJoinSelects node -getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) -getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], []) -getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType, relIsSpread} forest) (selects,joins) = +getJoinSelects :: ReadPlanTree -> [SQL.Snippet] +getJoinSelects (Node ReadPlan{relSelect} _) = + mapMaybe relSelectToSnippet relSelect + where + relSelectToSnippet :: RelSelectField -> Maybe SQL.Snippet + relSelectToSnippet fld = + let aggAlias = pgFmtIdent $ rsAggAlias fld + in + case fld of + JsonEmbed{rsEmptyEmbed = True} -> + Nothing + JsonEmbed{rsSelName, rsEmbedMode = JsonObject} -> + Just $ "row_to_json(" <> aggAlias <> ".*)::jsonb AS " <> pgFmtIdent rsSelName + JsonEmbed{rsSelName, rsEmbedMode = JsonArray} -> + Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName + Spread{rsSpreadSel, rsAggAlias} -> + Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel) + +getJoins :: ReadPlanTree -> [SQL.Snippet] +getJoins (Node _ []) = [] +getJoins (Node ReadPlan{relSelect} forest) = + map (\fld -> + let alias = rsAggAlias fld + matchingNode = fromJust $ find (\(Node ReadPlan{relAggAlias} _) -> alias == relAggAlias) forest + in getJoin fld matchingNode + ) relSelect + +getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet +getJoin fld node@(Node ReadPlan{relJoinType} _) = let - subquery = readPlanToQuery rr - aliasOrName = pgFmtIdent $ fromMaybe relName relAlias - aggAlias = pgFmtIdent relAggAlias correlatedSubquery sub al cond = (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond - (sel, joi) = if relIsToOne rel - then - ( if relIsSpread - then aggAlias <> ".*" - else "row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName - , correlatedSubquery subquery aggAlias "TRUE") - else - ( "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName - , correlatedSubquery ( - "SELECT json_agg(" <> aggAlias <> ") AS " <> aggAlias <> - "FROM (" <> subquery <> " ) AS " <> aggAlias - ) aggAlias $ if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE") + subquery = readPlanToQuery node + aggAlias = pgFmtIdent $ rsAggAlias fld in - (if null select && null forest then selects else sel:selects, joi:joins) + case fld of + JsonEmbed{rsEmbedMode = JsonObject} -> + correlatedSubquery subquery aggAlias "TRUE" + Spread{} -> + 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 mutatePlanToQuery :: MutatePlan -> SQL.Snippet mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) = diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index f2d21efce6..3965596b59 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -9,6 +9,7 @@ module PostgREST.Query.SqlFragment ( noLocationF , aggF , countF + , groupF , fromQi , limitOffsetF , locationF @@ -21,6 +22,7 @@ module PostgREST.Query.SqlFragment , pgFmtLogicTree , pgFmtOrderTerm , pgFmtSelectItem + , pgFmtSpreadSelectItem , fromJsonBodyF , responseHeadersF , responseStatusF @@ -51,7 +53,8 @@ import Control.Arrow ((***)) import Data.Foldable (foldr1) import Text.InterpolatedString.Perl6 (qc) -import PostgREST.ApiRequest.Types (Alias, Cast, +import PostgREST.ApiRequest.Types (AggregateFunction (..), + Alias, Cast, FtsOperator (..), JsonOperand (..), JsonOperation (..), @@ -72,6 +75,9 @@ import PostgREST.Plan.Types (CoercibleField (..), CoercibleFilter (..), CoercibleLogicTree (..), CoercibleOrderTerm (..), + CoercibleSelectField (..), + RelSelectField (..), + SpreadSelectField (..), unknownField) import PostgREST.RangeQuery (NonnegRange, allRange, rangeLimit, rangeOffset) @@ -83,7 +89,7 @@ import PostgREST.SchemaCache.Routine (ResultAggregate (..), funcReturnsSetOfScalar, funcReturnsSingleComposite) -import Protolude hiding (cast) +import Protolude hiding (Sum, cast) sourceCTEName :: Text sourceCTEName = "pgrst_source" @@ -261,12 +267,34 @@ pgFmtCoerceNamed :: CoercibleField -> SQL.Snippet pgFmtCoerceNamed CoercibleField{cfName=fn, cfTransform=(Just formatterProc)} = pgFmtCallUnary formatterProc (pgFmtIdent fn) <> " AS " <> pgFmtIdent fn pgFmtCoerceNamed CoercibleField{cfName=fn} = pgFmtIdent fn -pgFmtSelectItem :: QualifiedIdentifier -> (CoercibleField, Maybe Cast, Maybe Alias) -> SQL.Snippet -pgFmtSelectItem table (fld, Nothing, alias) = pgFmtTableCoerce table fld <> pgFmtAs (cfName fld) (cfJsonPath fld) alias +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 + where + fullSelName = case ssSelName of + "*" -> pgFmtIdent aggAlias <> ".*" + _ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent ssSelName + +pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet +pgFmtApplyAggregate Nothing _ snippet = snippet +pgFmtApplyAggregate (Just agg) aggCast snippet = + pgFmtApplyCast aggCast aggregatedSnippet + where + convertAggFunction :: AggregateFunction -> SQL.Snippet + -- Convert from e.g. Sum (the data type) to SUM + convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show + aggregatedSnippet = convertAggFunction agg <> "(" <> 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. -- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting. -- Not quoting should be fine, we validate the input on Parsers. -pgFmtSelectItem table (fld, Just cast, alias) = "CAST (" <> pgFmtTableCoerce table fld <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> pgFmtAs (cfName fld) (cfJsonPath fld) alias +pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" -- 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 @@ -398,17 +426,40 @@ pgFmtJsonPath = \case pgFmtJsonOperand (JKey k) = unknownLiteral k pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int" -pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SQL.Snippet -pgFmtAs _ [] Nothing = mempty -pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of - Just (JKey key) -> " AS " <> pgFmtIdent key - Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey) - -- We get the lastKey because on: - -- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ] - -- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ] - where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp) - Nothing -> mempty -pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias +pgFmtAs :: Maybe Alias -> SQL.Snippet +pgFmtAs Nothing = mempty +pgFmtAs (Just alias) = " AS " <> pgFmtIdent alias + +groupF :: QualifiedIdentifier -> [CoercibleSelectField] -> [RelSelectField] -> SQL.Snippet +groupF qi select relSelect + | (noSelectsAreAggregated && noRelSelectsAreAggregated) || null groupTerms = mempty + | 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 + groupTermsFromSelect = mapMaybe (pgFmtGroup qi) select + groupTermsFromRelSelect = mapMaybe groupTermFromRelSelectField relSelect + groupTerms = groupTermsFromSelect ++ groupTermsFromRelSelect + +groupTermFromRelSelectField :: RelSelectField -> Maybe SQL.Snippet +groupTermFromRelSelectField (JsonEmbed { rsSelName }) = + Just $ pgFmtIdent rsSelName +groupTermFromRelSelectField (Spread { rsSpreadSel, rsAggAlias }) = + if null groupTerms + then Nothing + else + Just $ intercalateSnippet ", " groupTerms + where + processField :: SpreadSelectField -> Maybe SQL.Snippet + processField SpreadSelectField{ssSelAggFunction = Just _} = Nothing + processField SpreadSelectField{ssSelName, ssSelAlias} = + Just $ pgFmtIdent rsAggAlias <> "." <> pgFmtIdent (fromMaybe ssSelName ssSelAlias) + groupTerms = mapMaybe processField rsSpreadSel + +pgFmtGroup :: QualifiedIdentifier -> CoercibleSelectField -> Maybe SQL.Snippet +pgFmtGroup _ CoercibleSelectField{csAggFunction=Just _} = Nothing +pgFmtGroup _ CoercibleSelectField{csAlias=Just alias, csAggFunction=Nothing} = Just $ pgFmtIdent alias +pgFmtGroup qi CoercibleSelectField{csField=fld, csAlias=Nothing, csAggFunction=Nothing} = Just $ pgFmtField qi fld countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet) countF countQuery shouldCount = diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs new file mode 100644 index 0000000000..a4395750ea --- /dev/null +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -0,0 +1,70 @@ +module Feature.Query.AggregateFunctionsSpec where + +import Network.Wai (Application) + +import Test.Hspec +import Test.Hspec.Wai +import Test.Hspec.Wai.JSON + +import PostgREST.Config.PgVersion (PgVersion) + +import Protolude hiding (get) +import SpecHelper + +spec :: PgVersion -> SpecWith ((), Application) +spec _ = + describe "aggregate functions" $ do + context "performing a count without specifying a field" $ do + it "returns the count of all rows when no other fields are selected" $ + get "/entities?select=count()" `shouldRespondWith` + [json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "allows you to specify an alias for the count" $ + get "/entities?select=cnt:count()" `shouldRespondWith` + [json|[{ "cnt": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "allows you to cast the result of the count" $ + get "/entities?select=count()::text" `shouldRespondWith` + [json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] } + it "returns the count grouped by all provided fields when other fields are selected" $ + get "/projects?select=c:count(),client_id" `shouldRespondWith` + [json|[{ "c": 1, "client_id": null }, { "c": 2, "client_id": 2 }, { "c": 2, "client_id": 1}]|] { matchHeaders = [matchContentTypeJson] } + context "performing an aggregation on one or more fields" $ do + it "supports sum()" $ + get "/car_model_sales?select=quantity.sum()" `shouldRespondWith` + [json|[{ "sum": 20 }]|] { matchHeaders = [matchContentTypeJson] } + it "supports avg()" $ + get "/car_model_sales?select=quantity.avg()" `shouldRespondWith` + [json|[{ "avg": 5.0000000000000000 }]|] { matchHeaders = [matchContentTypeJson] } + it "supports min()" $ + get "/car_model_sales?select=quantity.min()" `shouldRespondWith` + [json|[{ "min": 1 }]|] { matchHeaders = [matchContentTypeJson] } + it "supports max()" $ + get "/car_model_sales?select=quantity.max()" `shouldRespondWith` + [json|[{ "max": 9 }]|] { matchHeaders = [matchContentTypeJson] } + it "supports count()" $ + get "/car_model_sales?select=car_model_name.count()" `shouldRespondWith` + [json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "groups by any fields selected that do not have an aggregate applied" $ + get "/car_model_sales?select=quantity.sum(),quantity.max(),date.min(),car_model_name" `shouldRespondWith` + [json|[{ "sum": 4, "max": 3, "min": "2021-02-11", "car_model_name": "Murcielago"}, { "sum": 16, "max": 9, "min": "2021-01-14", "car_model_name": "DeLorean"}]|] { matchHeaders = [matchContentTypeJson] } + it "supports the use of aliases on fields that will be used in the group by" $ + get "/car_model_sales?select=quantity.sum(),quantity.max(),date.min(),cm:car_model_name" `shouldRespondWith` + [json|[{ "sum": 4, "max": 3, "min": "2021-02-11", "cm": "Murcielago"}, { "sum": 16, "max": 9, "min": "2021-01-14", "cm": "DeLorean"}]|] { matchHeaders = [matchContentTypeJson] } + it "allows you to specify an alias for the aggregate" $ + get "/car_model_sales?select=total_sold:quantity.sum(),car_model_name" `shouldRespondWith` + [json|[{ "total_sold": 4, "car_model_name": "Murcielago"}, { "total_sold": 16, "car_model_name": "DeLorean" }]|] { matchHeaders = [matchContentTypeJson] } + it "allows you to cast the result of the aggregate" $ + get "/car_model_sales?select=total_sold:quantity.sum()::text,car_model_name" `shouldRespondWith` + [json|[{ "total_sold": "4", "car_model_name": "Murcielago"}, { "total_sold": "16", "car_model_name": "DeLorean" }]|] { matchHeaders = [matchContentTypeJson] } + it "allows you to cast the input argument of the aggregate" $ + get "/trash_details?select=jsonb_col->>key::integer.sum()" `shouldRespondWith` + [json|[{"sum": 24}]|] { matchHeaders = [matchContentTypeJson] } + it "allows the combination of an alias, a before cast, and an after cast" $ + get "/trash_details?select=s:jsonb_col->>key::integer.sum()::text" `shouldRespondWith` + [json|[{"s": "24"}]|] { matchHeaders = [matchContentTypeJson] } + it "supports use of aggregates on RPC functions that return table values" $ + get "/rpc/getallprojects?select=id.max()" `shouldRespondWith` + [json|[{"max": 5}]|] { matchHeaders = [matchContentTypeJson] } + context "performing aggregations on spreaded fields from an embedded resource" $ do + xit "supports the use of aggregates on spreaded fields" $ + get "/car_models?select=car_brand_name,...car_model_sales(quantity.sum())" `shouldRespondWith` + [json|[{"car_brand_name": "dog"}]|] { matchHeaders = [matchContentTypeJson] } diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 9a4e5e98f0..a24762498b 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -35,6 +35,7 @@ import qualified Feature.OpenApi.ProxySpec import qualified Feature.OpenApi.RootSpec import qualified Feature.OpenApi.SecurityOpenApiSpec import qualified Feature.OptionsSpec +import qualified Feature.Query.AggregateFunctionsSpec import qualified Feature.Query.AndOrParamsSpec import qualified Feature.Query.ComputedRelsSpec import qualified Feature.Query.DeleteSpec @@ -126,7 +127,8 @@ main = do analyzeTable "child_entities" specs = uncurry describe <$> [ - ("Feature.Query.AndOrParamsSpec" , Feature.Query.AndOrParamsSpec.spec actualPgVersion) + ("Feature.Query.AggregateFunctionsSpec" , Feature.Query.AggregateFunctionsSpec.spec actualPgVersion) + , ("Feature.Query.AndOrParamsSpec" , Feature.Query.AndOrParamsSpec.spec actualPgVersion) , ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec actualPgVersion) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)