Skip to content

Commit de24d17

Browse files
fix(objectql, service-analytics)!: a plain group over a formula field is refused alike on every analytics face, instead of a 500, a driver backstop, one null group or a rendered statement (#30768)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DU8jQoF8NLerVQyciDAynrX)_ domain:server#3 · os-elon-musk · session_016WV4cyTehJsfYGVMzcXLJD Fixes #29304 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:按公式字段(不落库的虚拟字段)做普通分组,现在所有分析入口都给同一个 `INVALID_FIELD` / 400,带位置(`groupBy.N`)和"把值存到一个真实字段上再分组"的修法;原生 SQL 策略遇到这种维度会让路给引擎。 - 为什么改:同一个查询原来有四种答案:原生组合 500、ObjectQL 组合是驱动兜底的 400(无位置、建议"跑 schema sync")、`/analytics/sql` 干跑渲染出 `GROUP BY calc`、草稿预览和内存聚合给一个 `null` 分组,看起来像成功。 - 风险与回滚:只收窄"按公式字段分组"这一种查询;存储字段分组、日期分桶、纯度量查询照旧,且照旧走原生 SQL。回滚即 revert。 - 先看:`packages/objectql/src/aggregate-query-shape-door.ts` 的 `assertGroupByTargetsAreStored`;`native-sql-strategy.ts` 的 `groupsByVirtualField` 让路;`objectql-strategy.ts` 的 `assertCrossObjectAttrsStored`。 - 两分钟验证:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/objectql-echo-non-date-bucket.test.ts`,预期全部通过,含 5 条 `[#29304]` 用例。 - 需要你做的:无;契约复核后席位落地。 ## What changes **`@objectstack/objectql`: a storage axis at the aggregate door.** - `assertGroupByTargetsAreStored` (new, in `aggregate-query-shape-door.ts`, beside the date-bucket and column doors) refuses a `groupBy` entry of any form naming a field the object declares VIRTUAL, judged by the spec's `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`, today `formula`), whatever its `returnType`. `INVALID_FIELD` / 400 with `path` `groupBy.N` for a name, `groupBy.N.field` for the object form, and `groupBy.N.dateGranularity` for a bucket. - The #29167 bucket arm moved here from `assertDateBucketTargetsAreTemporal`, its words and position unchanged (the existing #29167 pins pass untouched). The date-bucket door keeps its class arm only, and `engine.aggregate` calls the storage door first. - The plain-group sentence is the #29167 message's voice: the position, the field, its type, "The query was NOT run", the store-the-value remedy, and what happened before. **`@objectstack/service-analytics`: the service's one copy, and the native route.** - `dataset-date-bucket-door.ts` gains `assertDatasetGroupTargetsStored`, the engine refusal field for field (the #29167 bucket arm moved into it too). It is called ahead of `assertDatasetDateBucketsTemporal` at the three existing call sites: the draft preview, the ObjectQL `/analytics/sql` echo, and the delegated fallback face. - `NativeSQLStrategy.canHandle` declines a query whose `dimensions` entry resolves (through `resolveStorageTarget`, the compiler's own resolution) to a column the host's `declaredFieldType` names virtual. The existing decline mechanism (#20887, #21080): what native cannot serve as the engine would, the engine serves. The query reaches the ObjectQL strategy and the engine door, and the native composition's dry run reaches the ObjectQL echo. A host that wired no field types is not judged. - A dimension through a relationship over the related object's formula (`account.name_calc`): the cross-object path hands the related object `groupBy: ['id', attr]`, which the engine refuses only once a base row has a key to resolve. `assertCrossObjectAttrsStored` judges those keys with the service copy in `executeCrossObject` (after the base aggregate, before any related read) and in the echo, so the query and the dry run give one answer on any data. **File surface.** The seat approved one addendum before the PR: `packages/services/service-analytics/src/strategies/native-sql-strategy.ts` (the decline). `strategies/objectql-strategy.ts` was NOT in that addendum; the first version of this body said it was, which was wrong. Why that file moved (+33 / −1): - The echo call, `assertDatasetGroupTargetsStored` beside `assertDatasetDateBucketsTemporal`: `/analytics/sql` on the ObjectQL composition is answered by this echo, the dry-run call site where `main` already calls the #29167 service copy. The dispatch surface names the service's dry-run call sites, but not this file, so it is named here. - `assertCrossObjectAttrsStored` (one helper, called in the echo and in `executeCrossObject`) goes beyond the named call sites. While pinning the faces I found `account.name_calc` (a dimension through a relationship over the related object's formula) still rendered on the dry run and served on an empty base, because the engine refuses it only once a base row has a key to resolve. I added it without asking the seat for an amendment first. It is pinned by the `account.name_calc` rows in `objectql-echo-non-date-bucket.test.ts`. The seat ruled that it stays in this PR: a cross-object dimension over a formula is still a dimension naming a formula field, so it is this card's defect on another path. The seat's ACCEPT names `strategies/objectql-strategy.ts` (both sites) in the surface addendum. **Fixtures the narrowing moved (fixture triage, not new behaviour):** - `engine-aggregate-reference-verdict-positions.test.ts`: at `having`, a formula column can no longer be an aggregated column, so a formula pair there is refused first by the storage door (`INVALID_FIELD`), as a list-or-object pair already was. The "addDays pair from a formula projection" case now asserts that refusal at `groupBy.0`. - `dimension-structured-json-door.test.ts` and `multi-value-json-stored-door.test.ts` GUARDs sweep every `FieldType` through a native-only harness: `formula` now declines out of native and that harness configures no ObjectQL path, so the guards record it as not this door's refusal. **Follow-up card:** #30773. `dataset-compiler.ts` refuses a formula as a dataset measure but still accepts a dataset or cube dimension over one, so an author can publish a dimension that every query now refuses. The authoring-time refusal (Prime Directive 12, `Metadata-protocol: narrowed`) is that card's, out of this one's scope (contract review 6674767869, should-fix). ## Tests - `packages/objectql/src/engine-aggregate-query-shape-door.test.ts` (+2 and two control rows): a plain group over `doubled` / `opened_calc` as a name and as `{ field }` / `{ field, alias }`, full message, `path`, `param`, `field`, `fields`, `object`, no driver read; the first offending position wins; the REST door through `findData` and `mapDataError`. Controls: `status` as a name and `{ field: 'status' }` served. - `preview-date-bucket-type.test.ts` (+3): native, ObjectQL and preview faces give one refusal, field for field, for a formula with `returnType` `date` and `text`; the later position after a stored dimension; a stored dimension served alike on all three faces. - `objectql-echo-non-date-bucket.test.ts` (+5): `/analytics/query` and `/analytics/sql` on the default (native) composition give one refusal for an inferred dimension, after a stored one, a cube dimension's `sql`, and `account.name_calc`. Route pin: a stored dimension, a measure-only query and a cube dimension stay native (`engine.aggregate` never called, the native `COALESCE` statement served); a formula dimension declines and the engine judges it (one `engine.aggregate` call). - `fallback-delegate-date-bucket-type.test.ts` (+2): the delegated face refuses a plain formula group as the engine face does, query and dry run, delegate never called. - `packages/qa/dogfood/test/analytics-formula-group-refusal.dogfood.test.ts` (new, 5): the stock showcase over HTTP, `showcase_project.budget_remaining` (a currency formula) on `/analytics/query` and `/analytics/sql`: 400 `INVALID_FIELD`, the plain-group sentence and remedy, one `error` body on both doors; `status` served on both. ## Acceptance notes - Measure-only queries over a formula were already refused before this change by the measure-type door on both compositions (`calc_sum`: "sum does not accept that type"), so the route pin uses a stored measure. - A hand-composed native-only service (`queryCapabilities` with `objectqlAggregate: false` and no fallback) now answers "No strategy can handle query" for a formula dimension, where it compiled a `GROUP BY` the database refuses. No shipped composition reaches it: the plugin derives both flags from the bridges it wires. The same cost the #20887 / #21080 declines carry. - `account.name_calc` is refused at the position of the cross-object path's own FK-resolve query on the related object (`aggregate('…account'): groupBy.1`), a position the caller never wrote. It is the engine's answer for that query, now given on any data and on the dry run too; the structured-JSON door documents the same trait for its ObjectQL face. ## Verification Measured on `origin/main` `447226b2` with a real `SqlDriver` (scratch test, deleted), a dataset and a cube dimension over `calc` (formula, `returnType` `date`) and `calc_s` (`returnType` `text`): | face | before | now | |:--|:--|:--| | `/analytics/query`, native composition | `DATABASE_ERROR` / 500 | `INVALID_FIELD` / 400, `groupBy.0` | | `/analytics/query`, ObjectQL composition | driver backstop `INVALID_FIELD` / 400, no `path`, "run schema sync" | the same refusal | | `/analytics/sql`, both compositions | `SELECT calc AS "calc" … GROUP BY calc` | the same refusal | | draft preview | `[{ calc: null, total: 150 }]` | the same refusal | | `engine.aggregate`, in-memory aggregation | one `null` group | `INVALID_FIELD` / 400, `groupBy.N` | Controls at head, measured the same way: a `region` dimension and a measure-only query are served by the native strategy on both doors, as on `main`. <details><summary>Ablations (each restored, `git diff HEAD` empty after)</summary> Through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob moved), service-analytics imports `@objectstack/objectql` from `src` (vitest alias), so no rebuild: - A1, the `assertGroupByTargetsAreStored` call deleted from `engine.aggregate`: objectql 3 red (both #29304 pins and the #29167 bucket pin), service-analytics 18 red across the three face files. - A2, `assertDatasetGroupTargetsStored` made a no-op: service-analytics 18 red (preview, echo and fallback pins, plain and bucket). - A3, the native decline deleted: service-analytics 10 red (native-face plain-group pins, the route pin, the fallback engine-face comparison). </details> <details><summary>Suites, gates and dogfood at 42c89af</summary> The head after the contract review, `6bcd5781`, changes only the changeset (`Clause-②: no (narrowing)`, and the BREAKING heading "Who moves"). On it: `check-changeset-no-major --base origin/main`, `check-adr-0087-registration --base origin/main`, `check-empty-changeset --base origin/main` and `pnpm check:changeset-gate-self-tests` all exit 0; `scripts/pm/clause2-line.mjs` `readClause2Line` reads the changeset as `value: no, arm: narrowing` and `readMetadataProtocolLine` as `none`. The full dogfood run below carries over to that head, per the #29167 precedent. - `@objectstack/objectql` `test`: 556 files / 11071 tests passed; `typecheck` exit 0. `@objectstack/service-analytics` `test`: 211 files / 5007 passed, 300 skipped; `typecheck` exit 0. `eslint --no-inline-config` over the 13 changed `.ts` files: exit 0. - Full dogfood battery (the title carries `!`), `pnpm --filter @objectstack/dogfood test` under the verify lock: VERDICT command-exit 0, Test Files 422 passed / 3 skipped (425), Tests 3521 passed / 36 skipped (3557). The new pin alone: 5 / 5. - `dispatch-gates --commands` at `42c89af2`: 68 derived, 67 run with exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3: needs a whole-tree build; CI runs it). `--ran` reconcile: 0 UNRUN. </details> Skills-docs: `git grep -i formula -- skills content/docs`, read for grouping, dimension, bucket and analytics. No page says a formula can be grouped. `skills/objectstack-data/rules/field-types.md` says a formula is "virtual: not aggregatable", which still holds; it does not name grouping. It is a governed surface, so it is not touched here; a skills seat can add "or grouped by" there. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Locally: eslint over the changed files; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. <!-- signed: domain:server#3 · os-elon-musk · session_016WV4cyTehJsfYGVMzcXLJD --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8195bd commit de24d17

16 files changed

Lines changed: 520 additions & 54 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/service-analytics': patch
4+
---
5+
6+
fix(objectql, service-analytics)!: a plain group over a formula field is refused alike on every analytics face, instead of a 500, a driver backstop, one null group or a rendered statement
7+
8+
Clause-②: no (narrowing) — `engine.aggregate` now refuses `INVALID_FIELD` / 400 a `groupBy` entry of any form (a name, `{ field }`, or a bucket) naming a virtual (`formula`) field, whatever its `returnType`, before any driver is asked: at `groupBy.N` for a name, `groupBy.N.field` for the object form, and `groupBy.N.dateGranularity` for a bucket, as before. `/analytics/query` on the native composition, `/analytics/sql` (`AnalyticsService.generateSql`), the draft preview of `queryDataset` and the delegated fallback face give the same refusal, field for field. Nothing is widened: no export of either package root, public member, answer member, error code or status is added.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of either package root changes: a query that grouped by a column the table does not store is refused before it reaches the driver, with an error code and status already on the wire. There is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing on `engine.aggregate` (`@objectstack/objectql`) and on every analytics face (`@objectstack/service-analytics`), for a plain group over a `formula` field only.
14+
15+
- **Before.** Measured on `main` `447226b2` with a real `SqlDriver`, a dataset or cube dimension over a `formula` (`returnType` `date` or `text`). `/analytics/query` on the native composition answered `DATABASE_ERROR` / 500 (the native strategy ran `GROUP BY calc` itself); on the ObjectQL composition it answered the SQL driver's missing-column backstop, `INVALID_FIELD` / 400 with no `path` and a "run schema sync" prescription that cannot help. The `/analytics/sql` dry run rendered `SELECT calc AS "calc" … GROUP BY calc` on both compositions, and the draft preview served one `null` group holding the total. `engine.aggregate` on a driver with no native `aggregate` (the in-memory aggregation) served one `null` group holding every row.
16+
- **Now.** The engine's aggregate door gains a storage axis (`assertGroupByTargetsAreStored`, ahead of the date-bucket door), judged by the spec's `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`), the predicate the sort, filter, search and bucket axes already refuse a formula by. The service's copy (`dataset-date-bucket-door.ts`, `assertDatasetGroupTargetsStored`) gives the same refusal on the faces that hand the engine no keys: the dry run, the preview and the delegated fallback service. The native-SQL strategy declines a query whose `dimensions` entry resolves to a virtual field, so that query reaches the engine. A dimension through a relationship (`account.calc`) over a related object's formula is judged on the keys the cross-object path hands the related object, on the query and the dry run alike, whether or not a base row exists.
17+
- **Unchanged.** A plain group over a stored field, and a date or datetime bucket, are served on every face, byte for byte, and stay on the native strategy where they were. A measure-only query stays native. The #29167 refusal of a bucket over a formula keeps its words and position.
18+
- **Who moves.** A caller grouping by a formula field: a located refusal with a remedy, instead of a 500, a backstop with the wrong advice, an empty-looking group or SQL for a statement that never runs.
19+
20+
**The remedy.** Denormalise the value onto the object (a stored field, written when the source changes) and group by that.

‎packages/client/src/envelope-caller-census.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -534,8 +534,8 @@ const LEDGER: readonly LedgerRow[] = [
534534
// ── [#28977] the /analytics/sql dry run vs the query for a date bucket over a column that is not a date: producer reads only ──
535535
{
536536
file: 'packages/services/service-analytics/src/__tests__/objectql-echo-non-date-bucket.test.ts',
537-
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
538-
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to compare the query\'s refusal with the dry run\'s (a date bucket over a text, select, number, boolean or time column), and to read the `sql` the query serves for a date and a datetime bucket',
537+
method: 'analytics.query', receiver: 'service', count: 4, verdict: 'NOT_SDK',
538+
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to compare the query\'s refusal with the dry run\'s (a date bucket over a text, select, number, boolean or time column), and to read the `sql` the query serves for a date and a datetime bucket; [#29304] and to read which strategy serves a stored, measure-only or cube dimension (native) and which refuses a formula dimension (the engine)',
539539
},
540540
{
541541
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
@@ -781,7 +781,10 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
781781
// bucket over a column that is not a date calls the same
782782
// plugin-registered AnalyticsService to compare the query's refusal
783783
// and served `sql` with `generateSql`'s.
784-
expect(service.length, literalNote()).toBe(17);
784+
// [#29304] Two more sites in that file: the route pin calls the same
785+
// plugin-registered AnalyticsService to read which strategy serves a
786+
// stored dimension and which refuses a formula one.
787+
expect(service.length, literalNote()).toBe(19);
785788
expect([...new Set(service.map((s) => s.file))].sort()).toEqual([
786789
'packages/client/src/analytics-automation-json-erasure.test.ts',
787790
'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
@@ -836,15 +839,16 @@ describe('#13079 §3 — every call site is classified', () => {
836839
expect(production, literalNote()).toEqual([]);
837840
});
838841

839-
it('records the split: 18 payload pins, 10 result-insensitive, 17 not-SDK', () => {
842+
it('records the split: 18 payload pins, 10 result-insensitive, 19 not-SDK', () => {
840843
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
841844
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
842845
// [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2).
843846
// [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2).
844847
// [#28841] 11 -> 14: the dry-run parity pin's three producer calls (§2).
845848
// [#28852] 14 -> 15: its dateRange-precedence call.
846849
// [#28977] 15 -> 17: the non-date bucket dry-run parity pin's two producer calls (§2).
847-
expect(verdictTotal('NOT_SDK')).toBe(17);
850+
// [#29304] 17 -> 19: the same file's route pin, two producer calls (§2).
851+
expect(verdictTotal('NOT_SDK')).toBe(19);
848852
// The three above are LEDGER sums and cannot move on a census reading;
849853
// this one is census-derived, so it carries the note. [#13874]
850854
expect(sdkSites.length, literalNote()).toBe(28);

‎packages/objectql/src/aggregate-query-shape-door.ts‎

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
* used twice, an aggregation alias equal to a group column, or two group
1313
* entries projecting one column — {@link assertAggregatedColumnsAreDistinct}.
1414
*
15+
* [#29167, #29304] …and, ahead of the first, a `groupBy` entry of any form —
16+
* a name, `{ field }`, or a bucket — naming a VIRTUAL field, which no driver
17+
* stores a column for — {@link assertGroupByTargetsAreStored}.
18+
*
1519
* ## What ran before this door, measured on `origin/main` `cb0a2fcf4f`
1620
*
1721
* Through `findData` (the `POST /api/v1/data/:object/query` door) over three
@@ -48,26 +52,34 @@
4852
* ingress door answers an unknown one first; a system column the map does not
4953
* declare is bucketed as before), or a registry-less host.
5054
*
51-
* ## A virtual field (`formula`) is refused, on its storage ground [#29167]
55+
* ## A virtual field (`formula`) is refused, on its storage ground [#29167, #29304]
5256
*
5357
* A `formula` names no stored value class (its value is the expression's), so
5458
* the class reading above cannot judge it. It is refused on the ground the
5559
* sort, filter, search and value-aggregate axes already refuse it on: it is
5660
* VIRTUAL — computed on read, no column — judged by the spec's
5761
* `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`), never a local type list,
5862
* and whatever its `returnType` says. Measured on `origin/main` `7788eedb`
59-
* before this arm, a month bucket over a `formula` with `returnType: 'date'`:
63+
* before the bucket arm (#29167), a month bucket over a `formula` with
64+
* `returnType: 'date'`, and on `origin/main` `447226b2` before the plain arm
65+
* (#29304), a plain group over a `formula` (`returnType` `date` or `text`):
6066
*
6167
* | caller | answer |
6268
* |:--|:--|
6369
* | a SQL driver | `INVALID_FIELD` / 400 from the driver's missing-column backstop: no `path`, and a "run schema sync" prescription that cannot help |
6470
* | a driver with no native `aggregate` (the in-memory aggregation) | `200`, one `null` group holding every row |
6571
*
66-
* Both now answer {@link virtualDateBucketMessage}, `INVALID_FIELD` / 400 at
67-
* `groupBy.N.dateGranularity`, with the remedy those axes give: store the
68-
* value on a stored field and use that. `@objectstack/service-analytics`'s
69-
* `dataset-date-bucket-door.ts` gives the same refusal on the two faces that
70-
* reach no engine (the `/analytics/sql` dry run and the draft preview).
72+
* Every `groupBy` entry is judged, whatever its form, by
73+
* {@link assertGroupByTargetsAreStored}, ahead of the date-bucket door: a
74+
* bucket answers {@link virtualDateBucketMessage} at
75+
* `groupBy.N.dateGranularity` (the #29167 words), a plain group answers
76+
* {@link virtualGroupMessage} at `groupBy.N` (a name) or `groupBy.N.field`
77+
* (the object form) — `INVALID_FIELD` / 400 either way, with the remedy those
78+
* axes give: store the value on a stored field and use that.
79+
* `@objectstack/service-analytics`'s `dataset-date-bucket-door.ts` gives the
80+
* same refusals on the faces that reach no engine (the `/analytics/sql` dry
81+
* run and the draft preview), and its native-SQL strategy declines a grouped
82+
* dimension over a virtual field, so that face reaches this door.
7183
*
7284
* ## The column names
7385
*
@@ -127,11 +139,69 @@ function virtualDateBucketMessage(
127139
+ 'that aggregates in memory put every row into one null group while the answer looked served.';
128140
}
129141

142+
/**
143+
* [#29304] The refusal of a plain group over a VIRTUAL field (see the module
144+
* header): {@link virtualDateBucketMessage}'s words for a group key with no
145+
* bucket. `@objectstack/service-analytics`'s `dataset-date-bucket-door.ts`
146+
* carries the one copy, compared field for field by its tests.
147+
*/
148+
function virtualGroupMessage(object: string, path: string, field: string, type: string): string {
149+
return `aggregate('${object}'): ${path} groups by '${field}', but '${field}' is a ${type} field — its value `
150+
+ 'is computed on read and never stored, so no driver materialises a column to group by. The query was NOT '
151+
+ `run. Denormalise the value onto '${object}' (a stored field, written when the source changes) and group `
152+
+ 'by that. A formula field is virtual: with no column behind it the group key reached the driver as a '
153+
+ 'column that does not exist, and an engine that aggregates in memory put every row into one null group '
154+
+ 'while the answer looked served.';
155+
}
156+
157+
/**
158+
* Refuse a `groupBy` entry of any form — a name, `{ field }`, or a
159+
* `{ field, dateGranularity }` bucket — naming a field the object declares
160+
* VIRTUAL (`formula`; the spec's `isVirtualSearchField`), `INVALID_FIELD` /
161+
* 400 at the position it names. See the module header.
162+
*/
163+
export function assertGroupByTargetsAreStored(
164+
object: string,
165+
schema: unknown,
166+
groupBy: unknown,
167+
): void {
168+
if (!Array.isArray(groupBy) || groupBy.length === 0) return;
169+
const fields = (schema as { fields?: unknown } | undefined)?.fields;
170+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
171+
for (const [i, entry] of groupBy.entries()) {
172+
const named = typeof entry === 'string';
173+
if (!named && (entry === null || typeof entry !== 'object' || Array.isArray(entry))) continue;
174+
const { field, dateGranularity } = named
175+
? { field: entry, dateGranularity: undefined }
176+
: entry as { field?: unknown; dateGranularity?: unknown };
177+
if (typeof field !== 'string' || !Object.prototype.hasOwnProperty.call(fields, field)) continue;
178+
const declared = (fields as Record<string, { type?: unknown }>)[field];
179+
if (!isVirtualSearchField(declared as never)) continue;
180+
const type = String(declared?.type);
181+
if (dateGranularity != null) {
182+
// [#29167] The bucket keeps its own words and position.
183+
const path = `groupBy.${i}.dateGranularity`;
184+
throw refusal(
185+
'INVALID_FIELD',
186+
virtualDateBucketMessage(object, path, field, type, String(dateGranularity)),
187+
{ field, fields: [field], object, param: 'groupBy', path },
188+
);
189+
}
190+
const path = named ? `groupBy.${i}` : `groupBy.${i}.field`;
191+
throw refusal(
192+
'INVALID_FIELD',
193+
virtualGroupMessage(object, path, field, type),
194+
{ field, fields: [field], object, param: 'groupBy', path },
195+
);
196+
}
197+
}
198+
130199
/**
131200
* Refuse a `groupBy` entry whose `dateGranularity` buckets a field the object
132-
* declares neither a date nor a datetime, or a virtual (`formula`) field —
133-
* `INVALID_FIELD` / 400, naming the position, the field and its declared type.
134-
* See the module header.
201+
* declares neither a date nor a datetime — `INVALID_FIELD` / 400, naming the
202+
* position, the field and its declared type. A virtual field is judged ahead
203+
* of this door, by {@link assertGroupByTargetsAreStored}. See the module
204+
* header.
135205
*/
136206
export function assertDateBucketTargetsAreTemporal(
137207
object: string,
@@ -147,18 +217,6 @@ export function assertDateBucketTargetsAreTemporal(
147217
const { field, dateGranularity } = entry as { field?: unknown; dateGranularity?: unknown };
148218
if (dateGranularity == null || typeof field !== 'string') continue;
149219
const path = `groupBy.${i}.dateGranularity`;
150-
const declared = Object.prototype.hasOwnProperty.call(fields, field)
151-
? (fields as Record<string, { type?: unknown }>)[field]
152-
: undefined;
153-
// [#29167] A virtual field first: the class reading below has no class
154-
// for it, so it would otherwise be skipped unjudged.
155-
if (isVirtualSearchField(declared as never)) {
156-
throw refusal(
157-
'INVALID_FIELD',
158-
virtualDateBucketMessage(object, path, field, String(declared?.type), String(dateGranularity)),
159-
{ field, fields: [field], object, param: 'groupBy', path },
160-
);
161-
}
162220
classes ??= declaredFieldClasses(fields);
163221
const cls = classes.get(field);
164222
if (cls === undefined || cls === 'date' || cls === 'datetime') continue;

0 commit comments

Comments
 (0)