diff --git a/README.md b/README.md index 896564a..f405454 100644 --- a/README.md +++ b/README.md @@ -271,15 +271,32 @@ 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`). 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. + 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 +324,8 @@ query AverageGoalsOnDaysWithAveragePointsOver200 { byDay: groupedAggregates( groupBy: [CREATED_AT_TRUNCATED_TO_DAY] having: { average: { points: { greaterThan: 200 } } } + orderBy: [AVERAGE_GOALS_DESC] + first: 3 ) { keys average { @@ -458,6 +477,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. @@ -494,6 +523,28 @@ 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 -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: diff --git a/__tests__/__snapshots__/schema.test.ts.snap b/__tests__/__snapshots__/schema.test.ts.snap index 2e57c9e..939aeb8 100644 --- a/__tests__/__snapshots__/schema.test.ts.snap +++ b/__tests__/__snapshots__/schema.test.ts.snap @@ -303,6 +303,12 @@ 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 + """Conditions on the grouped aggregates.""" having: FilmHavingInput ): [FilmAggregates!] @@ -392,6 +398,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 +1700,12 @@ 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 + """Conditions on the grouped aggregates.""" having: MatchStatHavingInput ): [MatchStatAggregates!] @@ -1731,6 +1821,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 +2733,12 @@ 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 + """Conditions on the grouped aggregates.""" having: PlayerHavingInput ): [PlayerAggregates!] @@ -2570,6 +2798,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 +3781,12 @@ 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 + """Conditions on the grouped aggregates.""" having: ViewMatchStatHavingInput ): [ViewMatchStatAggregates!] @@ -3644,6 +3902,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__/groupedAggregatesOrderByBehavior.test.ts b/__tests__/groupedAggregatesOrderByBehavior.test.ts new file mode 100644 index 0000000..80288ca --- /dev/null +++ b/__tests__/groupedAggregatesOrderByBehavior.test.ts @@ -0,0 +1,164 @@ +import type { GraphQLEnumType } from "graphql"; +import { Pool } from "pg"; +import { makeSchema } from "postgraphile"; +import { makePgService } from "postgraphile/adaptors/pg"; +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"); + } + }); + + 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"); + } + }); +}); diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 5678415..f1b5c78 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -49,6 +49,11 @@ 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/__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 new file mode 100644 index 0000000..2a6a52d --- /dev/null +++ b/__tests__/queries/__snapshots__/groupedAggregatesOrderBy.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupedAggregatesOrderBy: result 1`] = ` +{ + "allMatchStats": { + "top": [ + { + "keys": [ + "1", + ], + "sum": { + "points": "2175", + }, + }, + { + "keys": [ + "2", + ], + "sum": { + "points": "1771", + }, + }, + ], + }, +} +`; + +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 __match_stats__."player_id" asc, 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..cc29457 --- /dev/null +++ b/__tests__/queries/groupedAggregatesOrderBy.test.ts @@ -0,0 +1,21 @@ +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 + } + } + } + } + `) +); diff --git a/src/AddConnectionGroupedAggregatesPlugin.ts b/src/AddConnectionGroupedAggregatesPlugin.ts index 52ef3ed..50153de 100644 --- a/src/AddConnectionGroupedAggregatesPlugin.ts +++ b/src/AddConnectionGroupedAggregatesPlugin.ts @@ -120,6 +120,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; @@ -151,6 +154,45 @@ 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()); + }, + [] + ), + }, ...(TableHavingInputType ? { having: { 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", }); }, [ diff --git a/src/AddGroupedAggregatesOrderByPlugin.ts b/src/AddGroupedAggregatesOrderByPlugin.ts new file mode 100644 index 0000000..050bc30 --- /dev/null +++ b/src/AddGroupedAggregatesOrderByPlugin.ts @@ -0,0 +1,248 @@ +import type { PgCodecAttribute, PgSelectQueryBuilder } from "@dataplan/pg"; +import type { GraphQLEnumValueConfigMap } from "graphql"; + +import { EXPORTABLE } from "./EXPORTABLE.js"; +import type { AggregateSpec } from "./interfaces.js"; + +const { version } = require("../package.json"); + +declare global { + namespace GraphileBuild { + 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; + } + interface ScopeEnumValues { + isPgAggregateGroupedOrderByEnum?: boolean; + } + } +} + +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 (for all aggregates)?", + entities: ["pgCodecAttribute"], + }, + }, + }, + + entityBehavior: { + pgResource: "-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 ( + resource.parameters || + !resource.codec.attributes || + resource.isUnique + ) { + continue; + } + if ( + !build.behavior.pgResourceMatches( + resource, + "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 }), + { + 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; + } + + 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, + attributeCodec, + attributeName, + direction, + sql, + targetCodec + ) => + function apply($pgSelect: PgSelectQueryBuilder) { + const fragment = aggregateSpec.sqlAggregateWrap( + sql`${$pgSelect.alias}.${sql.identifier(attributeName)}`, + attributeCodec + ); + $pgSelect.orderBy({ + fragment, + codec: targetCodec, + direction, + }); + }, + [ + aggregateSpec, + attributeCodec, + attributeName, + direction, + sql, + targetCodec, + ] + ); + + 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; + } + + // Check if this specific aggregate's orderBy is enabled for this attribute + if ( + !build.behavior.pgCodecAttributeMatches( + [resource.codec, attributeName], + `${aggregateSpec.id}: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..b8183ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { PgAggregatesAddConnectionAggregatesPlugin } from "./AddConnectionAggreg import { PgAggregatesAddConnectionGroupedAggregatesPlugin } from "./AddConnectionGroupedAggregatesPlugin.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"; @@ -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