Skip to content

Commit 52de5a5

Browse files
fix(service-analytics): the ObjectQL strategy serves two dataset dimensions over one column (#28762)
domain:server#3 · os-project-manager · session_01FEkQ5kbUJTTWAxjV9nqr1P <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB1hsVtuncZ5F5C4zpiftHC)_ Fixes #28744 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` says the ObjectQL strategy refuses two dimensions over one column. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:一个数据集把同一列暴露成两个维度(如 `region`,以及声明在 `region` 上的 `zone`),同时按这两个维度查询时,ObjectQL 策略现在只对这一列分组一次,结果按两个维度名都回填,与原生 SQL 和草稿预览一致。直连路径、带月份桶、带跨对象维度(`account.name`)三种情况都覆盖。顺带并入上次评审的 S1(`analytics-service.ts` 里三份相同的 where 叶子读取合成一个 `whereLeafMembers`)和 S2(补一条「按度量键筛选」的测试)。 - **为什么改**:卡 #28744(p3),是 PR #28742 开发时实测到的同族缺口:同一个查询原生面能答,ObjectQL 面 400 `INVALID_QUERY`。 - **风险与回滚**:只放宽:之前 400 的现在服务。引擎的重复列拒绝不动(`packages/objectql` 未改),同一列的原值和它的月份桶仍是两个键,引擎照旧拒绝,有测试钉住。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/strategies/objectql-strategy.ts` 新的 `pushGroupKey` 及 `execute`、`executeCrossObject` 两处调用;`analytics-service.ts` 的 `whereLeafMembers`。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/preview-time-filter-fk-parity.test.ts`,预期 23 passed。 - **需要你做的**:无,席位落地。 ## What changes - **#28744 — `strategies/objectql-strategy.ts`.** A module-level `pushGroupKey(groupBy, item)` appends a group key unless an identical one is already there: the same column, or the same column bucketed at the same granularity. The direct path (`execute`) and the cross-object path (`executeCrossObject`) both build their `groupBy` through it. Two dataset dimensions over one column share its group, and the row mapping that already reads each projected dimension from its resolved column keys the answer by both names, so no re-key step was needed. In the cross-object path the helper replaces #28692's two FK-only checks (an FK column under two cross dimensions, a base dimension over the FK column), which are the same rule. - **What stays refused.** A column raw beside its own bucket (`closed_at` with `OBJECT.closed_at` bucketed by month) is two different keys under one output name, so both are handed on and the engine's duplicate-column refusal answers `INVALID_QUERY` / 400 on both live faces, as before. Nothing in `packages/objectql` changes. - **Review S1 — `analytics-service.ts`.** One `whereLeafMembers(where)` reader (the guarded `collectFilterLeaves(normalizeAnalyticsFilterTree(...))` read) for `namedQueryFields`, `relationshipMembers` and `previewWhereColumns`, in place of three copies. Same behaviour: a non-object or refused `where` names nothing. The fourth read in the `where` field gate passes the whole query and returns early on a refusal, so it is left as it is. - **Review S2 — test.** A `runtimeFilter` on the measure key `total` (over `amount`) is read as `amount > 60` on every face, the `'any'` arm of `declaredMemberEntry` that PR #28742 added for the preview. - Tests: six cases added to `src/__tests__/preview-time-filter-fk-parity.test.ts` (now 23, real SQLite). - Changeset `.changeset/28744-objectql-two-dimensions-one-column.md`, `@objectstack/service-analytics` patch. ## Why Measured on real SQLite (the #28201 / #28555 / #28690 harness), dataset over a deal object with dimensions `region` and `zone` (both over `region`), rows amer 100, amer 50, emea 30: | selection | native | ObjectQL before | preview | ObjectQL after | |:--|:--|:--|:--|:--| | dims `region`, `zone` (either order) | amer 150 / emea 30, keyed by both | `INVALID_QUERY` (groupBy.1 names 'region') | same as native | same as native | | …with td `closed_at` month | three groups | `INVALID_QUERY` | same as native | same as native | | backed, with `account.name` | three groups | `INVALID_QUERY` | refused (#28555) | same as native | | `closed_at` raw + `OBJECT.closed_at` month | `INVALID_QUERY` | `INVALID_QUERY` | serves | `INVALID_QUERY` (unchanged) | ## Tests <details><summary>Red first, green, ablations</summary> - Red on base: `objectql-strategy.ts` restored to `272fec05` (the file is unchanged up to `origin/main` `7a48bf73`), tests at HEAD: `Tests 4 failed | 19 passed (23)`; the four #28744 cases fail on the ObjectQL leg with the engine's duplicate-column message. Restored with `git checkout HEAD --`, `git status` clean. The S2 case is green on base too (PR #28742 shipped that arm); its red leg is A-S2 below. - Green at `cb122c70`: `23 passed (23)`. - Ablations at `cb122c70` (`node scripts/ablation-replace.mjs`, fix committed, each anchor 1 → 0, restore blob == HEAD, `git diff HEAD` empty; the suite imports `src`): - B1 the direct path's `pushGroupKey` back to `groupBy.push`: `3 failed | 20 passed` (the three direct-path #28744 cases). - B2 the cross path's base-dimension `pushGroupKey` back to `groupBy.push`: `2 failed | 21 passed` (`account` after `account.name`, #28744 beside `account.name`). - B3 `pushGroupKey` appending unconditionally: `7 failed | 16 passed` (every shared-column case of #28692 and #28744). - B4 key identity by column only, so a raw column and its bucket merge: `1 failed | 22 passed` (the raw-beside-bucket refusal pin). - A-S2 `--delete` the `previewWhereColumns(q, cube)` argument: `8 failed | 15 passed` (the S2 case among the #28691 cases). - `pnpm --filter @objectstack/service-analytics exec vitest run`: `Test Files 203 passed (203)`, `Tests 4862 passed | 282 skipped` (on `c8c555f7`, before the rebase onto `7a48bf73`, which touches no service-analytics file). - `pnpm --filter @objectstack/rest exec vitest run src/analytics` at `cb122c70` after a full build: `Test Files 23 passed (23)`, `Tests 298 passed | 22 skipped`. - `pnpm --filter @objectstack/service-analytics typecheck` at `cb122c70` exit 0; `eslint` on the three touched source and test files exit 0. </details> ## Acceptance notes - The order's hypothesis held for the groupBy and not for the re-key: the direct path's row mapping already reads every projected dimension from its resolved column, so once the column is grouped once, both names are filled. No re-key map was added. - S1 and S2 are folded in as the order allowed. S1 is in `analytics-service.ts`, not the strategy file, but it is three call sites in one file and behaviour-neutral (full suite green). - Out of scope, measured, not fixed: for `closed_at` raw beside `OBJECT.closed_at` bucketed by month, both live faces refuse `INVALID_QUERY` / 400 (two values under one name), while the draft preview serves both columns. Reported to the seat. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 families derived on `cb122c70` (4 paths vs merge base `7a48bf73`); each run with stdin from `/dev/null`, exit captured before any pipe, after a full `turbo run build --filter=!@objectstack/docs --concurrency=2` (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: not run. Clause-② is widening only, with no `!` and no narrowing arm. - 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 60d8522 commit 52de5a5

4 files changed

Lines changed: 141 additions & 39 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the ObjectQL strategy serves two dataset dimensions over one column
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
A dataset that exposes one column under two dimension names (`region`, and `zone` declared over `region`) and is queried with both is now served by the ObjectQL strategy with one group per value, keyed by both names, as the native strategy and the draft preview answer it. This holds on the strategy's direct path, beside a bucketed time dimension, and beside a cross-object dimension such as `account.name`. Before, the strategy handed the engine a `groupBy` naming the column twice and answered `INVALID_QUERY` / 400, measured on real SQLite.
11+
12+
The strategy now names each group key once: the same column, or the same column bucketed at the same granularity. The engine's duplicate-column refusal is unchanged, and it still answers a selection that asks for two values under one name, such as a column raw beside its own month bucket. Nothing is refused that was served, and no export, key, error code or status is added.

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
* two dimensions through one relationship, with the native strategy's
2222
* groups. Before, it handed the engine a groupBy naming one column twice and
2323
* answered `INVALID_QUERY`.
24+
* - #28744. The ObjectQL strategy answers two dataset dimensions over one
25+
* column (`region`, and `zone` declared over `region`) with one group per
26+
* value, keyed by both names, as native and the preview do, on its direct
27+
* path and beside a cross-object dimension. Before, it answered
28+
* `INVALID_QUERY` for the same duplicate groupBy column.
2429
*/
2530

2631
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -248,6 +253,15 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
248253
);
249254
});
250255

256+
it('reads a filter on a measure key from the measure\'s column, as the live faces resolve it', async () => {
257+
// `total` is a measure over `amount`; the strategies' filter-member
258+
// lookup falls through to the measures, so the row filter is `amount > 60`.
259+
await answeredAlike(FACES, UNBACKED, { dimensions: ['region'], measures: ['total'], runtimeFilter: { total: { $gt: 60 } } }, {
260+
rows: [{ region: 'amer', total: 100 }],
261+
fields: ['region:string', 'total:number'],
262+
});
263+
});
264+
251265
it('filters on zone beside a time-dimension-only bucket', async () => {
252266
await answeredAlike(
253267
FACES,
@@ -297,4 +311,61 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
297311
});
298312
});
299313
});
314+
describe('#28744 — the ObjectQL strategy groups a column once when two dimensions read it', () => {
315+
it.each([
316+
['region, zone', ['region', 'zone']],
317+
['zone, region', ['zone', 'region']],
318+
])('serves dimensions %s over one column with one group per value, where it answered INVALID_QUERY', async (_label, dimensions) => {
319+
await answeredAlike(FACES, UNBACKED, { dimensions, measures: ['total'] }, {
320+
rows: [
321+
{ region: 'amer', zone: 'amer', total: 150 },
322+
{ region: 'emea', zone: 'emea', total: 30 },
323+
],
324+
fields: [...dimensions.map((d) => `${d}:string`), 'total:number'],
325+
});
326+
});
327+
328+
it('serves them beside a month bucket', async () => {
329+
await answeredAlike(
330+
FACES,
331+
UNBACKED,
332+
{ dimensions: ['region', 'zone'], measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] },
333+
{
334+
rows: [
335+
{ region: 'amer', zone: 'amer', closed_at: '2026-03', total: 100 },
336+
{ region: 'amer', zone: 'amer', closed_at: '2026-04', total: 50 },
337+
{ region: 'emea', zone: 'emea', closed_at: '2026-04', total: 30 },
338+
],
339+
fields: ['region:string', 'zone:string', 'closed_at:time', 'total:number'],
340+
},
341+
);
342+
});
343+
344+
it("still hands the engine a column raw beside its bucket, which it refuses (two values under one name)", async () => {
345+
// `closed_at` raw and `OBJECT.closed_at` bucketed by month are different
346+
// keys over one column: nothing is merged, and the engine's
347+
// duplicate-column refusal stands on both live faces.
348+
for (const face of LIVE) {
349+
await expect(
350+
ask(face, UNBACKED, {
351+
dimensions: ['closed_at'],
352+
measures: ['total'],
353+
timeDimensions: [{ dimension: `${OBJECT}.closed_at`, granularity: 'month' }],
354+
}),
355+
face,
356+
).rejects.toMatchObject({ code: 'INVALID_QUERY', status: 400 });
357+
}
358+
});
359+
360+
it("serves them beside a cross-object 'account.name' dimension", async () => {
361+
await answeredAlike(LIVE, BACKED, { dimensions: ['region', 'zone', 'account.name'], measures: ['total'] }, {
362+
rows: [
363+
{ region: 'amer', zone: 'amer', 'account.name': 'Acme', total: 100 },
364+
{ region: 'amer', zone: 'amer', 'account.name': 'Globex', total: 50 },
365+
{ region: 'emea', zone: 'emea', 'account.name': 'Acme', total: 30 },
366+
],
367+
fields: ['region:string', 'zone:string', 'account.name:string', 'total:number'],
368+
});
369+
});
370+
});
300371
});

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

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,25 @@ const RELATIONSHIP_MEMBER_NOUN: Readonly<Record<RelationshipMember['param'], str
452452
where: 'Filter member',
453453
};
454454

455+
/**
456+
* The member each leaf of a `where` tree binds against, in tree order, read
457+
* through the lowering both strategies compile with
458+
* (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`), structure
459+
* discarded. A `where` that is not an object, or that the lowering refuses,
460+
* names nothing here: it is refused where it already is. One reader for
461+
* {@link namedQueryFields}, {@link relationshipMembers} and
462+
* {@link previewWhereColumns}, so what each of them judges is the same leaf
463+
* list.
464+
*/
465+
function whereLeafMembers(where: unknown): string[] {
466+
if (!where || typeof where !== 'object') return [];
467+
try {
468+
return collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member);
469+
} catch {
470+
return [];
471+
}
472+
}
473+
455474
/**
456475
* [#28555] Every entry of `query` that reads a column through a relationship
457476
* path, in request order: `dimensions`, `timeDimensions`, then (with
@@ -483,16 +502,7 @@ function relationshipMembers(query: AnalyticsQuery, cube: Cube, withMeasures: bo
483502
for (const member of query.dimensions ?? []) read(member, 'dimensions', 'dimension');
484503
for (const td of query.timeDimensions ?? []) read(td?.dimension, 'timeDimensions', 'any');
485504
if (withMeasures) for (const member of query.measures ?? []) read(member, 'measures', 'measure');
486-
const where = (query as { where?: unknown }).where;
487-
if (where && typeof where === 'object') {
488-
let leaves: string[] = [];
489-
try {
490-
leaves = collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member);
491-
} catch {
492-
leaves = [];
493-
}
494-
for (const member of leaves) read(member, 'where', 'any');
495-
}
505+
for (const member of whereLeafMembers((query as { where?: unknown }).where)) read(member, 'where', 'any');
496506
return out;
497507
}
498508

@@ -630,23 +640,14 @@ function namedQueryFields(
630640
}
631641
return true;
632642
};
633-
const filterMembers = (where: unknown): string[] => {
634-
if (!where || typeof where !== 'object') return [];
635-
try {
636-
return collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member);
637-
} catch {
638-
return [];
639-
}
640-
};
641-
642643
for (const member of query.dimensions ?? []) name(member, 'dimension', 'aggregate');
643644
for (const td of query.timeDimensions ?? []) name(td.dimension, 'any', td.granularity ? 'aggregate' : 'predicate');
644645
for (const member of query.measures ?? []) if (!durationColumns(member, 'aggregate')) name(member, 'measure', 'aggregate');
645-
for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate');
646+
for (const member of whereLeafMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate');
646647
for (const measure of query.measures ?? []) {
647-
for (const member of filterMembers(scopedMeasureFilter(datasetScope, measure, cube.name))) name(member, 'any', 'predicate');
648+
for (const member of whereLeafMembers(scopedMeasureFilter(datasetScope, measure, cube.name))) name(member, 'any', 'predicate');
648649
}
649-
for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate');
650+
for (const member of whereLeafMembers(datasetScope?.filter)) name(member, 'any', 'predicate');
650651
const order = (query as { order?: unknown }).order;
651652
if (order && typeof order === 'object' && !Array.isArray(order)) {
652653
// An order key naming both a duration measure and a declared member is
@@ -4798,16 +4799,8 @@ function previewEvaluationCube(query: AnalyticsQuery, cube: Cube): Cube {
47984799
* before the evaluator runs.
47994800
*/
48004801
function previewWhereColumns(query: AnalyticsQuery, cube: Cube): Record<string, string> {
4801-
const where = (query as { where?: unknown }).where;
4802-
if (!where || typeof where !== 'object') return {};
4803-
let leaves: string[];
4804-
try {
4805-
leaves = collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member);
4806-
} catch {
4807-
return {};
4808-
}
48094802
const out: Record<string, string> = {};
4810-
for (const member of leaves) {
4803+
for (const member of whereLeafMembers((query as { where?: unknown }).where)) {
48114804
if (out[member] !== undefined) continue;
48124805
const entry = declaredMemberEntry(cube, member, 'any');
48134806
if (!entry) continue;

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

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

223+
/**
224+
* [#28692, #28744] Append `item` to an `engine.aggregate` `groupBy` unless an
225+
* identical key is already there: the same column, or the same column bucketed
226+
* at the same granularity. Several dimensions can read one column (two dataset
227+
* dimensions declared over it, a relationship's FK column under each dimension
228+
* through it), and they share its one group, which every dimension name is
229+
* mapped from afterwards. The engine refuses a `groupBy` naming a column twice
230+
* (`INVALID_QUERY`), and that refusal stands for a caller that really asks for
231+
* two values under one name: the column raw beside its bucket, or two buckets
232+
* of it, are different keys, so both are handed on and the engine refuses them.
233+
*/
234+
function pushGroupKey(
235+
groupBy: Array<string | { field: string; dateGranularity: string }>,
236+
item: string | { field: string; dateGranularity: string },
237+
): void {
238+
const same = (a: (typeof groupBy)[number]) =>
239+
typeof a === 'string' || typeof item === 'string'
240+
? a === item
241+
: a.field === item.field && a.dateGranularity === item.dateGranularity;
242+
if (!groupBy.some(same)) groupBy.push(item);
243+
}
244+
223245
/**
224246
* ObjectQLStrategy — Priority 2
225247
*
@@ -253,18 +275,22 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
253275
for (const td of query.timeDimensions ?? []) {
254276
if (td.granularity) granByDim.set(td.dimension, td.granularity);
255277
}
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.
256282
const groupBy: GroupByItem[] = [];
257283
if (query.dimensions && query.dimensions.length > 0) {
258284
for (const dim of query.dimensions) {
259285
const field = this.resolveFieldName(cube, dim, 'dimension');
260286
const gran = granByDim.get(dim);
261-
groupBy.push(gran ? { field, dateGranularity: gran } : field);
287+
pushGroupKey(groupBy, gran ? { field, dateGranularity: gran } : field);
262288
granByDim.delete(dim);
263289
}
264290
}
265291
// Time dimensions not also listed in `dimensions` still bucket + group.
266292
for (const [dim, gran] of granByDim) {
267-
groupBy.push({ field: this.resolveFieldName(cube, dim, 'dimension'), dateGranularity: gran });
293+
pushGroupKey(groupBy, { field: this.resolveFieldName(cube, dim, 'dimension'), dateGranularity: gran });
268294
}
269295

270296
// The compiled dataset's own scope (#10298 / PR #10758), read ONCE and
@@ -1345,21 +1371,21 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
13451371
// share it, and the engine refuses a groupBy naming a column twice
13461372
// (`INVALID_QUERY`), where the native strategy served the groups. The
13471373
// re-bucket reads the one column for each dimension.
1348-
if (!groupBy.includes(cd.fkField)) groupBy.push(cd.fkField);
1374+
pushGroupKey(groupBy, cd.fkField);
13491375
continue;
13501376
}
13511377
const field = this.resolveFieldName(cube, dim, 'dimension');
13521378
const gran = granByDim.get(dim);
1353-
// …and a base dimension over that FK column (`account` after
1354-
// `account.name`) reads the one already grouped.
1355-
const fkGrouped = !gran && groupBy.includes(field) && plan.crossDims.some((c) => c.fkField === field);
1356-
if (!fkGrouped) groupBy.push(gran ? { field, dateGranularity: gran } : field);
1379+
// …and so is a base column two dimensions read: the FK column itself
1380+
// (`account` beside `account.name`), or one column under two dimension
1381+
// names (`region`, `zone` over `region`, #28744).
1382+
pushGroupKey(groupBy, gran ? { field, dateGranularity: gran } : field);
13571383
baseDimFields.push(field);
13581384
granByDim.delete(dim);
13591385
}
13601386
for (const [dim, gran] of granByDim) {
13611387
const field = this.resolveFieldName(cube, dim, 'dimension');
1362-
groupBy.push({ field, dateGranularity: gran });
1388+
pushGroupKey(groupBy, { field, dateGranularity: gran });
13631389
baseDimFields.push(field);
13641390
}
13651391

0 commit comments

Comments
 (0)