From ddb87f4c0fb2229eed78c0e15f2bf4035115de95 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Wed, 29 Oct 2025 11:53:09 +0100 Subject: [PATCH 1/7] Add orderBy, first and last parameters to groupedAggregates Closes #75 --- README.md | 28 +- __tests__/__snapshots__/schema.test.ts.snap | 402 ++++++++++++++++++ .../groupedAggregatesOrderBy.test.ts.snap | 56 +++ .../queries/groupedAggregatesOrderBy.test.ts | 31 ++ src/AddConnectionGroupedAggregatesPlugin.ts | 72 ++++ src/AddGroupedAggregatesOrderByPlugin.ts | 264 ++++++++++++ src/InflectionPlugin.ts | 13 + src/index.ts | 4 +- 8 files changed, 865 insertions(+), 5 deletions(-) create mode 100644 __tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap create mode 100644 __tests__/queries/groupedAggregatesOrderBy.test.ts create mode 100644 src/AddGroupedAggregatesOrderByPlugin.ts diff --git a/README.md b/README.md index 896564a..54c89fc 100644 --- a/README.md +++ b/README.md @@ -271,15 +271,30 @@ See [Defining your own grouping derivatives](#defining-your-own-grouping-derivatives) below for details on how to add your own grouping derivatives. +The `groupedAggregates` field accepts a few arguments in addition to `groupBy`: + +- `orderBy` – controls how groups are sorted. You can order by any aggregate that + appears in the grouped output (e.g. `SUM_POINTS_DESC`). +- `first` / `last` – slice the ordered groups, returning only the leading or + trailing `n` groups. + +Always pair `first` or `last` with an explicit `orderBy` so PostgreSQL can +deterministically rank the groups before trimming them. + The aggregates supported over groups are the same as over the connection as a whole (see [Aggregates](#aggregates) above), but in addition you may also -determine the `keys` that were used for the aggregate. There will be one key for -each of the `groupBy` values; for example in this query: +determine the `keys` that were used for the aggregate, and optionally limit the +groups returned. There will be one key for each of the `groupBy` values; for +example in this query: ```graphql -query AverageDurationByYearOfRelease { +query TopTwoYearsByAverageDuration { allFilms { - groupedAggregates(groupBy: [YEAR_OF_RELEASE]) { + groupedAggregates( + groupBy: [YEAR_OF_RELEASE] + orderBy: [AVERAGE_DURATION_IN_MINUTES_DESC] + first: 2 + ) { keys average { durationInMinutes @@ -307,6 +322,8 @@ query AverageGoalsOnDaysWithAveragePointsOver200 { byDay: groupedAggregates( groupBy: [CREATED_AT_TRUNCATED_TO_DAY] having: { average: { points: { greaterThan: 200 } } } + orderBy: [AVERAGE_GOALS_DESC] + last: 3 ) { keys average { @@ -317,6 +334,9 @@ query AverageGoalsOnDaysWithAveragePointsOver200 { } ``` +When using `last`, be sure to supply an `orderBy` so the database can produce a +deterministic ordering before the tail slice is applied. + ## Defining your own aggregates You can add your own aggregates by using a plugin to add your own aggregate diff --git a/__tests__/__snapshots__/schema.test.ts.snap b/__tests__/__snapshots__/schema.test.ts.snap index 2e57c9e..4a99953 100644 --- a/__tests__/__snapshots__/schema.test.ts.snap +++ b/__tests__/__snapshots__/schema.test.ts.snap @@ -303,6 +303,15 @@ type FilmConnection { """The method to use when grouping \`Film\` for these aggregates.""" groupBy: [FilmGroupBy!]! + """The ordering to apply to the grouped aggregates of \`Film\`.""" + orderBy: [FilmGroupedAggregatesOrderBy!] + + """Only include the first \`n\` grouped aggregates.""" + first: Int + + """Only include the last \`n\` grouped aggregates.""" + last: Int + """Conditions on the grouped aggregates.""" having: FilmHavingInput ): [FilmAggregates!] @@ -392,6 +401,84 @@ enum FilmGroupBy { DURATION_IN_MINUTES } +"""Ordering options when grouping \`Film\` aggregates.""" +enum FilmGroupedAggregatesOrderBy { + SUM_ROW_ID_ASC + SUM_ROW_ID_DESC + SUM_YEAR_OF_RELEASE_ASC + SUM_YEAR_OF_RELEASE_DESC + SUM_BOX_OFFICE_IN_BILLIONS_ASC + SUM_BOX_OFFICE_IN_BILLIONS_DESC + SUM_DURATION_IN_MINUTES_ASC + SUM_DURATION_IN_MINUTES_DESC + DISTINCT_COUNT_ROW_ID_ASC + DISTINCT_COUNT_ROW_ID_DESC + DISTINCT_COUNT_NAME_ASC + DISTINCT_COUNT_NAME_DESC + DISTINCT_COUNT_YEAR_OF_RELEASE_ASC + DISTINCT_COUNT_YEAR_OF_RELEASE_DESC + DISTINCT_COUNT_BOX_OFFICE_IN_BILLIONS_ASC + DISTINCT_COUNT_BOX_OFFICE_IN_BILLIONS_DESC + DISTINCT_COUNT_DURATION_IN_MINUTES_ASC + DISTINCT_COUNT_DURATION_IN_MINUTES_DESC + MIN_ROW_ID_ASC + MIN_ROW_ID_DESC + MIN_YEAR_OF_RELEASE_ASC + MIN_YEAR_OF_RELEASE_DESC + MIN_BOX_OFFICE_IN_BILLIONS_ASC + MIN_BOX_OFFICE_IN_BILLIONS_DESC + MIN_DURATION_IN_MINUTES_ASC + MIN_DURATION_IN_MINUTES_DESC + MAX_ROW_ID_ASC + MAX_ROW_ID_DESC + MAX_YEAR_OF_RELEASE_ASC + MAX_YEAR_OF_RELEASE_DESC + MAX_BOX_OFFICE_IN_BILLIONS_ASC + MAX_BOX_OFFICE_IN_BILLIONS_DESC + MAX_DURATION_IN_MINUTES_ASC + MAX_DURATION_IN_MINUTES_DESC + AVERAGE_ROW_ID_ASC + AVERAGE_ROW_ID_DESC + AVERAGE_YEAR_OF_RELEASE_ASC + AVERAGE_YEAR_OF_RELEASE_DESC + AVERAGE_BOX_OFFICE_IN_BILLIONS_ASC + AVERAGE_BOX_OFFICE_IN_BILLIONS_DESC + AVERAGE_DURATION_IN_MINUTES_ASC + AVERAGE_DURATION_IN_MINUTES_DESC + STDDEV_SAMPLE_ROW_ID_ASC + STDDEV_SAMPLE_ROW_ID_DESC + STDDEV_SAMPLE_YEAR_OF_RELEASE_ASC + STDDEV_SAMPLE_YEAR_OF_RELEASE_DESC + STDDEV_SAMPLE_BOX_OFFICE_IN_BILLIONS_ASC + STDDEV_SAMPLE_BOX_OFFICE_IN_BILLIONS_DESC + STDDEV_SAMPLE_DURATION_IN_MINUTES_ASC + STDDEV_SAMPLE_DURATION_IN_MINUTES_DESC + STDDEV_POPULATION_ROW_ID_ASC + STDDEV_POPULATION_ROW_ID_DESC + STDDEV_POPULATION_YEAR_OF_RELEASE_ASC + STDDEV_POPULATION_YEAR_OF_RELEASE_DESC + STDDEV_POPULATION_BOX_OFFICE_IN_BILLIONS_ASC + STDDEV_POPULATION_BOX_OFFICE_IN_BILLIONS_DESC + STDDEV_POPULATION_DURATION_IN_MINUTES_ASC + STDDEV_POPULATION_DURATION_IN_MINUTES_DESC + VARIANCE_SAMPLE_ROW_ID_ASC + VARIANCE_SAMPLE_ROW_ID_DESC + VARIANCE_SAMPLE_YEAR_OF_RELEASE_ASC + VARIANCE_SAMPLE_YEAR_OF_RELEASE_DESC + VARIANCE_SAMPLE_BOX_OFFICE_IN_BILLIONS_ASC + VARIANCE_SAMPLE_BOX_OFFICE_IN_BILLIONS_DESC + VARIANCE_SAMPLE_DURATION_IN_MINUTES_ASC + VARIANCE_SAMPLE_DURATION_IN_MINUTES_DESC + VARIANCE_POPULATION_ROW_ID_ASC + VARIANCE_POPULATION_ROW_ID_DESC + VARIANCE_POPULATION_YEAR_OF_RELEASE_ASC + VARIANCE_POPULATION_YEAR_OF_RELEASE_DESC + VARIANCE_POPULATION_BOX_OFFICE_IN_BILLIONS_ASC + VARIANCE_POPULATION_BOX_OFFICE_IN_BILLIONS_DESC + VARIANCE_POPULATION_DURATION_IN_MINUTES_ASC + VARIANCE_POPULATION_DURATION_IN_MINUTES_DESC +} + input FilmHavingAverageFilmsComputedColumnWithArgumentsArgsInput { numberToAdd: Int! } @@ -1616,6 +1703,15 @@ type MatchStatConnection { """The method to use when grouping \`MatchStat\` for these aggregates.""" groupBy: [MatchStatGroupBy!]! + """The ordering to apply to the grouped aggregates of \`MatchStat\`.""" + orderBy: [MatchStatGroupedAggregatesOrderBy!] + + """Only include the first \`n\` grouped aggregates.""" + first: Int + + """Only include the last \`n\` grouped aggregates.""" + last: Int + """Conditions on the grouped aggregates.""" having: MatchStatHavingInput ): [MatchStatAggregates!] @@ -1731,6 +1827,138 @@ enum MatchStatGroupBy { CREATED_AT_TRUNCATED_TO_DAY } +"""Ordering options when grouping \`MatchStat\` aggregates.""" +enum MatchStatGroupedAggregatesOrderBy { + SUM_ROW_ID_ASC + SUM_ROW_ID_DESC + SUM_MATCH_ID_ASC + SUM_MATCH_ID_DESC + SUM_PLAYER_ID_ASC + SUM_PLAYER_ID_DESC + SUM_TEAM_POSITION_ASC + SUM_TEAM_POSITION_DESC + SUM_POINTS_ASC + SUM_POINTS_DESC + SUM_GOALS_ASC + SUM_GOALS_DESC + SUM_SAVES_ASC + SUM_SAVES_DESC + DISTINCT_COUNT_ROW_ID_ASC + DISTINCT_COUNT_ROW_ID_DESC + DISTINCT_COUNT_MATCH_ID_ASC + DISTINCT_COUNT_MATCH_ID_DESC + DISTINCT_COUNT_PLAYER_ID_ASC + DISTINCT_COUNT_PLAYER_ID_DESC + DISTINCT_COUNT_TEAM_POSITION_ASC + DISTINCT_COUNT_TEAM_POSITION_DESC + DISTINCT_COUNT_POINTS_ASC + DISTINCT_COUNT_POINTS_DESC + DISTINCT_COUNT_GOALS_ASC + DISTINCT_COUNT_GOALS_DESC + DISTINCT_COUNT_SAVES_ASC + DISTINCT_COUNT_SAVES_DESC + DISTINCT_COUNT_CREATED_AT_ASC + DISTINCT_COUNT_CREATED_AT_DESC + MIN_ROW_ID_ASC + MIN_ROW_ID_DESC + MIN_MATCH_ID_ASC + MIN_MATCH_ID_DESC + MIN_PLAYER_ID_ASC + MIN_PLAYER_ID_DESC + MIN_TEAM_POSITION_ASC + MIN_TEAM_POSITION_DESC + MIN_POINTS_ASC + MIN_POINTS_DESC + MIN_GOALS_ASC + MIN_GOALS_DESC + MIN_SAVES_ASC + MIN_SAVES_DESC + MAX_ROW_ID_ASC + MAX_ROW_ID_DESC + MAX_MATCH_ID_ASC + MAX_MATCH_ID_DESC + MAX_PLAYER_ID_ASC + MAX_PLAYER_ID_DESC + MAX_TEAM_POSITION_ASC + MAX_TEAM_POSITION_DESC + MAX_POINTS_ASC + MAX_POINTS_DESC + MAX_GOALS_ASC + MAX_GOALS_DESC + MAX_SAVES_ASC + MAX_SAVES_DESC + AVERAGE_ROW_ID_ASC + AVERAGE_ROW_ID_DESC + AVERAGE_MATCH_ID_ASC + AVERAGE_MATCH_ID_DESC + AVERAGE_PLAYER_ID_ASC + AVERAGE_PLAYER_ID_DESC + AVERAGE_TEAM_POSITION_ASC + AVERAGE_TEAM_POSITION_DESC + AVERAGE_POINTS_ASC + AVERAGE_POINTS_DESC + AVERAGE_GOALS_ASC + AVERAGE_GOALS_DESC + AVERAGE_SAVES_ASC + AVERAGE_SAVES_DESC + STDDEV_SAMPLE_ROW_ID_ASC + STDDEV_SAMPLE_ROW_ID_DESC + STDDEV_SAMPLE_MATCH_ID_ASC + STDDEV_SAMPLE_MATCH_ID_DESC + STDDEV_SAMPLE_PLAYER_ID_ASC + STDDEV_SAMPLE_PLAYER_ID_DESC + STDDEV_SAMPLE_TEAM_POSITION_ASC + STDDEV_SAMPLE_TEAM_POSITION_DESC + STDDEV_SAMPLE_POINTS_ASC + STDDEV_SAMPLE_POINTS_DESC + STDDEV_SAMPLE_GOALS_ASC + STDDEV_SAMPLE_GOALS_DESC + STDDEV_SAMPLE_SAVES_ASC + STDDEV_SAMPLE_SAVES_DESC + STDDEV_POPULATION_ROW_ID_ASC + STDDEV_POPULATION_ROW_ID_DESC + STDDEV_POPULATION_MATCH_ID_ASC + STDDEV_POPULATION_MATCH_ID_DESC + STDDEV_POPULATION_PLAYER_ID_ASC + STDDEV_POPULATION_PLAYER_ID_DESC + STDDEV_POPULATION_TEAM_POSITION_ASC + STDDEV_POPULATION_TEAM_POSITION_DESC + STDDEV_POPULATION_POINTS_ASC + STDDEV_POPULATION_POINTS_DESC + STDDEV_POPULATION_GOALS_ASC + STDDEV_POPULATION_GOALS_DESC + STDDEV_POPULATION_SAVES_ASC + STDDEV_POPULATION_SAVES_DESC + VARIANCE_SAMPLE_ROW_ID_ASC + VARIANCE_SAMPLE_ROW_ID_DESC + VARIANCE_SAMPLE_MATCH_ID_ASC + VARIANCE_SAMPLE_MATCH_ID_DESC + VARIANCE_SAMPLE_PLAYER_ID_ASC + VARIANCE_SAMPLE_PLAYER_ID_DESC + VARIANCE_SAMPLE_TEAM_POSITION_ASC + VARIANCE_SAMPLE_TEAM_POSITION_DESC + VARIANCE_SAMPLE_POINTS_ASC + VARIANCE_SAMPLE_POINTS_DESC + VARIANCE_SAMPLE_GOALS_ASC + VARIANCE_SAMPLE_GOALS_DESC + VARIANCE_SAMPLE_SAVES_ASC + VARIANCE_SAMPLE_SAVES_DESC + VARIANCE_POPULATION_ROW_ID_ASC + VARIANCE_POPULATION_ROW_ID_DESC + VARIANCE_POPULATION_MATCH_ID_ASC + VARIANCE_POPULATION_MATCH_ID_DESC + VARIANCE_POPULATION_PLAYER_ID_ASC + VARIANCE_POPULATION_PLAYER_ID_DESC + VARIANCE_POPULATION_TEAM_POSITION_ASC + VARIANCE_POPULATION_TEAM_POSITION_DESC + VARIANCE_POPULATION_POINTS_ASC + VARIANCE_POPULATION_POINTS_DESC + VARIANCE_POPULATION_GOALS_ASC + VARIANCE_POPULATION_GOALS_DESC + VARIANCE_POPULATION_SAVES_ASC + VARIANCE_POPULATION_SAVES_DESC +} + input MatchStatHavingAverageInput { rowId: HavingIntFilter matchId: HavingIntFilter @@ -2511,6 +2739,15 @@ type PlayerConnection { """The method to use when grouping \`Player\` for these aggregates.""" groupBy: [PlayerGroupBy!]! + """The ordering to apply to the grouped aggregates of \`Player\`.""" + orderBy: [PlayerGroupedAggregatesOrderBy!] + + """Only include the first \`n\` grouped aggregates.""" + first: Int + + """Only include the last \`n\` grouped aggregates.""" + last: Int + """Conditions on the grouped aggregates.""" having: PlayerHavingInput ): [PlayerAggregates!] @@ -2570,6 +2807,30 @@ enum PlayerGroupBy { NAME } +"""Ordering options when grouping \`Player\` aggregates.""" +enum PlayerGroupedAggregatesOrderBy { + SUM_ROW_ID_ASC + SUM_ROW_ID_DESC + DISTINCT_COUNT_ROW_ID_ASC + DISTINCT_COUNT_ROW_ID_DESC + DISTINCT_COUNT_NAME_ASC + DISTINCT_COUNT_NAME_DESC + MIN_ROW_ID_ASC + MIN_ROW_ID_DESC + MAX_ROW_ID_ASC + MAX_ROW_ID_DESC + AVERAGE_ROW_ID_ASC + AVERAGE_ROW_ID_DESC + STDDEV_SAMPLE_ROW_ID_ASC + STDDEV_SAMPLE_ROW_ID_DESC + STDDEV_POPULATION_ROW_ID_ASC + STDDEV_POPULATION_ROW_ID_DESC + VARIANCE_SAMPLE_ROW_ID_ASC + VARIANCE_SAMPLE_ROW_ID_DESC + VARIANCE_POPULATION_ROW_ID_ASC + VARIANCE_POPULATION_ROW_ID_DESC +} + input PlayerHavingAverageInput { rowId: HavingIntFilter } @@ -3529,6 +3790,15 @@ type ViewMatchStatConnection { """The method to use when grouping \`ViewMatchStat\` for these aggregates.""" groupBy: [ViewMatchStatGroupBy!]! + """The ordering to apply to the grouped aggregates of \`ViewMatchStat\`.""" + orderBy: [ViewMatchStatGroupedAggregatesOrderBy!] + + """Only include the first \`n\` grouped aggregates.""" + first: Int + + """Only include the last \`n\` grouped aggregates.""" + last: Int + """Conditions on the grouped aggregates.""" having: ViewMatchStatHavingInput ): [ViewMatchStatAggregates!] @@ -3644,6 +3914,138 @@ enum ViewMatchStatGroupBy { CREATED_AT_TRUNCATED_TO_DAY } +"""Ordering options when grouping \`ViewMatchStat\` aggregates.""" +enum ViewMatchStatGroupedAggregatesOrderBy { + SUM_ROW_ID_ASC + SUM_ROW_ID_DESC + SUM_MATCH_ID_ASC + SUM_MATCH_ID_DESC + SUM_PLAYER_ID_ASC + SUM_PLAYER_ID_DESC + SUM_TEAM_POSITION_ASC + SUM_TEAM_POSITION_DESC + SUM_POINTS_ASC + SUM_POINTS_DESC + SUM_GOALS_ASC + SUM_GOALS_DESC + SUM_SAVES_ASC + SUM_SAVES_DESC + DISTINCT_COUNT_ROW_ID_ASC + DISTINCT_COUNT_ROW_ID_DESC + DISTINCT_COUNT_MATCH_ID_ASC + DISTINCT_COUNT_MATCH_ID_DESC + DISTINCT_COUNT_PLAYER_ID_ASC + DISTINCT_COUNT_PLAYER_ID_DESC + DISTINCT_COUNT_TEAM_POSITION_ASC + DISTINCT_COUNT_TEAM_POSITION_DESC + DISTINCT_COUNT_POINTS_ASC + DISTINCT_COUNT_POINTS_DESC + DISTINCT_COUNT_GOALS_ASC + DISTINCT_COUNT_GOALS_DESC + DISTINCT_COUNT_SAVES_ASC + DISTINCT_COUNT_SAVES_DESC + DISTINCT_COUNT_CREATED_AT_ASC + DISTINCT_COUNT_CREATED_AT_DESC + MIN_ROW_ID_ASC + MIN_ROW_ID_DESC + MIN_MATCH_ID_ASC + MIN_MATCH_ID_DESC + MIN_PLAYER_ID_ASC + MIN_PLAYER_ID_DESC + MIN_TEAM_POSITION_ASC + MIN_TEAM_POSITION_DESC + MIN_POINTS_ASC + MIN_POINTS_DESC + MIN_GOALS_ASC + MIN_GOALS_DESC + MIN_SAVES_ASC + MIN_SAVES_DESC + MAX_ROW_ID_ASC + MAX_ROW_ID_DESC + MAX_MATCH_ID_ASC + MAX_MATCH_ID_DESC + MAX_PLAYER_ID_ASC + MAX_PLAYER_ID_DESC + MAX_TEAM_POSITION_ASC + MAX_TEAM_POSITION_DESC + MAX_POINTS_ASC + MAX_POINTS_DESC + MAX_GOALS_ASC + MAX_GOALS_DESC + MAX_SAVES_ASC + MAX_SAVES_DESC + AVERAGE_ROW_ID_ASC + AVERAGE_ROW_ID_DESC + AVERAGE_MATCH_ID_ASC + AVERAGE_MATCH_ID_DESC + AVERAGE_PLAYER_ID_ASC + AVERAGE_PLAYER_ID_DESC + AVERAGE_TEAM_POSITION_ASC + AVERAGE_TEAM_POSITION_DESC + AVERAGE_POINTS_ASC + AVERAGE_POINTS_DESC + AVERAGE_GOALS_ASC + AVERAGE_GOALS_DESC + AVERAGE_SAVES_ASC + AVERAGE_SAVES_DESC + STDDEV_SAMPLE_ROW_ID_ASC + STDDEV_SAMPLE_ROW_ID_DESC + STDDEV_SAMPLE_MATCH_ID_ASC + STDDEV_SAMPLE_MATCH_ID_DESC + STDDEV_SAMPLE_PLAYER_ID_ASC + STDDEV_SAMPLE_PLAYER_ID_DESC + STDDEV_SAMPLE_TEAM_POSITION_ASC + STDDEV_SAMPLE_TEAM_POSITION_DESC + STDDEV_SAMPLE_POINTS_ASC + STDDEV_SAMPLE_POINTS_DESC + STDDEV_SAMPLE_GOALS_ASC + STDDEV_SAMPLE_GOALS_DESC + STDDEV_SAMPLE_SAVES_ASC + STDDEV_SAMPLE_SAVES_DESC + STDDEV_POPULATION_ROW_ID_ASC + STDDEV_POPULATION_ROW_ID_DESC + STDDEV_POPULATION_MATCH_ID_ASC + STDDEV_POPULATION_MATCH_ID_DESC + STDDEV_POPULATION_PLAYER_ID_ASC + STDDEV_POPULATION_PLAYER_ID_DESC + STDDEV_POPULATION_TEAM_POSITION_ASC + STDDEV_POPULATION_TEAM_POSITION_DESC + STDDEV_POPULATION_POINTS_ASC + STDDEV_POPULATION_POINTS_DESC + STDDEV_POPULATION_GOALS_ASC + STDDEV_POPULATION_GOALS_DESC + STDDEV_POPULATION_SAVES_ASC + STDDEV_POPULATION_SAVES_DESC + VARIANCE_SAMPLE_ROW_ID_ASC + VARIANCE_SAMPLE_ROW_ID_DESC + VARIANCE_SAMPLE_MATCH_ID_ASC + VARIANCE_SAMPLE_MATCH_ID_DESC + VARIANCE_SAMPLE_PLAYER_ID_ASC + VARIANCE_SAMPLE_PLAYER_ID_DESC + VARIANCE_SAMPLE_TEAM_POSITION_ASC + VARIANCE_SAMPLE_TEAM_POSITION_DESC + VARIANCE_SAMPLE_POINTS_ASC + VARIANCE_SAMPLE_POINTS_DESC + VARIANCE_SAMPLE_GOALS_ASC + VARIANCE_SAMPLE_GOALS_DESC + VARIANCE_SAMPLE_SAVES_ASC + VARIANCE_SAMPLE_SAVES_DESC + VARIANCE_POPULATION_ROW_ID_ASC + VARIANCE_POPULATION_ROW_ID_DESC + VARIANCE_POPULATION_MATCH_ID_ASC + VARIANCE_POPULATION_MATCH_ID_DESC + VARIANCE_POPULATION_PLAYER_ID_ASC + VARIANCE_POPULATION_PLAYER_ID_DESC + VARIANCE_POPULATION_TEAM_POSITION_ASC + VARIANCE_POPULATION_TEAM_POSITION_DESC + VARIANCE_POPULATION_POINTS_ASC + VARIANCE_POPULATION_POINTS_DESC + VARIANCE_POPULATION_GOALS_ASC + VARIANCE_POPULATION_GOALS_DESC + VARIANCE_POPULATION_SAVES_ASC + VARIANCE_POPULATION_SAVES_DESC +} + input ViewMatchStatHavingAverageInput { rowId: HavingIntFilter matchId: HavingIntFilter diff --git a/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap new file mode 100644 index 0000000..b6d9e27 --- /dev/null +++ b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupedAggregatesOrderBy: result 1`] = ` +{ + "allMatchStats": { + "bottom": [ + { + "keys": [ + "1", + ], + "sum": { + "points": "2175", + }, + }, + { + "keys": [ + "5", + ], + "sum": { + "points": "2834", + }, + }, + ], + "top": [ + { + "keys": [ + "5", + ], + "sum": { + "points": "2834", + }, + }, + { + "keys": [ + "1", + ], + "sum": { + "points": "2175", + }, + }, + ], + }, +} +`; + +exports[`GroupedAggregatesOrderBy: sql 1`] = ` +[ + "select + (coalesce(sum(__match_stats__."points"), '0'))::text as "0", + __match_stats__."player_id"::text as "1" +from "test"."match_stats" as __match_stats__ +group by __match_stats__."player_id" +order by coalesce(sum(__match_stats__."points"), '0') desc +limit 2;", +] +`; diff --git a/__tests__/queries/groupedAggregatesOrderBy.test.ts b/__tests__/queries/groupedAggregatesOrderBy.test.ts new file mode 100644 index 0000000..3857d6f --- /dev/null +++ b/__tests__/queries/groupedAggregatesOrderBy.test.ts @@ -0,0 +1,31 @@ +import { testGraphQL } from "../helpers.js"; + +it( + "GroupedAggregatesOrderBy", + testGraphQL(/* GraphQL */ ` + query GroupedAggregatesOrderBy { + allMatchStats { + top: groupedAggregates( + groupBy: [PLAYER_ID] + orderBy: [SUM_POINTS_DESC] + first: 2 + ) { + keys + sum { + points + } + } + bottom: groupedAggregates( + groupBy: [PLAYER_ID] + orderBy: [SUM_POINTS_ASC] + last: 2 + ) { + keys + sum { + points + } + } + } + } + `) +); diff --git a/src/AddConnectionGroupedAggregatesPlugin.ts b/src/AddConnectionGroupedAggregatesPlugin.ts index 9a09c77..0b1aa82 100644 --- a/src/AddConnectionGroupedAggregatesPlugin.ts +++ b/src/AddConnectionGroupedAggregatesPlugin.ts @@ -125,6 +125,9 @@ const Plugin: GraphileConfig.Plugin = { const TableGroupByType = build.getTypeByName( inflection.aggregateGroupByType({ resource: table }) ) as GraphQLEnumType | undefined; + const TableGroupedOrderByType = build.getTypeByName( + inflection.aggregateGroupedAggregatesOrderByType({ resource: table }) + ) as GraphQLEnumType | undefined; const TableHavingInputType = build.getTypeByName( inflection.aggregateHavingInputType({ resource: table }) ) as GraphQLInputType; @@ -156,6 +159,75 @@ const Plugin: GraphileConfig.Plugin = { [] ), }, + ...(TableGroupedOrderByType && isValidEnum(build, TableGroupedOrderByType) + ? { + orderBy: { + type: new GraphQLList( + new GraphQLNonNull(TableGroupedOrderByType) + ), + description: build.wrapDescription( + `The ordering to apply to the grouped aggregates of \`${tableTypeName}\`.`, + "arg" + ), + applyPlan: EXPORTABLE( + () => + function ( + _$parent, + $pgSelect: PgSelectStep, + input + ) { + return input.apply($pgSelect); + }, + [] + ), + }, + } + : null), + first: { + type: build.graphql.GraphQLInt, + description: build.wrapDescription( + "Only include the first `n` grouped aggregates.", + "arg" + ), + applyPlan: EXPORTABLE( + () => + function ( + _$parent, + $pgSelect: PgSelectStep, + arg + ) { + $pgSelect.setFirst(arg.getRaw()); + }, + [] + ), + }, + last: { + type: build.graphql.GraphQLInt, + description: build.wrapDescription( + "Only include the last `n` grouped aggregates.", + "arg" + ), + applyPlan: EXPORTABLE( + () => + function ( + _$parent, + $pgSelect: PgSelectStep, + arg + ) { + const selectAny = $pgSelect as any; + const originalAssert = + selectAny.assertCursorPaginationAllowed; + try { + selectAny.assertCursorPaginationAllowed = () => {}; + $pgSelect.setLast(arg.getRaw()); + } finally { + selectAny.assertCursorPaginationAllowed = + originalAssert; + } + }, + [] + ), + }, ...(TableHavingInputType ? { having: { diff --git a/src/AddGroupedAggregatesOrderByPlugin.ts b/src/AddGroupedAggregatesOrderByPlugin.ts new file mode 100644 index 0000000..183e2f9 --- /dev/null +++ b/src/AddGroupedAggregatesOrderByPlugin.ts @@ -0,0 +1,264 @@ +import type { + PgCodecAttribute, + PgResource, + PgSelectQueryBuilder, +} from "@dataplan/pg"; +import type { GraphQLEnumValueConfigMap } from "graphql"; + +import type { AggregateSpec } from "./interfaces.js"; +import { EXPORTABLE } from "./EXPORTABLE.js"; + +const { version } = require("../package.json"); + +declare global { + namespace GraphileBuild { + interface BehaviorStrings { + "resource:groupedAggregates:orderBy": true; + "attribute:aggregate:groupedAggregates:orderBy": true; + } + interface ScopeEnum { + isPgAggregateGroupedOrderByEnum?: boolean; + } + interface ScopeEnumValues { + isPgAggregateGroupedOrderByEnum?: boolean; + } + } +} + +function isSuitableResource(resource: PgResource) { + return ( + !resource.parameters && + !!resource.codec.attributes && + !resource.isUnique + ); +} + +const Plugin: GraphileConfig.Plugin = { + name: "PgAggregatesAddGroupedAggregatesOrderByPlugin", + description: "Adds the orderBy enum used by groupedAggregates.", + version, + provides: ["aggregates"], + + schema: { + behaviorRegistry: { + add: { + "resource:groupedAggregates:orderBy": { + description: + "Should groupedAggregates orderBy options be added for this resource?", + entities: ["pgResource"], + }, + "attribute:aggregate:groupedAggregates:orderBy": { + description: + "Should groupedAggregates orderBy options be added for this attribute?", + entities: ["pgCodecAttribute"], + }, + }, + }, + + entityBehavior: { + pgResource: [ + "resource:groupedAggregates", + "resource:groupedAggregates:orderBy", + ], + pgCodecAttribute: ["attribute:aggregate:groupedAggregates:orderBy"], + }, + + hooks: { + init(_init, build) { + const { inflection } = build; + for (const resource of Object.values( + build.input.pgRegistry.pgResources + )) { + if (!isSuitableResource(resource)) { + continue; + } + if ( + !build.behavior.pgResourceMatches( + resource, + "resource:groupedAggregates" + ) + ) { + continue; + } + if ( + !build.behavior.pgResourceMatches( + resource, + "resource:groupedAggregates:orderBy" + ) + ) { + continue; + } + build.registerEnumType( + inflection.aggregateGroupedAggregatesOrderByType({ resource }), + { + pgTypeResource: resource, + isPgAggregateGroupedOrderByEnum: true, + }, + () => ({ + description: build.wrapDescription( + `Ordering options when grouping \`${inflection.tableType( + resource.codec + )}\` aggregates.`, + "type" + ), + values: {}, + }), + `Adding groupedAggregates orderBy enum for ${resource.name}.` + ); + } + return _init; + }, + + GraphQLEnumType_values(values, build, context) { + const { + extend, + inflection, + sql, + pgAggregateSpecs, + } = build; + const { + scope: { isPgAggregateGroupedOrderByEnum, pgTypeResource: resource }, + } = context; + if ( + !isPgAggregateGroupedOrderByEnum || + !resource || + resource.parameters || + !resource.codec.attributes + ) { + return values; + } + if ( + !build.behavior.pgResourceMatches( + resource, + "resource:groupedAggregates:orderBy" + ) + ) { + return values; + } + + const tableTypeName = inflection.tableType(resource.codec); + const additions: GraphQLEnumValueConfigMap = Object.create(null); + + const addAttributeOrderBy = ( + aggregateSpec: AggregateSpec, + attributeName: string, + attribute: PgCodecAttribute + ) => { + const attributeCodec = attribute.codec; + const targetCodec = + aggregateSpec.pgTypeCodecModifier?.(attributeCodec) ?? + attributeCodec; + const attributeFieldName = inflection.attribute({ + attributeName, + codec: resource.codec, + }); + const baseName = inflection.constantCase( + `${aggregateSpec.id}-${attributeFieldName}` + ); + const makeApply = (direction: "ASC" | "DESC") => + EXPORTABLE( + ( + aggregateSpec, + attributeName, + attributeCodec, + sql, + targetCodec, + direction + ) => + function apply($pgSelect: PgSelectQueryBuilder) { + const fragment = aggregateSpec.sqlAggregateWrap( + sql`${$pgSelect.alias}.${sql.identifier(attributeName)}`, + attributeCodec + ); + $pgSelect.orderBy({ + fragment, + codec: targetCodec, + direction, + }); + }, + [ + aggregateSpec, + attributeName, + attributeCodec, + sql, + targetCodec, + direction, + ] + ); + + additions[`${baseName}_ASC`] = { + extensions: { + grafast: { + apply: makeApply("ASC"), + }, + }, + }; + additions[`${baseName}_DESC`] = { + extensions: { + grafast: { + apply: makeApply("DESC"), + }, + }, + }; + }; + + for (const aggregateSpec of pgAggregateSpecs) { + if ( + !build.behavior.pgResourceMatches( + resource, + `${aggregateSpec.id}:resource:aggregates` + ) + ) { + continue; + } + + for (const [attributeName, attribute] of Object.entries( + resource.codec.attributes + ) as [string, PgCodecAttribute][]) { + if ( + !build.behavior.pgCodecAttributeMatches( + [resource.codec, attributeName], + `${aggregateSpec.id}:attribute:aggregate` + ) + ) { + continue; + } + if ( + !build.behavior.pgCodecAttributeMatches( + [resource.codec, attributeName], + "attribute:aggregate:groupedAggregates:orderBy" + ) + ) { + continue; + } + if ( + (aggregateSpec.shouldApplyToEntity && + !aggregateSpec.shouldApplyToEntity({ + type: "attribute", + codec: resource.codec, + attributeName, + })) || + !aggregateSpec.isSuitableType(attribute.codec) + ) { + continue; + } + + addAttributeOrderBy(aggregateSpec, attributeName, attribute); + } + } + + if (Object.keys(additions).length === 0) { + return values; + } + + return extend( + values, + additions, + `Adding groupedAggregates orderBy values for ${tableTypeName}` + ); + }, + }, + }, +}; + +export { Plugin as PgAggregatesAddGroupedAggregatesOrderByPlugin }; diff --git a/src/InflectionPlugin.ts b/src/InflectionPlugin.ts index c01fc77..0afbf05 100644 --- a/src/InflectionPlugin.ts +++ b/src/InflectionPlugin.ts @@ -56,6 +56,12 @@ declare global { resource: PgResource; } ): string; + aggregateGroupedAggregatesOrderByType( + this: Inflection, + details: { + resource: PgResource; + } + ): string; aggregateGroupByAttributeEnum( this: Inflection, details: { @@ -159,6 +165,13 @@ export const PgAggregatesInflectorsPlugin: GraphileConfig.Plugin = { `${this._singularizedCodecName(details.resource.codec)}-group-by` ); }, + aggregateGroupedAggregatesOrderByType(_preset, details) { + return this.upperCamelCase( + `${this._singularizedCodecName( + details.resource.codec + )}-grouped-aggregates-order-by` + ); + }, aggregateGroupByAttributeEnum(_preset, details) { return this.constantCase( `${this._attributeName({ diff --git a/src/index.ts b/src/index.ts index f6bf83a..37a8d9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { PgAggregatesAddAggregateTypesPlugin } from "./AddAggregateTypesPlugin.js"; import { PgAggregatesAddConnectionAggregatesPlugin } from "./AddConnectionAggregatesPlugin.js"; import { PgAggregatesAddConnectionGroupedAggregatesPlugin } from "./AddConnectionGroupedAggregatesPlugin.js"; +import { PgAggregatesAddGroupedAggregatesOrderByPlugin } from "./AddGroupedAggregatesOrderByPlugin.js"; import { PgAggregatesAddGroupByAggregateEnumsPlugin } from "./AddGroupByAggregateEnumsPlugin.js"; import { PgAggregatesAddGroupByAggregateEnumValuesForAttributesPlugin } from "./AddGroupByAggregateEnumValuesForAttributesPlugin.js"; import { PgAggregatesAddHavingAggregateTypesPlugin } from "./AddHavingAggregateTypesPlugin.js"; @@ -20,6 +21,7 @@ export const PgAggregatesPreset: GraphileConfig.Preset = { PgAggregatesAddHavingAggregateTypesPlugin, PgAggregatesAddAggregateTypesPlugin, PgAggregatesAddConnectionAggregatesPlugin, + PgAggregatesAddGroupedAggregatesOrderByPlugin, PgAggregatesAddConnectionGroupedAggregatesPlugin, PgAggregatesOrderByAggregatesPlugin, PgAggregatesFilterRelationalAggregatesPlugin, @@ -51,4 +53,4 @@ declare global { } } -// :args src/InflectionPlugin.ts src/AggregateSpecsPlugin.ts src/AddGroupByAggregateEnumsPlugin.ts src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts src/AddHavingAggregateTypesPlugin.ts src/AddAggregateTypesPlugin.ts src/AddConnectionAggregatesPlugin.ts src/AddConnectionGroupedAggregatesPlugin.ts src/OrderByAggregatesPlugin.ts src/FilterRelationalAggregatesPlugin.ts src/AggregatesSmartTagsPlugin.ts +// :args src/InflectionPlugin.ts src/AggregateSpecsPlugin.ts src/AddGroupByAggregateEnumsPlugin.ts src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts src/AddHavingAggregateTypesPlugin.ts src/AddAggregateTypesPlugin.ts src/AddConnectionAggregatesPlugin.ts src/AddGroupedAggregatesOrderByPlugin.ts src/AddConnectionGroupedAggregatesPlugin.ts src/OrderByAggregatesPlugin.ts src/FilterRelationalAggregatesPlugin.ts src/AggregatesSmartTagsPlugin.ts From 1b38970314d3d567ee7e110edaaaa6b770954895 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 18:06:57 +0100 Subject: [PATCH 2/7] Add per-aggregate behaviors for groupedAggregates orderBy Enhances the groupedAggregates:orderBy behavior system to support per-aggregate scoping, providing more granular control over which aggregates can be used for ordering grouped results. Changes: - Add per-aggregate behavior declarations (sum:, min:, max:, average:, distinctCount:, stddevSample:, stddevPopulation:, varianceSample:, variancePopulation:) for groupedAggregates:orderBy - Simplify entity behavior registration by removing redundant array wrapper - Remove manual hierarchical behavior checking logic in favor of PostGraphile's built-in behavior precedence system - Inline resource validation check for better encapsulation Documentation: - Document groupedAggregates:orderBy behavior with explanation of why it's disabled by default (18 enum values per attribute = schema bloat) - Add examples showing three levels of granularity: table-level, column-level, and per-aggregate scoping - Follow existing documentation style for consistency This allows users to selectively enable only the specific aggregates they need (e.g., +sum:attribute:aggregate:groupedAggregates:orderBy) rather than all 9 aggregates, reducing schema bloat while maintaining flexibility. --- README.md | 28 +++++ .../groupedAggregatesOrderByBehavior.test.ts | 118 ++++++++++++++++++ __tests__/helpers.ts | 4 + src/AddGroupedAggregatesOrderByPlugin.ts | 55 ++++---- 4 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 __tests__/groupedAggregatesOrderByBehavior.test.ts diff --git a/README.md b/README.md index 54c89fc..3a6e705 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,16 @@ appearing on a table's connections. The `groupedAggregates` behavior is used to enable/disable the 'groupedAggregates' field appearing on a table's connections. +The `groupedAggregates:orderBy` behavior (available at both resource and +attribute level) is used to enable/disable the `orderBy` argument on the +'groupedAggregates' field. This is **disabled by default** as it adds 18 enum +values per aggregatable attribute (2 directions × 9 aggregate types), which can +cause significant schema bloat with many aggregatable attributes. You can +further scope these, for example adding the behavior +`+sum:attribute:aggregate:groupedAggregates:orderBy` to a specific column would +enable ordering groupedAggregates by the `sum` aggregate of this column whilst +leaving all other aggregates disabled. + The `having` behavior is used to enable/disable the `having` filter on the 'groupedAggregates' field appearing on a table's connections. @@ -514,6 +524,24 @@ Enable aggregates for a specific table: COMMENT ON TABLE my_schema.my_table IS E'@behavior +aggregates +aggregates:filterBy +aggregates:orderBy'; ``` +Enable `groupedAggregates` orderBy for a specific table: + +```sql +COMMENT ON TABLE my_schema.my_table IS E'@behavior +resource:groupedAggregates:orderBy'; +``` + +Or enable it only for specific columns: + +```sql +COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior +attribute:aggregate:groupedAggregates:orderBy'; +``` + +Or enable only specific aggregates for a column (e.g., only SUM and AVERAGE): + +```sql +COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior +sum:attribute:aggregate:groupedAggregates:orderBy +average:attribute:aggregate:groupedAggregates:orderBy'; +``` + You also can keep aggregates enabled by default, but disable aggregates for specific tables: diff --git a/__tests__/groupedAggregatesOrderByBehavior.test.ts b/__tests__/groupedAggregatesOrderByBehavior.test.ts new file mode 100644 index 0000000..b7d6234 --- /dev/null +++ b/__tests__/groupedAggregatesOrderByBehavior.test.ts @@ -0,0 +1,118 @@ +import { Pool } from "pg"; +import { makeSchema } from "postgraphile"; +import { makePgService } from "postgraphile/adaptors/pg"; +import type { GraphQLEnumType } from "graphql"; +import { PostGraphileAmberPreset } from "postgraphile/presets/amber"; +import { PostGraphileConnectionFilterPreset } from "postgraphile-plugin-connection-filter"; +import { PgAggregatesPreset } from "../dist/index.js"; + +let pool: Pool | undefined; + +afterEach(() => { + if (pool) { + pool.end(); + pool = undefined; + } +}); + +async function getSchemaWithBehavior(behaviorConfig?: string) { + if (!pool) { + pool = new Pool({ + connectionString: + process.env.TEST_DATABASE_URL ?? "postgres:///graphile_aggregates_test", + }); + pool.on("error", () => {}); + pool.on("connect", (client) => { + client.query(`set time zone 'UTC'`); + client.on("error", () => {}); + }); + } + + const preset: GraphileConfig.Preset = { + extends: [ + PostGraphileAmberPreset, + PostGraphileConnectionFilterPreset, + PgAggregatesPreset, + ], + disablePlugins: ["MutationPlugin", "PgIndexBehaviorsPlugin"], + pgServices: [ + makePgService({ + pool, + schemas: ["test"], + }), + ], + schema: behaviorConfig + ? { + defaultBehavior: behaviorConfig, + } + : undefined, + }; + return await makeSchema(preset); +} + +describe("GroupedAggregates OrderBy Behavior", () => { + it("should support opt-out via explicit negative behavior", async () => { + const { schema } = await getSchemaWithBehavior( + "-resource:groupedAggregates:orderBy -attribute:aggregate:groupedAggregates:orderBy" + ); + const matchStatOrderByType = schema.getType( + "MatchStatGroupedAggregatesOrderBy" + ) as GraphQLEnumType | undefined; + + // When explicitly disabled, enum should have no values or not exist + if (matchStatOrderByType) { + const values = matchStatOrderByType.getValues(); + expect(values.length).toBe(0); + } + }); + + it("should include orderBy enum values with resource-level opt-in", async () => { + const { schema } = await getSchemaWithBehavior( + "+resource:groupedAggregates:orderBy" + ); + const matchStatOrderByType = schema.getType( + "MatchStatGroupedAggregatesOrderBy" + ) as GraphQLEnumType; + + expect(matchStatOrderByType).toBeDefined(); + const values = matchStatOrderByType.getValues(); + + // Should have orderBy options for all aggregates on all suitable attributes + expect(values.length).toBeGreaterThan(0); + + // Check for specific values we expect + const valueNames = values.map((v) => v.name); + expect(valueNames).toContain("SUM_POINTS_ASC"); + expect(valueNames).toContain("SUM_POINTS_DESC"); + expect(valueNames).toContain("AVERAGE_GOALS_ASC"); + expect(valueNames).toContain("MAX_SAVES_DESC"); + }); + + it("should include orderBy enum values with attribute-level opt-in", async () => { + const { schema } = await getSchemaWithBehavior( + "+attribute:aggregate:groupedAggregates:orderBy" + ); + const matchStatOrderByType = schema.getType( + "MatchStatGroupedAggregatesOrderBy" + ) as GraphQLEnumType | undefined; + + // When attribute-level behavior is enabled, enum should exist with values + expect(matchStatOrderByType).toBeDefined(); + if (matchStatOrderByType) { + const values = matchStatOrderByType.getValues(); + expect(values.length).toBeGreaterThan(0); + + const valueNames = values.map((v) => v.name); + expect(valueNames).toContain("SUM_POINTS_ASC"); + expect(valueNames).toContain("AVERAGE_POINTS_DESC"); + } + }); + + // Note: This implementation supports two levels of control: + // 1. Resource-level: +resource:groupedAggregates:orderBy (enables for all attributes) + // 2. Attribute-level: +attribute:aggregate:groupedAggregates:orderBy (enables for specific attribute) + // + // Aggregate-specific behaviors (e.g., sum-only, average-only) are not currently supported + // due to complexity in the behavior matching algorithm. If needed in the future, this could + // be implemented by checking behaviors at the resource level rather than attribute level. +}); diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 5678415..f34655e 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -49,6 +49,10 @@ export async function getSchema() { schemas: ["test"], }), ], + schema: { + // Opt-in to orderBy for grouped aggregates in tests + defaultBehavior: "+resource:groupedAggregates:orderBy +attribute:aggregate:groupedAggregates:orderBy", + }, }; return await makeSchema(preset); } diff --git a/src/AddGroupedAggregatesOrderByPlugin.ts b/src/AddGroupedAggregatesOrderByPlugin.ts index 183e2f9..2c50e6c 100644 --- a/src/AddGroupedAggregatesOrderByPlugin.ts +++ b/src/AddGroupedAggregatesOrderByPlugin.ts @@ -15,6 +15,16 @@ declare global { interface BehaviorStrings { "resource:groupedAggregates:orderBy": true; "attribute:aggregate:groupedAggregates:orderBy": true; + + "sum:attribute:aggregate:groupedAggregates:orderBy": true; + "distinctCount:attribute:aggregate:groupedAggregates:orderBy": true; + "min:attribute:aggregate:groupedAggregates:orderBy": true; + "max:attribute:aggregate:groupedAggregates:orderBy": true; + "average:attribute:aggregate:groupedAggregates:orderBy": true; + "stddevSample:attribute:aggregate:groupedAggregates:orderBy": true; + "stddevPopulation:attribute:aggregate:groupedAggregates:orderBy": true; + "varianceSample:attribute:aggregate:groupedAggregates:orderBy": true; + "variancePopulation:attribute:aggregate:groupedAggregates:orderBy": true; } interface ScopeEnum { isPgAggregateGroupedOrderByEnum?: boolean; @@ -25,14 +35,6 @@ declare global { } } -function isSuitableResource(resource: PgResource) { - return ( - !resource.parameters && - !!resource.codec.attributes && - !resource.isUnique - ); -} - const Plugin: GraphileConfig.Plugin = { name: "PgAggregatesAddGroupedAggregatesOrderByPlugin", description: "Adds the orderBy enum used by groupedAggregates.", @@ -49,18 +51,15 @@ const Plugin: GraphileConfig.Plugin = { }, "attribute:aggregate:groupedAggregates:orderBy": { description: - "Should groupedAggregates orderBy options be added for this attribute?", + "Should groupedAggregates orderBy options be added for this attribute (for all aggregates)?", entities: ["pgCodecAttribute"], }, }, }, entityBehavior: { - pgResource: [ - "resource:groupedAggregates", - "resource:groupedAggregates:orderBy", - ], - pgCodecAttribute: ["attribute:aggregate:groupedAggregates:orderBy"], + pgResource: "-resource:groupedAggregates:orderBy", + pgCodecAttribute: "-attribute:aggregate:groupedAggregates:orderBy", }, hooks: { @@ -69,25 +68,24 @@ const Plugin: GraphileConfig.Plugin = { for (const resource of Object.values( build.input.pgRegistry.pgResources )) { - if (!isSuitableResource(resource)) { - continue; - } if ( - !build.behavior.pgResourceMatches( - resource, - "resource:groupedAggregates" - ) + resource.parameters || + !resource.codec.attributes || + resource.isUnique ) { continue; } if ( !build.behavior.pgResourceMatches( resource, - "resource:groupedAggregates:orderBy" + "resource:groupedAggregates" ) ) { continue; } + + // Register the enum type - it will be empty if no attributes/aggregates + // have orderBy enabled, and isValidEnum will filter it out from the schema build.registerEnumType( inflection.aggregateGroupedAggregatesOrderByType({ resource }), { @@ -127,14 +125,6 @@ const Plugin: GraphileConfig.Plugin = { ) { return values; } - if ( - !build.behavior.pgResourceMatches( - resource, - "resource:groupedAggregates:orderBy" - ) - ) { - return values; - } const tableTypeName = inflection.tableType(resource.codec); const additions: GraphQLEnumValueConfigMap = Object.create(null); @@ -223,14 +213,17 @@ const Plugin: GraphileConfig.Plugin = { ) { continue; } + + // Check if this specific aggregate's orderBy is enabled for this attribute if ( !build.behavior.pgCodecAttributeMatches( [resource.codec, attributeName], - "attribute:aggregate:groupedAggregates:orderBy" + `${aggregateSpec.id}:attribute:aggregate:groupedAggregates:orderBy` ) ) { continue; } + if ( (aggregateSpec.shouldApplyToEntity && !aggregateSpec.shouldApplyToEntity({ From e5ebee01aaf7f58291747f3ee0a4af6806cf9fbd Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 19:27:11 +0100 Subject: [PATCH 3/7] Remove 'last' argument from groupedAggregates field The 'last' argument is the opposite of 'first' and adds unnecessary complexity for grouped aggregates. Users can achieve the same result by reversing the orderBy direction and using 'first'. Changes: - Remove 'last' argument from groupedAggregates field definition - Update README to only document 'first' argument - Update test query to remove usage of 'last' argument - Regenerate snapshots with updated schema This simplifies the API while maintaining all necessary functionality. --- README.md | 14 ++++------ __tests__/__snapshots__/schema.test.ts.snap | 12 --------- .../groupedAggregatesOrderBy.test.ts.snap | 18 ------------- .../queries/groupedAggregatesOrderBy.test.ts | 10 ------- src/AddConnectionGroupedAggregatesPlugin.ts | 27 ------------------- 5 files changed, 5 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 3a6e705..926c2db 100644 --- a/README.md +++ b/README.md @@ -274,12 +274,11 @@ below for details on how to add your own grouping derivatives. The `groupedAggregates` field accepts a few arguments in addition to `groupBy`: - `orderBy` – controls how groups are sorted. You can order by any aggregate that - appears in the grouped output (e.g. `SUM_POINTS_DESC`). -- `first` / `last` – slice the ordered groups, returning only the leading or - trailing `n` groups. + appears in the grouped output (e.g. `SUM_POINTS_DESC`). +- `first` – limit the results to only the first `n` groups. -Always pair `first` or `last` with an explicit `orderBy` so PostgreSQL can -deterministically rank the groups before trimming them. +Always pair `first` with an explicit `orderBy` so PostgreSQL can +deterministically rank the groups before limiting them. The aggregates supported over groups are the same as over the connection as a whole (see [Aggregates](#aggregates) above), but in addition you may also @@ -323,7 +322,7 @@ query AverageGoalsOnDaysWithAveragePointsOver200 { groupBy: [CREATED_AT_TRUNCATED_TO_DAY] having: { average: { points: { greaterThan: 200 } } } orderBy: [AVERAGE_GOALS_DESC] - last: 3 + first: 3 ) { keys average { @@ -334,9 +333,6 @@ query AverageGoalsOnDaysWithAveragePointsOver200 { } ``` -When using `last`, be sure to supply an `orderBy` so the database can produce a -deterministic ordering before the tail slice is applied. - ## Defining your own aggregates You can add your own aggregates by using a plugin to add your own aggregate diff --git a/__tests__/__snapshots__/schema.test.ts.snap b/__tests__/__snapshots__/schema.test.ts.snap index 4a99953..939aeb8 100644 --- a/__tests__/__snapshots__/schema.test.ts.snap +++ b/__tests__/__snapshots__/schema.test.ts.snap @@ -309,9 +309,6 @@ type FilmConnection { """Only include the first \`n\` grouped aggregates.""" first: Int - """Only include the last \`n\` grouped aggregates.""" - last: Int - """Conditions on the grouped aggregates.""" having: FilmHavingInput ): [FilmAggregates!] @@ -1709,9 +1706,6 @@ type MatchStatConnection { """Only include the first \`n\` grouped aggregates.""" first: Int - """Only include the last \`n\` grouped aggregates.""" - last: Int - """Conditions on the grouped aggregates.""" having: MatchStatHavingInput ): [MatchStatAggregates!] @@ -2745,9 +2739,6 @@ type PlayerConnection { """Only include the first \`n\` grouped aggregates.""" first: Int - """Only include the last \`n\` grouped aggregates.""" - last: Int - """Conditions on the grouped aggregates.""" having: PlayerHavingInput ): [PlayerAggregates!] @@ -3796,9 +3787,6 @@ type ViewMatchStatConnection { """Only include the first \`n\` grouped aggregates.""" first: Int - """Only include the last \`n\` grouped aggregates.""" - last: Int - """Conditions on the grouped aggregates.""" having: ViewMatchStatHavingInput ): [ViewMatchStatAggregates!] diff --git a/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap index b6d9e27..d8d1808 100644 --- a/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap +++ b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap @@ -3,24 +3,6 @@ exports[`GroupedAggregatesOrderBy: result 1`] = ` { "allMatchStats": { - "bottom": [ - { - "keys": [ - "1", - ], - "sum": { - "points": "2175", - }, - }, - { - "keys": [ - "5", - ], - "sum": { - "points": "2834", - }, - }, - ], "top": [ { "keys": [ diff --git a/__tests__/queries/groupedAggregatesOrderBy.test.ts b/__tests__/queries/groupedAggregatesOrderBy.test.ts index 3857d6f..cc29457 100644 --- a/__tests__/queries/groupedAggregatesOrderBy.test.ts +++ b/__tests__/queries/groupedAggregatesOrderBy.test.ts @@ -15,16 +15,6 @@ it( points } } - bottom: groupedAggregates( - groupBy: [PLAYER_ID] - orderBy: [SUM_POINTS_ASC] - last: 2 - ) { - keys - sum { - points - } - } } } `) diff --git a/src/AddConnectionGroupedAggregatesPlugin.ts b/src/AddConnectionGroupedAggregatesPlugin.ts index 7b76f17..d83a554 100644 --- a/src/AddConnectionGroupedAggregatesPlugin.ts +++ b/src/AddConnectionGroupedAggregatesPlugin.ts @@ -196,33 +196,6 @@ const Plugin: GraphileConfig.Plugin = { [] ), }, - last: { - type: build.graphql.GraphQLInt, - description: build.wrapDescription( - "Only include the last `n` grouped aggregates.", - "arg" - ), - applyPlan: EXPORTABLE( - () => - function ( - _$parent, - $pgSelect: PgSelectStep, - arg - ) { - const selectAny = $pgSelect as any; - const originalAssert = - selectAny.assertCursorPaginationAllowed; - try { - selectAny.assertCursorPaginationAllowed = () => {}; - $pgSelect.setLast(arg.getRaw()); - } finally { - selectAny.assertCursorPaginationAllowed = - originalAssert; - } - }, - [] - ), - }, ...(TableHavingInputType ? { having: { From b90ba3c132bf8322103c378613df6bac97b21f97 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 19:33:21 +0100 Subject: [PATCH 4/7] Add default ordering by GROUP BY columns for groupedAggregates When no explicit orderBy is specified, groupedAggregates now returns results ordered by the GROUP BY columns in ascending order. This ensures deterministic, predictable results instead of database-dependent ordering. Changes: - Modify groupBy enum value apply functions to add default ORDER BY - Apply to both direct attributes and derivative group-by specs - Update README to document default ordering behavior - Regenerate test snapshots with new ORDER BY clauses in SQL This provides a better developer experience with consistent, sorted results by default while still allowing custom ordering via orderBy argument when needed. --- README.md | 8 ++- ...verageDurationByYearOfRelease.test.ts.snap | 35 ++++++----- ...nDaysWithAveragePointsOver200.test.ts.snap | 11 ++-- ...groupedAggregatesByDerivative.test.ts.snap | 62 ++++++++++--------- .../groupedAggregatesOrderBy.test.ts.snap | 10 +-- ...yAggregateEnumValuesForAttributesPlugin.ts | 37 +++++++---- 6 files changed, 91 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 926c2db..5deca4f 100644 --- a/README.md +++ b/README.md @@ -274,11 +274,13 @@ below for details on how to add your own grouping derivatives. The `groupedAggregates` field accepts a few arguments in addition to `groupBy`: - `orderBy` – controls how groups are sorted. You can order by any aggregate that - appears in the grouped output (e.g. `SUM_POINTS_DESC`). + appears in the grouped output (e.g. `SUM_POINTS_DESC`). If not specified, results + are ordered by the `groupBy` columns in ascending order for deterministic results. - `first` – limit the results to only the first `n` groups. -Always pair `first` with an explicit `orderBy` so PostgreSQL can -deterministically rank the groups before limiting them. +When using `first`, consider specifying an explicit `orderBy` to control which groups +are returned (e.g., top performers by sum). Without `orderBy`, groups are ordered by +their `groupBy` values. The aggregates supported over groups are the same as over the connection as a whole (see [Aggregates](#aggregates) above), but in addition you may also diff --git a/__tests__/queries/__snapshots__/averageDurationByYearOfRelease.test.ts.snap b/__tests__/queries/__snapshots__/averageDurationByYearOfRelease.test.ts.snap index 198f070..5dbf71a 100644 --- a/__tests__/queries/__snapshots__/averageDurationByYearOfRelease.test.ts.snap +++ b/__tests__/queries/__snapshots__/averageDurationByYearOfRelease.test.ts.snap @@ -6,10 +6,10 @@ exports[`AverageDurationByYearOfRelease: result 1`] = ` "groupedAggregates": [ { "average": { - "durationInMinutes": "139.0000000000000000", + "durationInMinutes": "195.0000000000000000", }, "keys": [ - "2017", + "1997", ], }, { @@ -22,58 +22,58 @@ exports[`AverageDurationByYearOfRelease: result 1`] = ` }, { "average": { - "durationInMinutes": "116.5000000000000000", + "durationInMinutes": "142.0000000000000000", }, "keys": [ - "2013", + "2011", ], }, { "average": { - "durationInMinutes": "195.0000000000000000", + "durationInMinutes": "143.0000000000000000", }, "keys": [ - "1997", + "2012", ], }, { "average": { - "durationInMinutes": "147.0000000000000000", + "durationInMinutes": "116.5000000000000000", }, "keys": [ - "2016", + "2013", ], }, { "average": { - "durationInMinutes": "130.6000000000000000", + "durationInMinutes": "126.0000000000000000", }, "keys": [ - "2018", + "2015", ], }, { "average": { - "durationInMinutes": "143.0000000000000000", + "durationInMinutes": "147.0000000000000000", }, "keys": [ - "2012", + "2016", ], }, { "average": { - "durationInMinutes": "126.0000000000000000", + "durationInMinutes": "139.0000000000000000", }, "keys": [ - "2015", + "2017", ], }, { "average": { - "durationInMinutes": "142.0000000000000000", + "durationInMinutes": "130.6000000000000000", }, "keys": [ - "2011", + "2018", ], }, ], @@ -87,6 +87,7 @@ exports[`AverageDurationByYearOfRelease: sql 1`] = ` (avg(__films__."duration_in_minutes"))::text as "0", __films__."year_of_release"::text as "1" from "test"."films" as __films__ -group by __films__."year_of_release";", +group by __films__."year_of_release" +order by __films__."year_of_release" asc;", ] `; diff --git a/__tests__/queries/__snapshots__/averageGoalsOnDaysWithAveragePointsOver200.test.ts.snap b/__tests__/queries/__snapshots__/averageGoalsOnDaysWithAveragePointsOver200.test.ts.snap index 1c1186f..ced6760 100644 --- a/__tests__/queries/__snapshots__/averageGoalsOnDaysWithAveragePointsOver200.test.ts.snap +++ b/__tests__/queries/__snapshots__/averageGoalsOnDaysWithAveragePointsOver200.test.ts.snap @@ -6,18 +6,18 @@ exports[`AverageGoalsOnDaysWithAveragePointsOver200: result 1`] = ` "byDay": [ { "average": { - "goals": "2.8571428571428571", + "goals": "2.8461538461538462", }, "keys": [ - "2020-10-24T00:00:00.000000+00:00", + "2020-10-22T00:00:00.000000+00:00", ], }, { "average": { - "goals": "2.8461538461538462", + "goals": "2.8571428571428571", }, "keys": [ - "2020-10-22T00:00:00.000000+00:00", + "2020-10-24T00:00:00.000000+00:00", ], }, ], @@ -34,6 +34,7 @@ from "test"."match_stats" as __match_stats__ group by date_trunc('day', __match_stats__."created_at") having ( ((avg(__match_stats__."points")) > $1::"int4") -);", +) +order by date_trunc('day', __match_stats__."created_at") asc;", ] `; diff --git a/__tests__/queries/__snapshots__/groupedAggregatesByDerivative.test.ts.snap b/__tests__/queries/__snapshots__/groupedAggregatesByDerivative.test.ts.snap index 13c1537..9b9a60a 100644 --- a/__tests__/queries/__snapshots__/groupedAggregatesByDerivative.test.ts.snap +++ b/__tests__/queries/__snapshots__/groupedAggregatesByDerivative.test.ts.snap @@ -6,116 +6,116 @@ exports[`GroupedAggregatesByDerivative: result 1`] = ` "byDay": [ { "average": { - "points": "176.8000000000000000", + "points": "294.6153846153846154", }, "keys": [ - "2020-10-25T00:00:00.000000+00:00", + "2020-10-22T00:00:00.000000+00:00", ], }, { "average": { - "points": "225.5714285714285714", + "points": "189.2307692307692308", }, "keys": [ - "2020-10-24T00:00:00.000000+00:00", + "2020-10-23T00:00:00.000000+00:00", ], }, { "average": { - "points": "189.2307692307692308", + "points": "225.5714285714285714", }, "keys": [ - "2020-10-23T00:00:00.000000+00:00", + "2020-10-24T00:00:00.000000+00:00", ], }, { "average": { - "points": "294.6153846153846154", + "points": "176.8000000000000000", }, "keys": [ - "2020-10-22T00:00:00.000000+00:00", + "2020-10-25T00:00:00.000000+00:00", ], }, ], "byHour": [ { "average": { - "points": "185.6666666666666667", + "points": "310.2000000000000000", }, "keys": [ - "2020-10-24T19:00:00.000000+00:00", + "2020-10-22T18:00:00.000000+00:00", ], }, { "average": { - "points": "69.0000000000000000", + "points": "242.6666666666666667", }, "keys": [ - "2020-10-24T17:00:00.000000+00:00", + "2020-10-22T19:00:00.000000+00:00", ], }, { "average": { - "points": "310.2000000000000000", + "points": "107.0000000000000000", }, "keys": [ - "2020-10-22T18:00:00.000000+00:00", + "2020-10-23T17:00:00.000000+00:00", ], }, { "average": { - "points": "31.0000000000000000", + "points": "163.7777777777777778", }, "keys": [ - "2020-10-25T18:00:00.000000+00:00", + "2020-10-23T18:00:00.000000+00:00", ], }, { "average": { - "points": "163.7777777777777778", + "points": "293.0000000000000000", }, "keys": [ - "2020-10-23T18:00:00.000000+00:00", + "2020-10-23T19:00:00.000000+00:00", ], }, { "average": { - "points": "213.2500000000000000", + "points": "69.0000000000000000", }, "keys": [ - "2020-10-25T19:00:00.000000+00:00", + "2020-10-24T17:00:00.000000+00:00", ], }, { "average": { - "points": "293.0000000000000000", + "points": "253.2000000000000000", }, "keys": [ - "2020-10-23T19:00:00.000000+00:00", + "2020-10-24T18:00:00.000000+00:00", ], }, { "average": { - "points": "107.0000000000000000", + "points": "185.6666666666666667", }, "keys": [ - "2020-10-23T17:00:00.000000+00:00", + "2020-10-24T19:00:00.000000+00:00", ], }, { "average": { - "points": "242.6666666666666667", + "points": "31.0000000000000000", }, "keys": [ - "2020-10-22T19:00:00.000000+00:00", + "2020-10-25T18:00:00.000000+00:00", ], }, { "average": { - "points": "253.2000000000000000", + "points": "213.2500000000000000", }, "keys": [ - "2020-10-24T18:00:00.000000+00:00", + "2020-10-25T19:00:00.000000+00:00", ], }, ], @@ -129,12 +129,14 @@ exports[`GroupedAggregatesByDerivative: sql 1`] = ` (avg(__match_stats__."points"))::text as "0", to_char(date_trunc('day', __match_stats__."created_at"), 'YYYY-MM-DD"T"HH24:MI:SS.USTZH:TZM'::text) as "1" from "test"."match_stats" as __match_stats__ -group by date_trunc('day', __match_stats__."created_at");", +group by date_trunc('day', __match_stats__."created_at") +order by date_trunc('day', __match_stats__."created_at") asc;", "/* UNSUPPORTED QUERY CALL! */", "select (avg(__match_stats__."points"))::text as "0", to_char(date_trunc('hour', __match_stats__."created_at"), 'YYYY-MM-DD"T"HH24:MI:SS.USTZH:TZM'::text) as "1" from "test"."match_stats" as __match_stats__ -group by date_trunc('hour', __match_stats__."created_at");", +group by date_trunc('hour', __match_stats__."created_at") +order by date_trunc('hour', __match_stats__."created_at") asc;", ] `; diff --git a/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap index d8d1808..2a6a52d 100644 --- a/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap +++ b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap @@ -6,18 +6,18 @@ exports[`GroupedAggregatesOrderBy: result 1`] = ` "top": [ { "keys": [ - "5", + "1", ], "sum": { - "points": "2834", + "points": "2175", }, }, { "keys": [ - "1", + "2", ], "sum": { - "points": "2175", + "points": "1771", }, }, ], @@ -32,7 +32,7 @@ exports[`GroupedAggregatesOrderBy: sql 1`] = ` __match_stats__."player_id"::text as "1" from "test"."match_stats" as __match_stats__ group by __match_stats__."player_id" -order by coalesce(sum(__match_stats__."points"), '0') desc +order by __match_stats__."player_id" asc, coalesce(sum(__match_stats__."points"), '0') desc limit 2;", ] `; diff --git a/src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts b/src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts index d713990..8f7beee 100644 --- a/src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts +++ b/src/AddGroupByAggregateEnumValuesForAttributesPlugin.ts @@ -105,12 +105,19 @@ const Plugin: GraphileConfig.Plugin = { apply: EXPORTABLE( (attrCodec, attributeName, sql) => function ($pgSelect: PgSelectQueryBuilder) { + const fragment = sql.fragment`${ + $pgSelect.alias + }.${sql.identifier(attributeName)}`; $pgSelect.groupBy({ - fragment: sql.fragment`${ - $pgSelect.alias - }.${sql.identifier(attributeName)}`, + fragment, codec: attrCodec, }); + // Default ordering by GROUP BY columns for deterministic results + $pgSelect.orderBy({ + fragment, + codec: attrCodec, + direction: "ASC", + }); }, [attrCodec, attributeName, sql] ), @@ -152,16 +159,22 @@ const Plugin: GraphileConfig.Plugin = { sql ) => function ($pgSelect: PgSelectQueryBuilder) { + const fragment = aggregateGroupBySpec.sqlWrap( + sql`${$pgSelect.alias}.${sql.identifier( + attributeName + )}` + ); + const codec = + aggregateGroupBySpec.sqlWrapCodec(attrCodec); $pgSelect.groupBy({ - fragment: aggregateGroupBySpec.sqlWrap( - sql`${$pgSelect.alias}.${sql.identifier( - attributeName - )}` - ), - codec: - aggregateGroupBySpec.sqlWrapCodec( - attrCodec - ), + fragment, + codec, + }); + // Default ordering by GROUP BY columns for deterministic results + $pgSelect.orderBy({ + fragment, + codec, + direction: "ASC", }); }, [ From 2f8bb30a87eacb917e36a23c490e1226522cfd00 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 19:37:59 +0100 Subject: [PATCH 5/7] Add tests for per-aggregate groupedAggregates orderBy behaviors Adds comprehensive tests validating that per-aggregate behavior scoping works correctly for groupedAggregates orderBy functionality. Tests verify: - Enabling only SUM aggregate for orderBy (excludes MIN, MAX, AVERAGE, etc.) - Enabling multiple specific aggregates (SUM + AVERAGE) - Proper behavior precedence (more specific beats less specific) Example usage shown in tests: -attribute:aggregate:groupedAggregates:orderBy +sum:attribute:aggregate:groupedAggregates:orderBy This allows users to selectively enable only the aggregates they need, reducing schema bloat from 18 enum values per attribute down to 2 per enabled aggregate type. --- .../groupedAggregatesOrderByBehavior.test.ts | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/__tests__/groupedAggregatesOrderByBehavior.test.ts b/__tests__/groupedAggregatesOrderByBehavior.test.ts index b7d6234..0da5e42 100644 --- a/__tests__/groupedAggregatesOrderByBehavior.test.ts +++ b/__tests__/groupedAggregatesOrderByBehavior.test.ts @@ -108,11 +108,56 @@ describe("GroupedAggregates OrderBy Behavior", () => { } }); - // Note: This implementation supports two levels of control: - // 1. Resource-level: +resource:groupedAggregates:orderBy (enables for all attributes) - // 2. Attribute-level: +attribute:aggregate:groupedAggregates:orderBy (enables for specific attribute) - // - // Aggregate-specific behaviors (e.g., sum-only, average-only) are not currently supported - // due to complexity in the behavior matching algorithm. If needed in the future, this could - // be implemented by checking behaviors at the resource level rather than attribute level. + it("should support per-aggregate opt-in (sum only)", async () => { + const { schema } = await getSchemaWithBehavior( + "-attribute:aggregate:groupedAggregates:orderBy +sum:attribute:aggregate:groupedAggregates:orderBy" + ); + const matchStatOrderByType = schema.getType( + "MatchStatGroupedAggregatesOrderBy" + ) as GraphQLEnumType | undefined; + + expect(matchStatOrderByType).toBeDefined(); + if (matchStatOrderByType) { + const values = matchStatOrderByType.getValues(); + const valueNames = values.map((v) => v.name); + + // Should have SUM values + expect(valueNames).toContain("SUM_POINTS_ASC"); + expect(valueNames).toContain("SUM_POINTS_DESC"); + expect(valueNames).toContain("SUM_GOALS_ASC"); + + // Should NOT have AVERAGE, MIN, MAX, etc. + expect(valueNames).not.toContain("AVERAGE_POINTS_ASC"); + expect(valueNames).not.toContain("MIN_POINTS_ASC"); + expect(valueNames).not.toContain("MAX_POINTS_ASC"); + } + }); + + it("should support multiple per-aggregate opt-ins (sum and average)", async () => { + const { schema } = await getSchemaWithBehavior( + "-attribute:aggregate:groupedAggregates:orderBy +sum:attribute:aggregate:groupedAggregates:orderBy +average:attribute:aggregate:groupedAggregates:orderBy" + ); + const matchStatOrderByType = schema.getType( + "MatchStatGroupedAggregatesOrderBy" + ) as GraphQLEnumType | undefined; + + expect(matchStatOrderByType).toBeDefined(); + if (matchStatOrderByType) { + const values = matchStatOrderByType.getValues(); + const valueNames = values.map((v) => v.name); + + // Should have SUM values + expect(valueNames).toContain("SUM_POINTS_ASC"); + expect(valueNames).toContain("SUM_GOALS_DESC"); + + // Should have AVERAGE values + expect(valueNames).toContain("AVERAGE_POINTS_ASC"); + expect(valueNames).toContain("AVERAGE_GOALS_DESC"); + + // Should NOT have MIN, MAX, etc. + expect(valueNames).not.toContain("MIN_POINTS_ASC"); + expect(valueNames).not.toContain("MAX_POINTS_ASC"); + expect(valueNames).not.toContain("STDDEV_SAMPLE_POINTS_ASC"); + } + }); }); From 2615e6bba26f28b8f126b85ce3c21e3d5c2181a7 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 19:38:37 +0100 Subject: [PATCH 6/7] Fix README example for per-aggregate behavior usage Corrects the per-aggregate behavior example to show that the generic behavior must be disabled first before enabling specific aggregates. Before (incorrect): +sum:... +average:... After (correct): -attribute:aggregate:groupedAggregates:orderBy +sum:... +average:... Without the initial `-attribute:...`, all aggregates would be enabled due to behavior precedence, defeating the purpose of per-aggregate scoping. Adds explanatory note to prevent confusion. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5deca4f..0f16aa4 100644 --- a/README.md +++ b/README.md @@ -537,9 +537,13 @@ COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior +attribute:aggrega Or enable only specific aggregates for a column (e.g., only SUM and AVERAGE): ```sql -COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior +sum:attribute:aggregate:groupedAggregates:orderBy +average:attribute:aggregate:groupedAggregates:orderBy'; +COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior -attribute:aggregate:groupedAggregates:orderBy +sum:attribute:aggregate:groupedAggregates:orderBy +average:attribute:aggregate:groupedAggregates:orderBy'; ``` +Note: When using per-aggregate behaviors, you must first disable the generic +`attribute:aggregate:groupedAggregates:orderBy` behavior to prevent all aggregates +from being enabled. + You also can keep aggregates enabled by default, but disable aggregates for specific tables: From 37797e4d14eae5d03b46a90c6099fa6ff3416498 Mon Sep 17 00:00:00 2001 From: Farid Nouri Neshat Date: Fri, 7 Nov 2025 19:41:48 +0100 Subject: [PATCH 7/7] Fix yarn lint:fix --- README.md | 17 +++++++------ .../groupedAggregatesOrderByBehavior.test.ts | 3 ++- __tests__/helpers.ts | 3 ++- src/AddConnectionGroupedAggregatesPlugin.ts | 9 +++---- src/AddGroupedAggregatesOrderByPlugin.ts | 25 ++++++------------- src/index.ts | 2 +- 6 files changed, 25 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0f16aa4..f405454 100644 --- a/README.md +++ b/README.md @@ -273,14 +273,15 @@ below for details on how to add your own grouping derivatives. The `groupedAggregates` field accepts a few arguments in addition to `groupBy`: -- `orderBy` – controls how groups are sorted. You can order by any aggregate that - appears in the grouped output (e.g. `SUM_POINTS_DESC`). If not specified, results - are ordered by the `groupBy` columns in ascending order for deterministic results. +- `orderBy` – controls how groups are sorted. You can order by any aggregate + that appears in the grouped output (e.g. `SUM_POINTS_DESC`). If not specified, + results are ordered by the `groupBy` columns in ascending order for + deterministic results. - `first` – limit the results to only the first `n` groups. -When using `first`, consider specifying an explicit `orderBy` to control which groups -are returned (e.g., top performers by sum). Without `orderBy`, groups are ordered by -their `groupBy` values. +When using `first`, consider specifying an explicit `orderBy` to control which +groups are returned (e.g., top performers by sum). Without `orderBy`, groups are +ordered by their `groupBy` values. The aggregates supported over groups are the same as over the connection as a whole (see [Aggregates](#aggregates) above), but in addition you may also @@ -541,8 +542,8 @@ COMMENT ON COLUMN my_schema.my_table.my_column IS E'@behavior -attribute:aggrega ``` Note: When using per-aggregate behaviors, you must first disable the generic -`attribute:aggregate:groupedAggregates:orderBy` behavior to prevent all aggregates -from being enabled. +`attribute:aggregate:groupedAggregates:orderBy` behavior to prevent all +aggregates from being enabled. You also can keep aggregates enabled by default, but disable aggregates for specific tables: diff --git a/__tests__/groupedAggregatesOrderByBehavior.test.ts b/__tests__/groupedAggregatesOrderByBehavior.test.ts index 0da5e42..80288ca 100644 --- a/__tests__/groupedAggregatesOrderByBehavior.test.ts +++ b/__tests__/groupedAggregatesOrderByBehavior.test.ts @@ -1,9 +1,10 @@ +import type { GraphQLEnumType } from "graphql"; import { Pool } from "pg"; import { makeSchema } from "postgraphile"; import { makePgService } from "postgraphile/adaptors/pg"; -import type { GraphQLEnumType } from "graphql"; import { PostGraphileAmberPreset } from "postgraphile/presets/amber"; import { PostGraphileConnectionFilterPreset } from "postgraphile-plugin-connection-filter"; + import { PgAggregatesPreset } from "../dist/index.js"; let pool: Pool | undefined; diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index f34655e..f1b5c78 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -51,7 +51,8 @@ export async function getSchema() { ], schema: { // Opt-in to orderBy for grouped aggregates in tests - defaultBehavior: "+resource:groupedAggregates:orderBy +attribute:aggregate:groupedAggregates:orderBy", + defaultBehavior: + "+resource:groupedAggregates:orderBy +attribute:aggregate:groupedAggregates:orderBy", }, }; return await makeSchema(preset); diff --git a/src/AddConnectionGroupedAggregatesPlugin.ts b/src/AddConnectionGroupedAggregatesPlugin.ts index d83a554..50153de 100644 --- a/src/AddConnectionGroupedAggregatesPlugin.ts +++ b/src/AddConnectionGroupedAggregatesPlugin.ts @@ -154,7 +154,8 @@ const Plugin: GraphileConfig.Plugin = { [] ), }, - ...(TableGroupedOrderByType && isValidEnum(build, TableGroupedOrderByType) + ...(TableGroupedOrderByType && + isValidEnum(build, TableGroupedOrderByType) ? { orderBy: { type: new GraphQLList( @@ -186,11 +187,7 @@ const Plugin: GraphileConfig.Plugin = { ), applyPlan: EXPORTABLE( () => - function ( - _$parent, - $pgSelect: PgSelectStep, - arg - ) { + function (_$parent, $pgSelect: PgSelectStep, arg) { $pgSelect.setFirst(arg.getRaw()); }, [] diff --git a/src/AddGroupedAggregatesOrderByPlugin.ts b/src/AddGroupedAggregatesOrderByPlugin.ts index 2c50e6c..050bc30 100644 --- a/src/AddGroupedAggregatesOrderByPlugin.ts +++ b/src/AddGroupedAggregatesOrderByPlugin.ts @@ -1,12 +1,8 @@ -import type { - PgCodecAttribute, - PgResource, - PgSelectQueryBuilder, -} from "@dataplan/pg"; +import type { PgCodecAttribute, PgSelectQueryBuilder } from "@dataplan/pg"; import type { GraphQLEnumValueConfigMap } from "graphql"; -import type { AggregateSpec } from "./interfaces.js"; import { EXPORTABLE } from "./EXPORTABLE.js"; +import type { AggregateSpec } from "./interfaces.js"; const { version } = require("../package.json"); @@ -108,12 +104,7 @@ const Plugin: GraphileConfig.Plugin = { }, GraphQLEnumType_values(values, build, context) { - const { - extend, - inflection, - sql, - pgAggregateSpecs, - } = build; + const { extend, inflection, sql, pgAggregateSpecs } = build; const { scope: { isPgAggregateGroupedOrderByEnum, pgTypeResource: resource }, } = context; @@ -149,11 +140,11 @@ const Plugin: GraphileConfig.Plugin = { EXPORTABLE( ( aggregateSpec, - attributeName, attributeCodec, + attributeName, + direction, sql, - targetCodec, - direction + targetCodec ) => function apply($pgSelect: PgSelectQueryBuilder) { const fragment = aggregateSpec.sqlAggregateWrap( @@ -168,11 +159,11 @@ const Plugin: GraphileConfig.Plugin = { }, [ aggregateSpec, - attributeName, attributeCodec, + attributeName, + direction, sql, targetCodec, - direction, ] ); diff --git a/src/index.ts b/src/index.ts index 37a8d9a..b8183ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import { PgAggregatesAddAggregateTypesPlugin } from "./AddAggregateTypesPlugin.js"; import { PgAggregatesAddConnectionAggregatesPlugin } from "./AddConnectionAggregatesPlugin.js"; import { PgAggregatesAddConnectionGroupedAggregatesPlugin } from "./AddConnectionGroupedAggregatesPlugin.js"; -import { PgAggregatesAddGroupedAggregatesOrderByPlugin } from "./AddGroupedAggregatesOrderByPlugin.js"; import { PgAggregatesAddGroupByAggregateEnumsPlugin } from "./AddGroupByAggregateEnumsPlugin.js"; import { PgAggregatesAddGroupByAggregateEnumValuesForAttributesPlugin } from "./AddGroupByAggregateEnumValuesForAttributesPlugin.js"; +import { PgAggregatesAddGroupedAggregatesOrderByPlugin } from "./AddGroupedAggregatesOrderByPlugin.js"; import { PgAggregatesAddHavingAggregateTypesPlugin } from "./AddHavingAggregateTypesPlugin.js"; import { PgAggregatesSpecsPlugin } from "./AggregateSpecsPlugin.js"; import { PgAggregatesSmartTagsPlugin } from "./AggregatesSmartTagsPlugin.js";