diff --git a/.changeset/28690-preview-time-buckets-filter-columns-fk-id.md b/.changeset/28690-preview-time-buckets-filter-columns-fk-id.md new file mode 100644 index 00000000000..28589caae5c --- /dev/null +++ b/.changeset/28690-preview-time-buckets-filter-columns-fk-id.md @@ -0,0 +1,16 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): the draft preview groups a bucketed time dimension and reads a qualified filter member as the live faces do, and the ObjectQL strategy serves a backed `RELATION.id` dimension + +Clause-②: yes (widening) +Metadata-protocol: none + +Three answers `queryDataset` gave differently depending on the face, measured on real SQLite: + +- **A bucketed time dimension in the draft preview.** A `timeDimensions` entry with a `granularity` is now grouped by its bucket whether or not the member is also in `dimensions`, and the bucket is a column of the answer and of `fields` (type `time` for a `date` dimension). This is the set both live strategies project. Before, the preview grouped by `dimensions` alone: one total where the live faces answered one row per month, regions without months, and a windowed `year` bucket with no key. +- **A filter member spelled other than its column, in the draft preview.** A `runtimeFilter` (or dataset filter) member such as `OBJECT.region`, `CUBE.region` or a dataset dimension `zone` declared over `region` is now read from the column it names, with that column's declared type, through the member resolution the live strategies use. Before, the preview matched no drafted row: `[]`, or under an `$or` only the other arm's rows, where both live faces served the rows. +- **A backed `RELATION.id` dimension on the ObjectQL strategy.** `account.id` (with `include: ['account']`), and two dimensions through one relationship (`account.id` beside `account.name`, or the FK column `account` beside `account.name`), are now served with the native strategy's groups. Before, the strategy handed the engine a `groupBy` naming one column twice and answered `INVALID_QUERY` / 400. The engine's duplicate-column refusal is unchanged; the strategy now names each column once. + +Nothing is refused that was served, and no export, key, error code or status is added. The draft preview still refuses a relationship-path member (#28555). diff --git a/packages/services/service-analytics/src/__tests__/preview-time-filter-fk-parity.test.ts b/packages/services/service-analytics/src/__tests__/preview-time-filter-fk-parity.test.ts new file mode 100644 index 00000000000..2ac57e3e13b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-time-filter-fk-parity.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28690, #28691, #28692] Three answers the faces of `queryDataset` gave + * differently, on the native strategy, the ObjectQL strategy and the draft + * preview, on real SQLite (the #28201 / #28258 / #28555 harness). + * + * ## What these pins hold + * + * - #28690. A `timeDimensions` entry with a granularity is grouped by its + * bucket whether or not the member is also in `dimensions`, on every face, + * with the bucket in `fields`. Before, the preview grouped by `dimensions` + * alone: one total where the live faces answered one group per month, and + * regions without months. + * - #28691. A `runtimeFilter` member spelled other than its column + * (`OBJECT.region`, `CUBE.region`, a dataset dimension `zone` over + * `region`, `OBJECT.closed_at`) is read from that column by the preview, as + * both live strategies read it. Before, the preview matched no drafted row + * (`[]`, or under an `$or` only the other arm's rows). + * - #28692. The ObjectQL strategy answers a backed `account.id` dimension, and + * two dimensions through one relationship, with the native strategy's + * groups. Before, it handed the engine a groupBy naming one column twice and + * answered `INVALID_QUERY`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'os28690_deal'; +const ACCOUNT = 'os28690_account'; + +const ACCOUNTS = [ + { id: 'a1', name: 'Acme' }, + { id: 'a2', name: 'Globex' }, +] as const; +const ROWS = [ + { id: 'd1', region: 'amer', amount: 100, account: 'a1', closed_at: '2026-03-01T00:00:00.000Z' }, + { id: 'd2', region: 'amer', amount: 50, account: 'a2', closed_at: '2026-04-01T00:00:00.000Z' }, + { id: 'd3', region: 'emea', amount: 30, account: 'a1', closed_at: '2026-04-02T00:00:00.000Z' }, +] as const; + +const shape = { + label: 'Deals', + object: OBJECT, + dimensions: [ + { name: 'region', field: 'region', type: 'string' }, + { name: 'closed_at', field: 'closed_at', type: 'date' }, + { name: 'zone', field: 'region', type: 'string' }, + ], + measures: [{ name: 'total', aggregate: 'sum', field: 'amount' }], +}; +/** Declares no relationship. */ +const UNBACKED = DatasetSchema.parse({ name: 'os28690_deals', ...shape }); +/** Declares `account`. */ +const BACKED = DatasetSchema.parse({ name: 'os28690_deals_by_account', include: ['account'], ...shape }); + +type Face = 'native' | 'objectql' | 'preview'; +const FACES: readonly Face[] = ['native', 'objectql', 'preview']; +const LIVE: readonly Face[] = ['native', 'objectql']; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + +type Selection = { + dimensions?: string[]; + measures: string[]; + runtimeFilter?: Record; + timeDimensions?: Array>; +}; +type Answer = { rows: Array>; fields: string[] }; + +/** A `'protocol'` service answering one pending seed draft, which routes `previewDrafts` onto the preview path. */ +const pendingSeedProtocol = { + getMetaItems: async () => [{ name: 'os28690_seed', object: OBJECT }], + getMetaItem: async () => ({ item: { records: ROWS } }), +}; + +describe('[#28690–#28692] time-dimension buckets, column-spelled filter members and FK-id groups on every face', () => { + let engine: ObjectQL; + const services: Partial> = {}; + + const ask = (face: Face, dataset: typeof UNBACKED, selection: Selection) => + services[face === 'preview' ? 'objectql' : face]!.queryDataset( + dataset, + { ...selection } as never, + undefined, + face === 'preview' ? { previewDrafts: true } : undefined, + ); + + /** One face's rows (measures as numbers, sorted) and its `fields` as `name:type`. */ + const answerOf = async (face: Face, dataset: typeof UNBACKED, selection: Selection): Promise => { + const result = await ask(face, dataset, selection); + const rows = (result.rows as Array>) + .map((r) => ({ ...r, total: Number(r.total) })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + return { rows, fields: (result.fields ?? []).map((f: { name: string; type: string }) => `${f.name}:${f.type}`) }; + }; + + /** Every face in `faces` answers `expected`, rows and fields alike. */ + const answeredAlike = async (faces: readonly Face[], dataset: typeof UNBACKED, selection: Selection, expected: Answer) => { + for (const face of faces) { + expect(await answerOf(face, dataset, selection), face).toEqual(expected); + } + }; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); + engine = new ObjectQL({ logger: quiet } as never); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registry.registerObject({ + name: ACCOUNT, + label: 'Account', + fields: { name: { name: 'name', type: 'text' } }, + } as never); + engine.registry.registerObject({ + name: OBJECT, + label: 'Deal', + fields: { + region: { name: 'region', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + closed_at: { name: 'closed_at', type: 'datetime' }, + account: { name: 'account', type: 'lookup', reference: ACCOUNT }, + }, + } as never); + await engine.syncSchemas(); + await installDefaultOrganization(engine as never); + for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as never); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); + + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + const provided: Record = { data: engine, protocol: pendingSeedProtocol }; + await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({ + getService: (name: string) => provided[name] ?? registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + describe('#28690 — a bucketed time dimension is grouped whether or not it is also a dimension', () => { + it('groups by month with no dimensions, where the preview answered one total', async () => { + await answeredAlike(FACES, UNBACKED, { measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] }, { + rows: [{ closed_at: '2026-03', total: 100 }, { closed_at: '2026-04', total: 80 }], + fields: ['closed_at:time', 'total:number'], + }); + }); + + it('groups by region and month, where the preview grouped by region alone', async () => { + await answeredAlike( + FACES, + UNBACKED, + { dimensions: ['region'], measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] }, + { + rows: [ + { region: 'amer', closed_at: '2026-03', total: 100 }, + { region: 'amer', closed_at: '2026-04', total: 50 }, + { region: 'emea', closed_at: '2026-04', total: 30 }, + ], + fields: ['region:string', 'closed_at:time', 'total:number'], + }, + ); + }); + + it('keys an OBJECT-qualified time dimension by its spelling, as the live faces do', async () => { + const member = `${OBJECT}.closed_at`; + await answeredAlike(FACES, UNBACKED, { measures: ['total'], timeDimensions: [{ dimension: member, granularity: 'month' }] }, { + rows: [{ [member]: '2026-03', total: 100 }, { [member]: '2026-04', total: 80 }], + fields: [`${member}:time`, 'total:number'], + }); + }); + + it('buckets a windowed year, where the preview answered the total with no key', async () => { + await answeredAlike( + FACES, + UNBACKED, + { measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'year', dateRange: ['2026-04-01', '2026-04-30'] }] }, + { rows: [{ closed_at: '2026', total: 80 }], fields: ['closed_at:time', 'total:number'] }, + ); + }); + + it('still projects nothing for a time dimension with no granularity (a window only)', async () => { + await answeredAlike( + FACES, + UNBACKED, + { measures: ['total'], timeDimensions: [{ dimension: 'closed_at', dateRange: ['2026-04-01', '2026-04-30'] }] }, + { rows: [{ total: 80 }], fields: ['total:number'] }, + ); + }); + }); + + describe('#28691 — a filter member spelled other than its column is read from that column', () => { + const emeaOnly: Answer = { rows: [{ region: 'emea', total: 30 }], fields: ['region:string', 'total:number'] }; + const amerAprilAndEmea: Answer = { + rows: [{ region: 'amer', total: 50 }, { region: 'emea', total: 30 }], + fields: ['region:string', 'total:number'], + }; + + it.each([ + ['OBJECT.region', `${OBJECT}.region`], + ['CUBE.region', `${UNBACKED.name}.region`], + ['the dimension zone over region', 'zone'], + ])('matches %s, where the preview answered []', async (_label, member) => { + await answeredAlike(FACES, UNBACKED, { dimensions: ['region'], measures: ['total'], runtimeFilter: { [member]: 'emea' } }, emeaOnly); + }); + + it("matches OBJECT.region's arm under an $or, where the preview answered only the other arm's rows", async () => { + await answeredAlike( + FACES, + UNBACKED, + { dimensions: ['region'], measures: ['total'], runtimeFilter: { $or: [{ [`${OBJECT}.region`]: { $in: ['emea'] } }, { amount: 50 }] } }, + amerAprilAndEmea, + ); + }); + + it('reads an OBJECT-qualified datetime member as the datetime column, where the preview answered []', async () => { + await answeredAlike( + FACES, + UNBACKED, + { dimensions: ['region'], measures: ['total'], runtimeFilter: { [`${OBJECT}.closed_at`]: { $gte: '2026-04-01' } } }, + amerAprilAndEmea, + ); + }); + + it("compares an OBJECT-qualified datetime member's instants, as the column's declaration reads them", async () => { + // 2026-04-02T01:00+02:00 is 2026-04-01T23:00Z, so d3 (2026-04-02T00:00Z) + // is after it. Compared as text, as an undeclared column would be, it is not. + await answeredAlike( + FACES, + UNBACKED, + { dimensions: ['region'], measures: ['total'], runtimeFilter: { [`${OBJECT}.closed_at`]: { $gte: '2026-04-02T01:00:00+02:00' } } }, + { rows: [{ region: 'emea', total: 30 }], fields: ['region:string', 'total:number'] }, + ); + }); + + it('filters on zone beside a time-dimension-only bucket', async () => { + await answeredAlike( + FACES, + UNBACKED, + { measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }], runtimeFilter: { zone: 'amer' } }, + { rows: [{ closed_at: '2026-03', total: 100 }, { closed_at: '2026-04', total: 50 }], fields: ['closed_at:time', 'total:number'] }, + ); + }); + }); + + describe('#28692 — the ObjectQL strategy groups a backed FK path once', () => { + it("serves a backed 'account.id' with the native strategy's groups, where it answered INVALID_QUERY", async () => { + await answeredAlike(LIVE, BACKED, { dimensions: ['account.id'], measures: ['total'] }, { + rows: [{ 'account.id': 'a1', total: 130 }, { 'account.id': 'a2', total: 50 }], + fields: ['account.id:string', 'total:number'], + }); + }); + + it("serves 'account.id' beside 'account.name', where it answered INVALID_QUERY", async () => { + await answeredAlike(LIVE, BACKED, { dimensions: ['account.id', 'account.name'], measures: ['total'] }, { + rows: [ + { 'account.id': 'a1', 'account.name': 'Acme', total: 130 }, + { 'account.id': 'a2', 'account.name': 'Globex', total: 50 }, + ], + fields: ['account.id:string', 'account.name:string', 'total:number'], + }); + }); + + it.each([ + ['after', ['account.name', 'account']], + ['before', ['account', 'account.name']], + ])("serves the FK column 'account' %s 'account.name'", async (_label, dimensions) => { + await answeredAlike(LIVE, BACKED, { dimensions, measures: ['total'] }, { + rows: [ + { account: 'a1', 'account.name': 'Acme', total: 130 }, + { account: 'a2', 'account.name': 'Globex', total: 50 }, + ], + fields: [...dimensions.map((d) => `${d}:string`), 'total:number'], + }); + }); + + it('leaves the draft preview refusing a backed relationship path (#28555)', async () => { + await expect(ask('preview', BACKED, { dimensions: ['account.id'], measures: ['total'] })).rejects.toMatchObject({ + code: 'INVALID_FIELD', + status: 400, + member: 'account.id', + }); + }); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 6868a45a042..057ef2cfbb9 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -2895,6 +2895,9 @@ export class AnalyticsService implements IAnalyticsService { seedRows!, (field) => this.sourceFieldMeta?.(dataset.object, field)?.type, compiled.durations, + // [#28691] …and each `where` member the live faces read from a + // column spelled otherwise is read from that column. + previewWhereColumns(q, cube), ); }, } as IAnalyticsService; @@ -4777,6 +4780,43 @@ function previewEvaluationCube(query: AnalyticsQuery, cube: Cube): Cube { return Object.keys(aliases).length > 0 ? { ...cube, dimensions: { ...cube.dimensions, ...aliases } } : cube; } +/** + * [#28691] The column each `where` member resolves to, for every member + * spelled other than its column, which the draft-preview evaluator matches + * against the drafted rows (they carry columns). `OBJECT.region`, + * `CUBE.region` and a dataset dimension `zone` over `region` all read + * `region`, as both live strategies read them. Measured before: each matched + * no drafted row (`[]`, or under an `$or` only the other arm's rows) where + * both live faces served the rows. + * + * The leaves are read with the reader the strategies compile with + * (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`, as + * {@link relationshipMembers} reads them), and each is resolved by + * {@link declaredMemberEntry}'s `'any'` arm, the strategies' filter-member + * lookup. A member that resolves to no declared entry, or to an expression or + * a relationship path, is not listed: it is read as written, or was refused + * before the evaluator runs. + */ +function previewWhereColumns(query: AnalyticsQuery, cube: Cube): Record { + const where = (query as { where?: unknown }).where; + if (!where || typeof where !== 'object') return {}; + let leaves: string[]; + try { + leaves = collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member); + } catch { + return {}; + } + const out: Record = {}; + for (const member of leaves) { + if (out[member] !== undefined) continue; + const entry = declaredMemberEntry(cube, member, 'any'); + if (!entry) continue; + const column = typeof entry.sql === 'string' ? entry.sql.trim() : entry.key; + if (column !== member && BARE_IDENTIFIER.test(column)) out[member] = column; + } + return out; +} + /** * [#28172] The measures `measures` names that `cube` (registered as * `cubeName`) does not declare, minted diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 8612d8e00a8..c0eb3c39f2b 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -47,6 +47,7 @@ import { dateRangeWindowBounds } from './dataset-executor.js'; import { DATASET_DURATION_UNIT_SECONDS } from '@objectstack/spec/ui'; import { durationMeasureIn } from './duration-measure-sql.js'; import { invalidMemberError } from './dataset-refusal.js'; +import { projectedDimensions } from './order-key-door.js'; import type { DurationMeasureSpec } from './strategies/types.js'; // [#19810] The `where` door's refusal envelope — `INVALID_FILTER` / 400, // EXPORTED by `filter-normalizer` precisely so a sibling in this package cannot @@ -779,6 +780,14 @@ function undeclaredPreviewMeasureError(member: string, cube: Cube): Error { * for the shared lowering; the production caller always passes it. A caller * that passes none cannot read declarations, and every column then reads * type-blind (ADR-0053 D-D1 item 7). + * + * `whereColumns` maps a `where` member to the column the live strategies + * resolve it to, for each member spelled other than its column + * (`OBJECT.region`, a dataset dimension `zone` over `region`). The rows carry + * columns, so the match reads each such member from its column, and the + * declared type is the column's (#28691). The service builds it with the + * member resolution the strategies use; a member it does not list is read as + * written. */ export function evaluateAnalyticsQueryOverRows( query: AnalyticsQuery, @@ -786,8 +795,18 @@ export function evaluateAnalyticsQueryOverRows( rows: Row[], declaredType?: (field: string) => string | undefined, durations?: Readonly>, + whereColumns?: Readonly>, ): AnalyticsResult { - const lowering = declaredPreviewLowering(declaredType); + const aliased = Object.entries(whereColumns ?? {}); + const whereType = + declaredType && aliased.length > 0 ? (field: string) => declaredType(whereColumns![field] ?? field) : declaredType; + const whereRow = (r: Row): Row => { + if (aliased.length === 0) return r; + const out: Row = { ...r }; + for (const [member, column] of aliased) out[member] = r[column]; + return out; + }; + const lowering = declaredPreviewLowering(whereType); // 1. Row-level filters: `where`, then timeDimension dateRanges. // [#19810] The operator vocabulary is decided BEFORE the rows are read, so an // unevaluable predicate refuses over an empty seed draft too — see @@ -822,9 +841,12 @@ export function evaluateAnalyticsQueryOverRows( assertPreviewCanEvaluate(lowered); // [#21505] …then each value comparison in the temporal STORAGE form, the // comparand here and the row value at the match (see {@link previewTemporalKind}). + const whereKindOf = previewTemporalKind(whereType); + const where = previewStorageComparands(lowered, whereKindOf); + let filtered = rows.filter((r) => matchesWhere(previewStorageRow(whereRow(r), whereKindOf), where)); + // The time window below names columns, so it reads the declarations as written. + const windowLowering = declaredPreviewLowering(declaredType); const kindOf = previewTemporalKind(declaredType); - const where = previewStorageComparands(lowered, kindOf); - let filtered = rows.filter((r) => matchesWhere(previewStorageRow(r, kindOf), where)); const timeDims = query.timeDimensions ?? []; for (const td of timeDims) { const dim = cube.dimensions?.[td.dimension]; @@ -849,12 +871,18 @@ export function evaluateAnalyticsQueryOverRows( // days it covers in the reference zone — the pair `dateRangeWindowBounds` // spells for the published faces, so the draft counts the published days. const bounds = dateRangeWindowBounds({ start, end, endExclusive }, query.timezone, declaredType?.(field) === 'date'); - const window = previewStorageComparands(lowerFilterCondition({ [field]: bounds }, lowering), kindOf); + const window = previewStorageComparands(lowerFilterCondition({ [field]: bounds }, windowLowering), kindOf); filtered = filtered.filter((r) => matchesWhere(previewStorageRow(r, kindOf), window)); } // 2. Grouping keys: each selected dimension (time dims bucketed). - const dimensions = query.dimensions ?? []; + // [#28690] The set the live strategies project — every `dimensions` entry, + // then every bucketed `timeDimensions` entry not already one of them — from + // the one definition they read (`projectedDimensions`). Reading + // `query.dimensions` alone dropped a bucketed time dimension the caller did + // not also list there: the draft answered one total (or regions without + // months) where both live faces answered one group per bucket. + const dimensions = projectedDimensions(query); const timezone = query.timezone; // ADR-0053 Phase 2: reference tz for bucketing const granByDim = new Map(timeDims.filter((t) => t.granularity).map((t) => [t.dimension, t.granularity!])); const keyOf = (r: Row): { key: string; values: Row } => { diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index d9cce6dbb10..c5327455533 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -1340,12 +1340,20 @@ export class ObjectQLStrategy implements AnalyticsStrategy { for (const dim of query.dimensions ?? []) { const cd = crossByDim.get(dim); if (cd) { - groupBy.push(cd.fkField); + // [#28692] Once per FK column: two dimensions through one relationship + // (`account.id` and `account.name`, or `account` beside `account.name`) + // share it, and the engine refuses a groupBy naming a column twice + // (`INVALID_QUERY`), where the native strategy served the groups. The + // re-bucket reads the one column for each dimension. + if (!groupBy.includes(cd.fkField)) groupBy.push(cd.fkField); continue; } const field = this.resolveFieldName(cube, dim, 'dimension'); const gran = granByDim.get(dim); - groupBy.push(gran ? { field, dateGranularity: gran } : field); + // …and a base dimension over that FK column (`account` after + // `account.name`) reads the one already grouped. + const fkGrouped = !gran && groupBy.includes(field) && plan.crossDims.some((c) => c.fkField === field); + if (!fkGrouped) groupBy.push(gran ? { field, dateGranularity: gran } : field); baseDimFields.push(field); granByDim.delete(dim); } @@ -1454,7 +1462,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { if (scope != null) markFilterSubtreeProvenance(scope, 'policy'); const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; const rows = await ctx.executeAggregate(refObject, { - groupBy: ['id', attr], + // [#28692] `.id` resolves the FK to the related record's own + // id: `id` is already the key, and naming it twice is a groupBy the + // engine refuses (`INVALID_QUERY`). + groupBy: attr === 'id' ? ['id'] : ['id', attr], aggregations: [{ field: 'id', method: 'count', alias: '_c' }], filter, context: ctx.context,