Skip to content

Commit 7a04ba2

Browse files
fix(objectql,metadata-protocol): refuse aggregate queries that answered a wrong table, and an empty aggregate list (#25935)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XW13v6b99WMmzHuTFCwjmae)_ Fixes #25818 Fixes #25819 Clause-②: no Metadata-protocol: none Ruling-ref: 6399831537 The aggregate door answered three malformed queries with a plausible wrong table under 200, and an empty `aggregations` list with `404 OBJECT_NOT_FOUND` for a registered object. All four now answer 400, naming the position. The claim is `6407789444`; the seat claim on #25819, `6408744205`, names the same branch. Branch `claude/project-thread-6vh3z0` is the card thread's harness-pinned branch: one commit, `46a0a7df`, on `main` `cb0a2fcf4f`. ## What changes **#25818, at the engine's `aggregate` entry** (`packages/objectql/src/aggregate-query-shape-door.ts`, called from `engine.ts` right after the structured-JSON group door and the aggregate × field-type door): | query | before (`main` `cb0a2fcf4f`, through `findData`) | now | |:--|:--|:--| | `groupBy: [{ field: 'name', dateGranularity: 'month' }]` on a `text` field | `200 [{ name: null, n: 3 }]` | `400 INVALID_FIELD`, `path: groupBy.0.dateGranularity` | | the same on a `number` field | `200 [{ amount: '1970-01', n: 3 }]` | `400 INVALID_FIELD` | | the same on a `time` field | `200 [{ at_time: null, n: 3 }]` | `400 INVALID_FIELD` | | `aggregations: [{ count, alias: 'n' }, { sum amount, alias: 'n' }]` | `200 [{ n: 60 }]` | `400 INVALID_QUERY`, `path: aggregations.1.alias` | | `groupBy: ['status'], aggregations: [{ count, alias: 'status' }]` | `200 [{ status: 2 }, { status: 1 }]` | `400 INVALID_QUERY`, `path: aggregations.0.alias` | | two group entries projecting one column (`opened_on` by month and by year) | `200 [{ opened_on: '2026', n: 1 }, { opened_on: '2026', n: 2 }]` | `400 INVALID_QUERY`, `path: groupBy.1.field` | - **"A date field" is the declared type's class**, read by `declaredFieldClasses` (`having-filter.ts`), which is the reading `in-memory-aggregation.ts`'s `calendarDayGroupFields` buckets a `date` field by. A `date` (`CALENDAR_DATE_TYPES`) and a `datetime` (`INSTANT_TYPES`) pass. A field the declaration cannot type is not judged: undeclared (the ingress door answers an unknown one first; a system column the map does not declare buckets as before), a `formula`, or a registry-less host. `driver-sql`'s pushdown reads its own column registry (`temporalFieldKind`), so the engine door judges before either face. - **A column name** is a group entry's name, or `alias ?? field` for the object form (the name `applyInMemoryAggregation` and `driver-sql` project), and an aggregation's `alias`. An alias moves the group column, so `groupBy: [{ field: 'status', alias: 's' }]` with an aggregation aliased `status` is served. - **Every door agrees** because the check sits in `engine.aggregate`: the REST query route and its dispatcher twin (both through `findData`), the analytics ObjectQL strategy, the MCP data bridge, a flow or a hook. The engine's `INVALID_FIELD` / `INVALID_QUERY` with `status: 400` reaches the wire through `mapDataError` unchanged (pinned below). - The two group entries projecting one column is the same defect class as the alias collision (one row column, two values) and closes in the same walk, so it rides here as a bounded in-place fix (same class, mechanical, same files, same gate family). Its base answer is the last row of the table above. **#25819, at `findData`** (`packages/metadata-protocol/src/protocol.ts`, after `assertAggregationFieldsExist`): an aggregate axis sent as an empty list, with no non-empty axis beside it, answers `400 INVALID_QUERY`, naming `'query.aggregations'` (or `'query.groupBy'`, or both) as empty. `{ groupBy: ['status'], aggregations: [] }` and `{ groupBy: [], aggregations: [count] }` are served as before. - **Where the 404 came from** (measured, the card had not traced it): the routing read `[]` as "no aggregate" and handed the key to `engine.find`, whose unknown-option refusal (`engine.ts:928` on base, called from `find` at `:15190`) is an uncoded `Error` reading "find('acct') does not recognise option 'aggregations'…". The REST mapping's unknown-object limb (`packages/types/src/data-error-classification.ts:2034`: the quoted object name plus `not`) read that as `404 OBJECT_NOT_FOUND`, "Object 'acct' is not registered". - **What `[]` means on each door.** REST and dispatcher (through `findData`): the 404 above, now 400. `{ groupBy: [] }` alone took the same path to the same 404, so the refusal covers it too. `engine.aggregate` called directly with neither axis returns the rows unchanged (the in-memory aggregator's documented no-axis answer); this PR does not change the engine door (see Acceptance notes). - **No shipped caller sends `[]` meaning plain rows**, so the ruling's default holds. Measured producers: `objectAggregateSpecQuery` (`@object-ui/core`) always sends one node; `compileListViewGroupQuery` (`@objectstack/spec/ui`) always leads with the `count` node; `ObjectStackAdapter.aggregate` forwards its caller's list; the analytics strategy calls `engine.aggregate`, not `findData`. `protocol.ts` hunk: `@@ -13435,0 +13436,31 @@`, inside `findData`. It does not overlap #25826 (lines 9021–9060) or #25467 cut 12 (23440–24900). ## Tests - `packages/objectql/src/engine-aggregate-query-shape-door.test.ts` (7 tests): every refusal asserts `code`, `status` and `httpStatus`, the `path` and `param`, and that no driver read happened. Controls: `date` and `datetime` buckets with their real buckets, a `formula` and an undeclared bucket, an alias that frees the field name, two buckets of one field under their own aliases. A REST-parity block runs `findData` and `mapDataError` for the three card shapes and #25819's empty list (wire `400`, never `404`). - `packages/metadata-protocol/src/protocol.empty-aggregate-axis.test.ts` (6 tests): four refused shapes (`code`, `status`, `param`, `object`, engine never asked), plus the served controls. - **Red first.** With `engine.ts` and `protocol.ts` reverted to `main` (tree-only, restored from saved copies, the diff back to 39 lines before the commit): objectql suite 7 of 7 failed (the control's failure was a read count in the test, corrected); metadata-protocol suite 4 failed, 2 controls passed. With the fix: 7/7 and 6/6. All at `46a0a7dff1`: - `pnpm --filter @objectstack/metadata-protocol test` then `typecheck`: lock `VERDICT command-exit 0`, Test Files 264 passed | 3 skipped, Tests 21496 passed | 19 skipped. - `pnpm --filter @objectstack/objectql test` then `typecheck`: `VERDICT command-exit 0`, Test Files 468 passed, Tests 8725 passed. - Consumer spot-check (not owed; the engine refuses shapes it used to serve): the 31 `@objectstack/rest` test files that name `aggregations` or `dateGranularity` (31 passed, 516 tests) and `pnpm --filter @objectstack/service-analytics test` (185 files passed, 4517 tests). - `eslint --no-inline-config packages/objectql packages/metadata-protocol`: exit 0. - Derived gates: `dispatch-gates --commands` derived 73 families; all 73 ran with exit 0, reconciled by `--ran` (73 run, 0 NOT-MEASURED). Two needed their prerequisite first, then re-ran green: `check-engine-split-ratio --days 90` (exit 2 on the shallow clone; after `git fetch --shallow-since=2026-07-21`) and `check:dual-build-cjs-loads` (exit 3 before a full `turbo run build`, 121 of 121 tasks). - Dogfood: n/a (no `!` in the title, no narrowing arm). ## Acceptance notes - **Uncoded engine option refusal reads as an unknown object.** `rejectUnknownEngineOptions` throws a bare `Error` whose words quote the object name, so any such refusal that reaches a REST data door is mapped to `404 OBJECT_NOT_FOUND` by the quoted-name limb. After this PR no measured REST path reaches it (the empty-list path was the one measured). Observation only, no carrier. - **The engine door's no-axis answer.** `engine.aggregate(object, {})` and `{ aggregations: [] }` return the raw rows (`ObjectRepository.aggregate` defaults its query to `{}`). The REST door now refuses the same body. No shipped in-process caller was measured sending it. Observation only, no carrier. - **A `formula` under a date bucket is not judged**, because the declaration names no stored value class for it. On base, a number-expression formula bucketed by month answered one `null` bucket. Observation only, no carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_018QKYDyfAfcAWGKY4UCv3e3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent efe3598 commit 7a04ba2

6 files changed

Lines changed: 499 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(objectql,metadata-protocol): an aggregate query that would answer a wrong table is refused 400, and an empty aggregate list no longer answers "not registered"
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Date bucket on a non-date field.** `engine.aggregate` refuses a `groupBy` entry whose `dateGranularity` buckets a field the object declares neither a `date` nor a `datetime`: `INVALID_FIELD` / 400, `path: 'groupBy.N.dateGranularity'`. Before, every row fell into one `null` bucket (a `number` field into a `1970-01` bucket) under 200. A `formula` or undeclared field is not judged.
12+
- **Two columns under one name.** `engine.aggregate` refuses a query whose aggregated row would carry two values under one column: an aggregation `alias` used twice, an alias equal to a group column (the field name, or the entry's own `alias`), or two group entries projecting one column. `INVALID_QUERY` / 400, `path` at the second position (`aggregations.1.alias`, `groupBy.1.field`). Before, the last value written won under 200: a count overwritten by a sum, or the group keys replaced by counts. Give the entry its own `alias`.
13+
- Both refusals run at the engine's `aggregate` entry, before any driver is asked, so the REST query door, its dispatcher twin, the analytics ObjectQL strategy and the MCP data bridge answer alike.
14+
- **Empty aggregate list.** `POST /data/:object/query` with `aggregations: []` (or `groupBy: []`, or both) and no non-empty axis beside it answers `400 INVALID_QUERY` naming `query.aggregations` (or `query.groupBy`) as empty. Before, it answered `404 OBJECT_NOT_FOUND`, "Object '…' is not registered", for a registered object. An empty list beside a non-empty axis is served as before.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25819] An aggregate axis sent as an EMPTY list, with no non-empty axis
5+
* beside it, is refused `INVALID_QUERY` / 400 by `findData`, naming the empty
6+
* key, before the engine is asked.
7+
*
8+
* Measured on `origin/main` `cb0a2fcf4f` through `findData` over the real
9+
* engine: `{ aggregations: [] }` (with or without a `where`), `{ groupBy: [] }`
10+
* and both together routed to `engine.find`, which refuses an option it does
11+
* not execute with an uncoded error ("find('acct') does not recognise option
12+
* 'aggregations'…"); the REST door's unknown-object heuristic reads the quoted
13+
* object name in it as `404 OBJECT_NOT_FOUND`, "Object 'acct' is not
14+
* registered". The wire half is pinned over the real engine in
15+
* `@objectstack/objectql`'s `engine-aggregate-query-shape-door.test.ts`.
16+
*/
17+
18+
import { describe, it, expect, vi } from 'vitest';
19+
import { ObjectStackProtocolImplementation } from './protocol.js';
20+
21+
const SCHEMA = {
22+
name: 'acct',
23+
fields: {
24+
name: { name: 'name', type: 'text' },
25+
status: { name: 'status', type: 'text' },
26+
},
27+
};
28+
29+
function makeProtocol() {
30+
const find = vi.fn(async () => [] as unknown[]);
31+
const aggregate = vi.fn(async () => [] as unknown[]);
32+
const engine = {
33+
registry: { getObject: (n: string) => (n === 'acct' ? SCHEMA : undefined) },
34+
find,
35+
aggregate,
36+
count: vi.fn(async () => 0),
37+
};
38+
return { p: new ObjectStackProtocolImplementation(engine as any), find, aggregate };
39+
}
40+
41+
describe('[#25819] findData — an empty aggregate axis is refused for what it is', () => {
42+
const REFUSED: ReadonlyArray<readonly [Record<string, unknown>, string, string]> = [
43+
[{ aggregations: [] }, 'aggregations', "'query.aggregations' is an empty list"],
44+
[{ aggregations: [], where: { status: 'open' } }, 'aggregations', "'query.aggregations' is an empty list"],
45+
[{ groupBy: [] }, 'groupBy', "'query.groupBy' is an empty list"],
46+
[{ groupBy: [], aggregations: [] }, 'aggregations', "'query.groupBy' and 'query.aggregations' are empty lists"],
47+
];
48+
49+
for (const [query, param, words] of REFUSED) {
50+
it(`refuses ${JSON.stringify(query)} with INVALID_QUERY / 400 — the engine is never asked`, async () => {
51+
const { p, find, aggregate } = makeProtocol();
52+
const err: any = await p.findData({ object: 'acct', query } as never).then(() => null, (e) => e);
53+
expect({ code: err?.code, status: err?.status, param: err?.param, object: err?.object })
54+
.toEqual({ code: 'INVALID_QUERY', status: 400, param, object: 'acct' });
55+
expect(err.message).toContain(words);
56+
expect(find).not.toHaveBeenCalled();
57+
expect(aggregate).not.toHaveBeenCalled();
58+
});
59+
}
60+
61+
it('CONTROL an empty list beside a non-empty axis is served as before, through engine.aggregate', async () => {
62+
const { p, find, aggregate } = makeProtocol();
63+
await p.findData({ object: 'acct', query: { groupBy: ['status'], aggregations: [] } } as never);
64+
await p.findData({ object: 'acct', query: { groupBy: [], aggregations: [{ function: 'count', alias: 'n' }] } } as never);
65+
expect(aggregate).toHaveBeenCalledTimes(2);
66+
expect(find).not.toHaveBeenCalled();
67+
});
68+
69+
it('CONTROL a query with neither axis is the plain row read', async () => {
70+
const { p, find, aggregate } = makeProtocol();
71+
await p.findData({ object: 'acct', query: { where: { status: 'open' } } } as never);
72+
expect(find).toHaveBeenCalledTimes(1);
73+
expect(aggregate).not.toHaveBeenCalled();
74+
});
75+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13463,6 +13463,37 @@ export class ObjectStackProtocolImplementation implements
1346313463
// rows returned ungrouped, looking exactly like a served query.
1346413464
this.assertGroupByFieldsExist(request.object, options.groupBy);
1346513465
this.assertAggregationFieldsExist(request.object, options.aggregations);
13466+
// [#25819] An aggregate axis sent as an EMPTY list, with no non-empty
13467+
// axis beside it, asks for an aggregate of nothing. The routing below
13468+
// reads it as no aggregate and handed the key to `engine.find`, which
13469+
// refuses an option it does not execute with an uncoded error whose
13470+
// words carry the quoted object name — read at the REST door as the
13471+
// unknown-object heuristic: `404 OBJECT_NOT_FOUND`, "Object '…' is not
13472+
// registered", for a registered object. No shipped caller sends `[]`
13473+
// meaning the plain rows, so it is refused for what it is. An empty
13474+
// list beside a non-empty one is served as before (`groupBy` with
13475+
// `aggregations: []` lists the groups).
13476+
{
13477+
const emptyAxes = (['groupBy', 'aggregations'] as const)
13478+
.filter((k) => Array.isArray(options[k]) && options[k].length === 0);
13479+
const servedAxis = (['groupBy', 'aggregations'] as const)
13480+
.some((k) => Array.isArray(options[k]) && options[k].length > 0);
13481+
if (emptyAxes.length > 0 && !servedAxis) {
13482+
const named = emptyAxes.map((k) => `'query.${k}'`).join(' and ');
13483+
const err: any = new Error(
13484+
`${named} ${emptyAxes.length > 1 ? 'are empty lists' : 'is an empty list'} on object `
13485+
+ `'${request.object}': an aggregate query names at least one aggregation, or a non-empty `
13486+
+ `'groupBy' to list its groups. The query was NOT run. Send e.g. { "aggregations": `
13487+
+ `[{ "function": "count", "alias": "n" }] }, or omit ${emptyAxes.map((k) => `'${k}'`).join(' and ')} `
13488+
+ 'for the plain rows.',
13489+
);
13490+
err.status = 400;
13491+
err.code = 'INVALID_QUERY';
13492+
err.param = emptyAxes[emptyAxes.length - 1];
13493+
err.object = request.object;
13494+
throw err;
13495+
}
13496+
}
1346613497
// [#21086] A stored metadata body is served only as its type's read
1346713498
// projection (see `redactStoredMetadataRows` below), and a GROUP KEY
1346813499
// cannot be projected — so grouping by the body column of a
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25818] Two shapes of an aggregate query that the engine's `aggregate`
5+
* answered with a plausible table under 200, now refused before any driver is
6+
* asked:
7+
*
8+
* 1. a `groupBy` entry that date-buckets (`{ field, dateGranularity }`) a field
9+
* the object does not declare a date or datetime —
10+
* {@link assertDateBucketTargetsAreTemporal};
11+
* 2. two columns of the aggregated row under one name: an aggregation alias
12+
* used twice, an aggregation alias equal to a group column, or two group
13+
* entries projecting one column — {@link assertAggregatedColumnsAreDistinct}.
14+
*
15+
* ## What ran before this door, measured on `origin/main` `cb0a2fcf4f`
16+
*
17+
* Through `findData` (the `POST /api/v1/data/:object/query` door) over three
18+
* rows, `status` holding `open`, `open`, `won`:
19+
*
20+
* | query | answer |
21+
* |:--|:--|
22+
* | `groupBy: [{ field: 'name', dateGranularity: 'month' }]` (a `text` field) | `200 [{ name: null, n: 3 }]`: every row in one `null` bucket |
23+
* | `groupBy: [{ field: 'amount', dateGranularity: 'month' }]` (a `number` field) | `200 [{ amount: '1970-01', n: 3 }]`: the numbers read as epoch milliseconds |
24+
* | `groupBy: [{ field: 'at_time', dateGranularity: 'day' }]` (a `time` field) | `200 [{ at_time: null, n: 3 }]` |
25+
* | `aggregations: [{ count, alias: 'n' }, { sum amount, alias: 'n' }]` | `200 [{ n: 60 }]`: the count overwritten by the sum |
26+
* | `groupBy: ['status'], aggregations: [{ count, alias: 'status' }]` | `200 [{ status: 2 }, { status: 1 }]`: the group keys replaced by counts |
27+
* | `groupBy: [{ field: 'opened_on', dateGranularity: 'month' }, { field: 'opened_on', dateGranularity: 'year' }]` | `200 [{ opened_on: '2026', n: 1 }, { opened_on: '2026', n: 2 }]`: the month key overwritten by the year |
28+
*
29+
* The ingress door already refuses an unknown `groupBy` field, because an
30+
* unknown column "puts every row in ONE bucket keyed null"; these are that
31+
* hazard, and its overwrite twin, reached by a declared field.
32+
*
33+
* ## Where it stands
34+
*
35+
* At the entry of `engine.aggregate`, beside the structured-JSON group door and
36+
* the aggregate × field-type door, so it holds for every caller that reaches
37+
* the engine: the REST query door and its dispatcher twin (both through
38+
* `findData`), the analytics ObjectQL strategy, the MCP data bridge, a flow or
39+
* a hook. After those two doors, so a `json` field under a `dateGranularity`
40+
* keeps the structured-JSON refusal's words.
41+
*
42+
* ## What "a date field" is
43+
*
44+
* The declared type's class, read by `declaredFieldClasses` (having-filter.ts)
45+
* — the reading `in-memory-aggregation.ts` buckets a `date` field's calendar
46+
* day by. A `date` (`CALENDAR_DATE_TYPES`) and a `datetime` (`INSTANT_TYPES`)
47+
* pass. **Not judged:** a field the declaration cannot type — undeclared (the
48+
* ingress door answers an unknown one first; a system column the map does not
49+
* declare is bucketed as before), a `formula`, or a registry-less host.
50+
*
51+
* ## The column names
52+
*
53+
* A group column is the entry's name, or `alias ?? field` for the object form
54+
* — the name `applyInMemoryAggregation` and `driver-sql` project it under. An
55+
* aggregation column is its `alias`. Entries that name nothing are left to the
56+
* shape gates that judge them.
57+
*
58+
* `INVALID_FIELD` for the date bucket (a verdict about the named field's type,
59+
* as the structured-JSON group door gives) and `INVALID_QUERY` for a column
60+
* collision (the query names one column twice; no field is wrong). Each
61+
* refusal carries `path`, the position it judged (`groupBy.0.dateGranularity`,
62+
* `aggregations.1.alias`).
63+
*
64+
* @see https://github.com/objectstack-ai/objectstack/issues/25818
65+
*/
66+
67+
import { StandardErrorCode } from '@objectstack/spec/api';
68+
import { declaredFieldClasses } from './having-filter.js';
69+
70+
type ShapeRefusal = Error & {
71+
code?: string; status?: number; httpStatus?: number;
72+
field?: string; fields?: string[]; object?: string; param?: string; path?: string;
73+
};
74+
75+
function refusal(
76+
code: 'INVALID_FIELD' | 'INVALID_QUERY',
77+
message: string,
78+
extra: Partial<ShapeRefusal>,
79+
): ShapeRefusal {
80+
const err = new Error(message) as ShapeRefusal;
81+
err.code = StandardErrorCode.enum[code];
82+
err.status = 400;
83+
// …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a
84+
// consumer holding the THROWN error reads; `status` stays for the HTTP doors.
85+
err.httpStatus = 400;
86+
Object.assign(err, extra);
87+
return err;
88+
}
89+
90+
/**
91+
* Refuse a `groupBy` entry whose `dateGranularity` buckets a field the object
92+
* declares neither a date nor a datetime — `INVALID_FIELD` / 400, naming the
93+
* position, the field and its declared type. See the module header.
94+
*/
95+
export function assertDateBucketTargetsAreTemporal(
96+
object: string,
97+
schema: unknown,
98+
groupBy: unknown,
99+
): void {
100+
if (!Array.isArray(groupBy) || groupBy.length === 0) return;
101+
const fields = (schema as { fields?: unknown } | undefined)?.fields;
102+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
103+
let classes: ReturnType<typeof declaredFieldClasses> | undefined;
104+
for (const [i, entry] of groupBy.entries()) {
105+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
106+
const { field, dateGranularity } = entry as { field?: unknown; dateGranularity?: unknown };
107+
if (dateGranularity == null || typeof field !== 'string') continue;
108+
classes ??= declaredFieldClasses(fields);
109+
const cls = classes.get(field);
110+
if (cls === undefined || cls === 'date' || cls === 'datetime') continue;
111+
const type = String((fields as Record<string, { type?: unknown }>)[field]?.type);
112+
const path = `groupBy.${i}.dateGranularity`;
113+
throw refusal(
114+
'INVALID_FIELD',
115+
`aggregate('${object}'): ${path} buckets '${field}' by '${String(dateGranularity)}', but '${field}' `
116+
+ `is a declared ${type} field — only a date or datetime field can be date-bucketed. The query was `
117+
+ `NOT run. Group by '${field}' without 'dateGranularity', or bucket a date or datetime field. A `
118+
+ 'value that is not a date has no calendar position: every row fell into one null bucket (or, '
119+
+ 'for a number, an epoch bucket) while the answer looked served.',
120+
{ field, fields: [field], object, param: 'groupBy', path },
121+
);
122+
}
123+
}
124+
125+
/** The column a `groupBy` entry projects: its name, or `alias ?? field`. */
126+
function groupColumnOf(entry: unknown): string | undefined {
127+
if (typeof entry === 'string') return entry;
128+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
129+
const { alias, field } = entry as { alias?: unknown; field?: unknown };
130+
const name = alias ?? field;
131+
return typeof name === 'string' && name.length > 0 ? name : undefined;
132+
}
133+
134+
/** `groupBy.N` for a name, `groupBy.N.alias` / `groupBy.N.field` for the object form. */
135+
function groupColumnPath(entry: unknown, i: number): string {
136+
if (typeof entry === 'string') return `groupBy.${i}`;
137+
return (entry as { alias?: unknown }).alias != null ? `groupBy.${i}.alias` : `groupBy.${i}.field`;
138+
}
139+
140+
/**
141+
* Refuse a query whose aggregated row would carry two columns under one name —
142+
* `INVALID_QUERY` / 400, naming both positions. See the module header.
143+
*/
144+
export function assertAggregatedColumnsAreDistinct(
145+
object: string,
146+
groupBy: unknown,
147+
aggregations: unknown,
148+
): void {
149+
const seen = new Map<string, string>();
150+
const claim = (column: string, path: string, param: 'groupBy' | 'aggregations', what: string) => {
151+
const first = seen.get(column);
152+
if (first === undefined) {
153+
seen.set(column, path);
154+
return;
155+
}
156+
throw refusal(
157+
'INVALID_QUERY',
158+
`aggregate('${object}'): ${path} names the column '${column}', which ${first} already names — `
159+
+ `one row cannot carry two values under one column. The query was NOT run. ${what} A repeated `
160+
+ 'column name kept only the last value written under it: a count overwritten by a sum, or the '
161+
+ 'group keys replaced by the numbers, while the answer looked served.',
162+
{ object, param, path, fields: [column] },
163+
);
164+
};
165+
for (const [i, entry] of (Array.isArray(groupBy) ? groupBy : []).entries()) {
166+
const column = groupColumnOf(entry);
167+
if (column === undefined) continue;
168+
claim(column, groupColumnPath(entry, i), 'groupBy',
169+
"Give one of the group entries its own 'alias'.");
170+
}
171+
for (const [i, entry] of (Array.isArray(aggregations) ? aggregations : []).entries()) {
172+
const alias = (entry as { alias?: unknown } | null)?.alias;
173+
if (typeof alias !== 'string' || alias.length === 0) continue;
174+
claim(alias, `aggregations.${i}.alias`, 'aggregations',
175+
"Give each aggregation an 'alias' of its own, distinct from every group column.");
176+
}
177+
}

0 commit comments

Comments
 (0)