Skip to content

Commit c5ac1b6

Browse files
fix(service-analytics)!: the draft preview refuses one column asked raw beside its own time bucket, as the live faces do (#28794)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB1hsVtuncZ5F5C4zpiftHC)_ Fixes #28765 Clause-②: no (narrowing) — the draft preview (`queryDataset` with `previewDrafts: true`) now refuses `INVALID_QUERY` / 400 a selection that asks one column for two values under one name (a dimension `X` beside a `timeDimensions` entry `OBJECT.X` with a granularity, or two buckets of one column), with the message, `path`, `param`, `object` and `fields` the native and ObjectQL strategies already answer; it served both columns. Nothing is widened: no package-root export, public member, answer member, error code or status is added, and the code was already on the wire. Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` says the draft preview serves a column raw beside its own bucket. **BREAKING** accept-set narrowing on the `queryDataset` draft preview (`@objectstack/service-analytics`). Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:草稿预览遇到「同一列既要原值又要它的时间桶」(例如维度 `closed_at` 加上按月分桶的 `对象名.closed_at`),或同一列的两个时间桶时,改为 400 `INVALID_QUERY` 拒绝,消息、`path` 等字段与原生 SQL、ObjectQL 两面逐字一致。之前预览照样给出两列,而发布后线上会拒绝。 - **为什么改**:卡 #28765(p3),是 PR #28762 开发时实测到的同族缺口:作者在草稿里看到一张表,发布后同一个查询却 400。 - **风险与回滚**:这是预览面的收窄,需要合约评审(席位跑)。只拒绝线上本来就拒绝的写法;不同列的时间桶、维度本身也作为时间维度(一个桶)、同列两个维度(#28744)照样服务,都有测试钉住。线上两面行为不变。回滚即 revert。 - **先看**:新文件 `packages/services/service-analytics/src/preview-group-column-door.ts`;`strategies/objectql-strategy.ts` 抽成模块级函数的 `aggregateGroupKeys`(不从包根导出,类的公开成员不变);`analytics-service.ts` 预览闸门列表末尾的调用。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/preview-time-filter-fk-parity.test.ts`,预期 27 passed。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes - **`strategies/objectql-strategy.ts`.** The direct path's `groupBy` build moves, unchanged, into the module-level `aggregateGroupKeys(query, cube)`, which `execute` calls. It reads only the cube, through `lookupCubeMember` and `resolveCubeFieldName`, which are now module-level too; the strategy's private `lookupMember`, `resolveFieldName` and `extractObjectName` delegate to them and to `aggregateObjectOf(cube)` (`cube.sql.trim()`). None of these is exported from the package root (`src/index.ts` names `ObjectQLStrategy` alone from this module), and the class gains no public member: the built `dist/index.d.ts` names none of `aggregateGroupKeys`, `aggregateObjectOf`, `AggregateGroupKey`. No behaviour change on the live faces. - **`preview-group-column-door.ts` (new).** `assertPreviewGroupColumnsDistinct(query, cube)` builds the strategy's keys with `aggregateGroupKeys`, reads the object the way the strategy does (`aggregateObjectOf`), and refuses keys that project one column twice: `INVALID_QUERY` / 400 with the engine's message, `path` (`groupBy.N` / `groupBy.N.field`), `param: 'groupBy'`, `object` and `fields`, plus `httpStatus`. - **Not a shared helper, and why.** The live refusal is `@objectstack/objectql`'s `assertAggregatedColumnsAreDistinct` (`aggregate-query-shape-door.ts`). `@objectstack/objectql` is a dev dependency of `service-analytics`, never a runtime one, and its root does not export the door, so the preview cannot call it without a new runtime dependency or a `packages/objectql` export. The order allows a copy in that case: only the `groupBy` half is copied (the strategy's keys carry no `alias`), and the test compares the preview's refusal with both live faces field by field, message included, so a change to the engine's wording reds it (ablation C2). - **`analytics-service.ts`.** The preview gate list asks the door last, after `assertPreviewJoinsNothing`: `assertPreviewGroupColumnsDistinct(q, cube)`. The positions it names are the engine's by construction, since the strategy's `execute` builds its `groupBy` with the same function. - **Contract review rework (`d26d29b4`).** The first head (`722fbe7c`) added a public `groupByOf` to the root-exported `ObjectQLStrategy`, which widened the published surface under a narrowing declaration, and the service held the strategy by name. Both are gone: the builder is module-level (remedy b), and the door takes the cube and reads the object off it (should-fix 1). - Tests: four cases added to `src/__tests__/preview-time-filter-fk-parity.test.ts` (now 27, real SQLite). - Changeset `.changeset/28765-preview-refuses-column-raw-beside-bucket.md`, `@objectstack/service-analytics` patch, with the adr-0087 not-required marker. ## Why Measured on `origin/main` `850eab2a` (real SQLite, every face), dataset over a deal object (`region`, `closed_at` datetime) with dimensions `region`, `closed_at` (date): | selection | native | ObjectQL | preview before | preview after | |:--|:--|:--|:--|:--| | dims `closed_at` + td `OBJECT.closed_at` month | `INVALID_QUERY`, `groupBy.1.field` … `groupBy.0` | same | served raw instants + months | same refusal as live | | dims `region`, `closed_at` + td `OBJECT.closed_at` month | `INVALID_QUERY`, `groupBy.2.field` … `groupBy.1` | same | served | same refusal as live | | td `closed_at` month + td `OBJECT.closed_at` year | `INVALID_QUERY`, `groupBy.1.field` … `groupBy.0.field` | same | served | same refusal as live | | dims `region` + td `closed_at` month | served | served | served | served (unchanged) | | dims `closed_at` + td `closed_at` month | one bucket | one bucket | one bucket | one bucket (unchanged) | ## Tests <details><summary>Red first, green, ablations</summary> - Red on base: `analytics-service.ts` and `objectql-strategy.ts` restored to `850eab2a` and the new door removed, tests at `722fbe7c`: `Tests 3 failed | 24 passed (27)`; the three refusal cases fail on the preview leg. Restored, `git status` clean. - Green at `d26d29b4`: `27 passed (27)`. - Ablations at `d26d29b4`, via `node scripts/ablation-replace.mjs` (each anchor 1 → 0, restore blob == HEAD, `git diff HEAD` empty): - C1 `--delete` the preview's door call: `3 failed | 24 passed` (the three refusals). - C2 one word of the copied message changed (`NOT run` → `not run`): `3 failed | 24 passed` (the field-by-field comparison with the live faces). - C3 a bucketed key's path spelled `groupBy.N` instead of `groupBy.N.field`: `3 failed | 24 passed`. - C4 the door refusing any second key, whatever its column: `3 failed | 24 passed` (region + month served, #28744 beside a month bucket, and the later-position refusal); so the still-served pins bite. - C5 the door's object read as `cube.name` instead of `aggregateObjectOf(cube)`: `3 failed | 24 passed`. - `pnpm --filter @objectstack/service-analytics exec vitest run` at `d26d29b4`: `Test Files 203 passed (203)`, `Tests 4866 passed | 282 skipped`. - `pnpm --filter @objectstack/rest exec vitest run src/analytics` at `d26d29b4`: `Test Files 23 passed (23)`, `Tests 298 passed | 22 skipped`. - `pnpm --filter @objectstack/service-analytics typecheck` at `d26d29b4` exit 0; `eslint` on the touched source files exit 0. </details> ## Acceptance notes - Consumers of the preview: the Studio paths that pass `previewDrafts` (`ui/packages/app-shell` dataset and chart previews) already receive the preview's other 400 refusals (#28555 and earlier); this adds one more selection to that set, never one a published query serves. - Two buckets of one column (month and year) are refused too: it is the same engine refusal on the live faces, measured, and the card's root (one column, two values, one name). - The preview's refusal runs after every other preview gate, so a selection refused for another reason keeps that refusal. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 families derived on `d26d29b4` (5 paths vs merge base `8afaf12d`; not stale against `origin/main` `8196881e`, which touches no `service-analytics` file); each run with stdin from `/dev/null`, exit captured before any pipe, after a full `turbo run build --filter=!@objectstack/docs --concurrency=2` on the same merge base and a rebuild of `service-analytics` at `d26d29b4` (lock VERDICT command-exit 0). `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**, every one `exit 0`. - Hand gates: `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0; `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` exit 0. - Dogfood battery (Clause-② carries a narrowing arm), on `d26d29b4`: `pnpm --filter @objectstack/dogfood test` under the lock, VERDICT command-exit 0, `Test Files 376 passed | 3 skipped (379)`, `Tests 3069 passed | 28 skipped (3097)`, no failure (also on `722fbe7c`, same counts). - CI families: read from this PR's check-runs. <details><summary>The 62 derived families and their exits</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/check-tenant-audit-census.mjs` :: exit 0 - `node scripts/check-tenant-audit-census.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:pm-changeset-deadline-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:swallow-census-controls` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped run above; 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: 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 (the 维护者速读 block is the dispatch contract's). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2081a5e commit c5ac1b6

5 files changed

Lines changed: 262 additions & 57 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics)!: the draft preview refuses one column asked raw beside its own time bucket, as the live faces do
6+
7+
Clause-②: no (narrowing) — the draft preview (`queryDataset` with `previewDrafts: true`) now refuses `INVALID_QUERY` / 400 a selection that asks one column for two values under one name (a dimension `X` beside a `timeDimensions` entry `OBJECT.X` with a granularity, or two buckets of one column), with the message, `path`, `param`, `object` and `fields` the native and ObjectQL strategies already answer; it served both columns. Nothing is widened: no export, answer member, error code or status is added, and the code was already on the wire.
8+
Metadata-protocol: none
9+
10+
<!-- 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 the package root changes: a selection both live faces already refuse is now refused by the draft preview too, with their refusal. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on the `queryDataset` draft preview (`@objectstack/service-analytics`).
13+
14+
- **Before.** Measured on real SQLite: `dimensions: ['closed_at']` with `timeDimensions: [{ dimension: 'OBJECT.closed_at', granularity: 'month' }]` was refused by both live strategies with the engine's duplicate-column refusal (`groupBy.1.field names the column 'closed_at', which groupBy.0 already names`), while the draft preview answered one row per raw timestamp carrying both columns. The same held for a month and a year bucket of one column.
15+
- **Now.** The preview judges the keys the ObjectQL strategy would hand the engine for the selection, at the same positions, and refuses with the engine's refusal for them, so all three faces answer one `INVALID_QUERY` / 400 with one message.
16+
- **Unchanged.** A raw dimension beside a bucket of a different column, a dimension that is also its own time dimension (one bucket), and two dimensions over one column with no bucket (#28744) are still served on every face. The live refusal does not move.
17+
- **Who sees it.** An author previewing a draft dataset with such a selection: the preview now says what publish would say, instead of a table publish refuses.
18+
19+
**The remedy.** Ask for the column once: as the bucketed time dimension, or as the raw dimension.

‎packages/services/service-analytics/src/__tests__/preview-time-filter-fk-parity.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* value, keyed by both names, as native and the preview do, on its direct
2727
* path and beside a cross-object dimension. Before, it answered
2828
* `INVALID_QUERY` for the same duplicate groupBy column.
29+
* - #28765. A selection that asks one column raw beside its own bucket (or for
30+
* two buckets of it) under one name is refused by the draft preview with the
31+
* live faces' refusal: `INVALID_QUERY` / 400, the same message, path, param,
32+
* object and fields. Before, the preview served both columns.
2933
*/
3034

3135
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -368,4 +372,61 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
368372
});
369373
});
370374
});
375+
describe('#28765 — the draft preview refuses one column asked twice under one name, as the live faces do', () => {
376+
/** Every face refuses `selection`, and the preview's refusal is the ObjectQL face's, field for field. */
377+
const refusedAsLive = async (selection: Selection, path: string) => {
378+
const refusals: Array<Record<string, unknown>> = [];
379+
for (const face of FACES) {
380+
const err = (await ask(face, UNBACKED, selection).then(
381+
() => { throw new Error(`${face}: expected a refusal, but the query resolved`); },
382+
(e: unknown) => e,
383+
)) as Error & Record<string, unknown>;
384+
expect(err.code, `${face}: ${err.message}`).toBe('INVALID_QUERY');
385+
expect(err.status, face).toBe(400);
386+
expect(err.path, face).toBe(path);
387+
refusals.push({
388+
message: err.message, code: err.code, status: err.status, httpStatus: err.httpStatus,
389+
object: err.object, param: err.param, path: err.path, fields: err.fields,
390+
});
391+
}
392+
expect(refusals[2], 'preview vs ObjectQL').toEqual(refusals[1]);
393+
expect(refusals[2], 'preview vs native').toEqual(refusals[0]);
394+
};
395+
396+
it('refuses a column raw beside its own month bucket, where the preview served both', async () => {
397+
await refusedAsLive(
398+
{ dimensions: ['closed_at'], measures: ['total'], timeDimensions: [{ dimension: `${OBJECT}.closed_at`, granularity: 'month' }] },
399+
'groupBy.1.field',
400+
);
401+
});
402+
403+
it('refuses it after another dimension, naming the later position', async () => {
404+
await refusedAsLive(
405+
{ dimensions: ['region', 'closed_at'], measures: ['total'], timeDimensions: [{ dimension: `${OBJECT}.closed_at`, granularity: 'month' }] },
406+
'groupBy.2.field',
407+
);
408+
});
409+
410+
it('refuses two buckets of one column', async () => {
411+
await refusedAsLive(
412+
{
413+
measures: ['total'],
414+
timeDimensions: [
415+
{ dimension: 'closed_at', granularity: 'month' },
416+
{ dimension: `${OBJECT}.closed_at`, granularity: 'year' },
417+
],
418+
},
419+
'groupBy.1.field',
420+
);
421+
});
422+
423+
it('still serves a dimension that is also its own time dimension, as one bucket', async () => {
424+
await answeredAlike(
425+
FACES,
426+
UNBACKED,
427+
{ dimensions: ['closed_at'], measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] },
428+
{ rows: [{ closed_at: '2026-03', total: 100 }, { closed_at: '2026-04', total: 80 }], fields: ['closed_at:time', 'total:number'] },
429+
);
430+
});
431+
});
371432
});

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
type DimensionLabelDeps,
117117
} from './dimension-labels.js';
118118
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
119+
import { assertPreviewGroupColumnsDistinct } from './preview-group-column-door.js';
119120
// [#25778] The one lookup of a dataset duration measure by name.
120121
import { durationMeasureIn } from './duration-measure-sql.js';
121122
import { scopedMeasureFilter } from './scoped-measure-key.js';
@@ -2890,6 +2891,11 @@ export class AnalyticsService implements IAnalyticsService {
28902891
// under one `null` key (and a filter on one matched no row) where
28912892
// the live strategies join it. See {@link assertPreviewJoinsNothing}.
28922893
this.assertPreviewJoinsNothing(q, cube);
2894+
// [#28765] …and a selection asking one column for two values
2895+
// under one name (raw beside its own bucket, or two buckets of
2896+
// it), with the refusal the engine gives both live faces for the
2897+
// same keys, where the evaluator served both columns.
2898+
assertPreviewGroupColumnsDistinct(q, cube);
28932899
return evaluateAnalyticsQueryOverRows(
28942900
q,
28952901
previewEvaluationCube(q, cube),
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28765] The draft preview's copy of the engine's duplicate-group-column
5+
* refusal.
6+
*
7+
* Both live faces hand `engine.aggregate` the `groupBy` the ObjectQL strategy
8+
* builds (the native strategy delegates a bucketed time dimension to it), and
9+
* the engine refuses one naming a column twice: `@objectstack/objectql`'s
10+
* `assertAggregatedColumnsAreDistinct` (`aggregate-query-shape-door.ts`),
11+
* `INVALID_QUERY` / 400. The strategy already names a key once when several
12+
* dimensions read it (#28744), so what still reaches that refusal is a
13+
* selection asking one column for two values under one name: the column raw
14+
* beside its own bucket, or two buckets of it.
15+
*
16+
* The preview evaluates drafted rows in memory and hands no engine anything,
17+
* so it served both columns. It now judges the strategy's own keys
18+
* (`aggregateGroupKeys`, the function the strategy's direct path builds them
19+
* with, so the positions are the engine's) on the object the strategy names
20+
* (`aggregateObjectOf`), and refuses the same way.
21+
*
22+
* Why a copy and not the engine's function: `@objectstack/objectql` is a dev
23+
* dependency of this package, never a runtime one, and its root does not
24+
* export the door. The message, code, status and keys here are the engine's,
25+
* byte for byte, and `preview-time-filter-fk-parity.test.ts` compares the
26+
* preview's refusal with both live faces' field by field, so a change to the
27+
* engine's wording reds that test. Only the `groupBy` half is copied: the
28+
* strategy's keys carry no `alias`, and a measure alias colliding with a group
29+
* column is a different question.
30+
*/
31+
32+
import { StandardErrorCode } from '@objectstack/spec/api';
33+
import type { AnalyticsQuery } from '@objectstack/spec/contracts';
34+
import type { Cube } from '@objectstack/spec/data';
35+
import { aggregateGroupKeys, aggregateObjectOf, type AggregateGroupKey } from './strategies/objectql-strategy.js';
36+
37+
type GroupColumnRefusal = Error & {
38+
code?: string; status?: number; httpStatus?: number;
39+
fields?: string[]; object?: string; param?: string; path?: string;
40+
};
41+
42+
/** The column a key projects, and how the engine names its position. */
43+
function columnOf(key: AggregateGroupKey, i: number): { column: string; path: string } {
44+
return typeof key === 'string'
45+
? { column: key, path: `groupBy.${i}` }
46+
: { column: key.field, path: `groupBy.${i}.field` };
47+
}
48+
49+
/**
50+
* Refuse `query` when two of the keys the ObjectQL strategy would hand the
51+
* engine for it over `cube` project one column, with the engine's refusal for
52+
* it.
53+
*/
54+
export function assertPreviewGroupColumnsDistinct(query: AnalyticsQuery, cube: Cube): void {
55+
const object = aggregateObjectOf(cube);
56+
const groupBy = aggregateGroupKeys(query, cube);
57+
const seen = new Map<string, string>();
58+
for (const [i, key] of groupBy.entries()) {
59+
const { column, path } = columnOf(key, i);
60+
const first = seen.get(column);
61+
if (first === undefined) {
62+
seen.set(column, path);
63+
continue;
64+
}
65+
const err = new Error(
66+
`aggregate('${object}'): ${path} names the column '${column}', which ${first} already names — `
67+
+ `one row cannot carry two values under one column. The query was NOT run. Give one of the group `
68+
+ "entries its own 'alias'. A repeated column name kept only the last value written under it: a "
69+
+ 'count overwritten by a sum, or the group keys replaced by the numbers, while the answer looked served.',
70+
) as GroupColumnRefusal;
71+
err.code = StandardErrorCode.enum.INVALID_QUERY;
72+
err.status = 400;
73+
err.httpStatus = 400;
74+
Object.assign(err, { object, param: 'groupBy', path, fields: [column] });
75+
throw err;
76+
}
77+
}

‎packages/services/service-analytics/src/strategies/objectql-strategy.ts‎

Lines changed: 99 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ function bucketEchoRefused(dimension: string, granularity: string, cause: Bucket
220220
return err;
221221
}
222222

223+
/** One `engine.aggregate` group key: a column, or a column bucketed by a granularity. */
224+
export type AggregateGroupKey = string | { field: string; dateGranularity: string };
225+
223226
/**
224227
* [#28692, #28744] Append `item` to an `engine.aggregate` `groupBy` unless an
225228
* identical key is already there: the same column, or the same column bucketed
@@ -231,17 +234,103 @@ function bucketEchoRefused(dimension: string, granularity: string, cause: Bucket
231234
* two values under one name: the column raw beside its bucket, or two buckets
232235
* of it, are different keys, so both are handed on and the engine refuses them.
233236
*/
234-
function pushGroupKey(
235-
groupBy: Array<string | { field: string; dateGranularity: string }>,
236-
item: string | { field: string; dateGranularity: string },
237-
): void {
237+
function pushGroupKey(groupBy: AggregateGroupKey[], item: AggregateGroupKey): void {
238238
const same = (a: (typeof groupBy)[number]) =>
239239
typeof a === 'string' || typeof item === 'string'
240240
? a === item
241241
: a.field === item.field && a.dateGranularity === item.dateGranularity;
242242
if (!groupBy.some(same)) groupBy.push(item);
243243
}
244244

245+
/**
246+
* The member `member` resolves to in `cube`'s `kind` bag: its own key, a
247+
* `<cube>.`-qualified or other dotted spelling of a key, the key with its dots
248+
* flattened, or (for a dotted dimension) a synthetic relation traversal.
249+
* Module-level because it reads only the cube; `ObjectQLStrategy.lookupMember`
250+
* is this function.
251+
*/
252+
function lookupCubeMember(
253+
cube: Cube,
254+
member: string,
255+
kind: 'dimension' | 'measure',
256+
): { sql: string; type?: string } | undefined {
257+
const bag = kind === 'dimension' ? cube.dimensions : cube.measures;
258+
if (bag[member]) return bag[member];
259+
if (member.includes('.')) {
260+
const [first, ...rest] = member.split('.');
261+
const tail = rest.join('.');
262+
if (first === cube.name && bag[tail]) return bag[tail];
263+
if (bag[tail]) return bag[tail];
264+
const flat = member.replace(/\./g, '_');
265+
if (bag[flat]) return bag[flat];
266+
if (kind === 'dimension') return { sql: member, type: 'string' };
267+
} else if (bag[member]) {
268+
return bag[member];
269+
}
270+
return undefined;
271+
}
272+
273+
/** The column `member` reads on the base object; `ObjectQLStrategy.resolveFieldName` is this function. */
274+
function resolveCubeFieldName(cube: Cube, member: string, kind: 'dimension' | 'measure' | 'any'): string {
275+
if (kind === 'dimension' || kind === 'any') {
276+
const dim = lookupCubeMember(cube, member, 'dimension');
277+
if (dim) return dim.sql.replace(/^\$/, '');
278+
}
279+
if (kind === 'measure' || kind === 'any') {
280+
const measure = lookupCubeMember(cube, member, 'measure');
281+
if (measure) return measure.sql.replace(/^\$/, '');
282+
}
283+
return member.includes('.') ? member.split('.')[1] : member;
284+
}
285+
286+
/** The object an aggregate over `cube` runs on; `ObjectQLStrategy.extractObjectName` is this function. */
287+
export function aggregateObjectOf(cube: Cube): string {
288+
return cube.sql.trim();
289+
}
290+
291+
/**
292+
* The `engine.aggregate` `groupBy` the strategy's direct path hands the engine
293+
* for `query`, in order: one key per `dimensions` entry, then one per bucketed
294+
* `timeDimensions` entry not also listed there, each named once
295+
* ({@link pushGroupKey}).
296+
*
297+
* [#28765] Module-level, and not exported from the package root, so the draft
298+
* preview, which hands no engine anything, judges the same keys at the same
299+
* positions and refuses a selection the engine would refuse with the engine's
300+
* own words (`assertPreviewGroupColumnsDistinct`). It reads only the cube.
301+
*/
302+
export function aggregateGroupKeys(query: AnalyticsQuery, cube: Cube): AggregateGroupKey[] {
303+
// Build groupBy from dimensions, honouring `timeDimensions` granularity.
304+
// A date dimension with a granularity becomes a STRUCTURED groupBy item
305+
// `{ field, dateGranularity }` — which `engine.aggregate()` buckets (the
306+
// driver's own SQL expression, or in memory). Without this the ObjectQL
307+
// path grouped raw timestamps (one bucket per row) and date-bucketed
308+
// dataset widgets never matched their legacy `categoryGranularity`
309+
// counterpart.
310+
const granByDim = new Map<string, string>();
311+
for (const td of query.timeDimensions ?? []) {
312+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
313+
}
314+
// [#28744] Each group key is named once (see {@link pushGroupKey}): two
315+
// dimensions over one column (`region`, and `zone` declared over `region`)
316+
// share its group, and the row mapping in `ObjectQLStrategy.execute` keys
317+
// the answer by every dimension name over it.
318+
const groupBy: AggregateGroupKey[] = [];
319+
if (query.dimensions && query.dimensions.length > 0) {
320+
for (const dim of query.dimensions) {
321+
const field = resolveCubeFieldName(cube, dim, 'dimension');
322+
const gran = granByDim.get(dim);
323+
pushGroupKey(groupBy, gran ? { field, dateGranularity: gran } : field);
324+
granByDim.delete(dim);
325+
}
326+
}
327+
// Time dimensions not also listed in `dimensions` still bucket + group.
328+
for (const [dim, gran] of granByDim) {
329+
pushGroupKey(groupBy, { field: resolveCubeFieldName(cube, dim, 'dimension'), dateGranularity: gran });
330+
}
331+
return groupBy;
332+
}
333+
245334
/**
246335
* ObjectQLStrategy — Priority 2
247336
*
@@ -263,35 +352,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
263352
const cube = ctx.getCube(query.cube!)!;
264353
const objectName = this.extractObjectName(cube);
265354

266-
// Build groupBy from dimensions, honouring `timeDimensions` granularity.
267-
// A date dimension with a granularity becomes a STRUCTURED groupBy item
268-
// `{ field, dateGranularity }` — which `engine.aggregate()` buckets (the
269-
// driver's own SQL expression, or in memory). Without this the ObjectQL
270-
// path grouped raw timestamps (one bucket per row) and date-bucketed
271-
// dataset widgets never matched their legacy `categoryGranularity`
272-
// counterpart.
273-
type GroupByItem = string | { field: string; dateGranularity: string };
274-
const granByDim = new Map<string, string>();
275-
for (const td of query.timeDimensions ?? []) {
276-
if (td.granularity) granByDim.set(td.dimension, td.granularity);
277-
}
278-
// [#28744] Each group key is named once (see {@link pushGroupKey}): two
279-
// dimensions over one column (`region`, and `zone` declared over
280-
// `region`) share its group, and the row mapping below keys the answer by
281-
// every dimension name over it.
282-
const groupBy: GroupByItem[] = [];
283-
if (query.dimensions && query.dimensions.length > 0) {
284-
for (const dim of query.dimensions) {
285-
const field = this.resolveFieldName(cube, dim, 'dimension');
286-
const gran = granByDim.get(dim);
287-
pushGroupKey(groupBy, gran ? { field, dateGranularity: gran } : field);
288-
granByDim.delete(dim);
289-
}
290-
}
291-
// Time dimensions not also listed in `dimensions` still bucket + group.
292-
for (const [dim, gran] of granByDim) {
293-
pushGroupKey(groupBy, { field: this.resolveFieldName(cube, dim, 'dimension'), dateGranularity: gran });
294-
}
355+
const groupBy = aggregateGroupKeys(query, cube);
295356

296357
// The compiled dataset's own scope (#10298 / PR #10758), read ONCE and
297358
// reused below for both the per-measure aggregation filter (#10413 phase
@@ -1701,22 +1762,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
17011762
member: string,
17021763
kind: 'dimension' | 'measure',
17031764
): { sql: string; type?: string } | undefined {
1704-
const bag = kind === 'dimension' ? cube.dimensions : cube.measures;
1705-
if (bag[member]) return bag[member];
1706-
if (member.includes('.')) {
1707-
const [first, ...rest] = member.split('.');
1708-
const tail = rest.join('.');
1709-
if (first === cube.name && bag[tail]) return bag[tail];
1710-
if (bag[tail]) return bag[tail];
1711-
const flat = member.replace(/\./g, '_');
1712-
if (bag[flat]) return bag[flat];
1713-
if (kind === 'dimension') return { sql: member, type: 'string' };
1714-
} else if (bag[member]) {
1715-
return bag[member];
1716-
}
1717-
return undefined;
1765+
return lookupCubeMember(cube, member, kind);
17181766
}
17191767

1768+
17201769
/**
17211770
* [#14079] The (object, field) a filter member binds against — the echo's
17221771
* copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
@@ -1747,17 +1796,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
17471796
}
17481797

17491798
private resolveFieldName(cube: Cube, member: string, kind: 'dimension' | 'measure' | 'any'): string {
1750-
if (kind === 'dimension' || kind === 'any') {
1751-
const dim = this.lookupMember(cube, member, 'dimension');
1752-
if (dim) return dim.sql.replace(/^\$/, '');
1753-
}
1754-
if (kind === 'measure' || kind === 'any') {
1755-
const measure = this.lookupMember(cube, member, 'measure');
1756-
if (measure) return measure.sql.replace(/^\$/, '');
1757-
}
1758-
return member.includes('.') ? member.split('.')[1] : member;
1799+
return resolveCubeFieldName(cube, member, kind);
17591800
}
17601801

1802+
17611803
private resolveMeasureAggregation(cube: Cube, measureName: string): { field: string; method: AggregationFunction } {
17621804
const direct = this.lookupMember(cube, measureName, 'measure') as
17631805
| { sql: string; type: string }
@@ -2307,7 +2349,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
23072349
}
23082350

23092351
private extractObjectName(cube: Cube): string {
2310-
return cube.sql.trim();
2352+
return aggregateObjectOf(cube);
23112353
}
23122354

23132355
/**

0 commit comments

Comments
 (0)