Skip to content

Commit 7e60dc2

Browse files
fix(service-analytics): a dataset selection asking one column twice is refused with a repair the selection can make, not the engine's "own 'alias'" (#28851)
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 #28798 Clause-②: no — the accept set of `queryDataset` is unchanged on every face: each selection served on `main` is served, each refused is refused with status 400. Only the refusal's prescription moves, and the ObjectQL strategy now refuses these keys itself, right before `engine.aggregate`. Nothing is widened: no package-root export, public member, answer member, error code or status is added. Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` quotes the duplicate-column refusal or tells a dataset author to alias a group entry. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:数据集查询里同一列被要两次(原始列加它自己的时间桶,或同一列的两个粒度),三条执行面(原生 SQL、ObjectQL、草稿预览)现在用同一个数据集拒绝回答。`code`、状态码、`param`、`path`、`object`、`fields` 都不变,只换处方:不再叫作者「给分组项起一个 alias」(数据集选择里没有 alias),而是点名选择里涉及这一列的成员,并给出能做的修法:去掉原始维度或去掉那个时间桶的粒度;两个粒度时只留一个。 - **为什么改**:卡 #28798(p3),是 PR #28794 合约评审的 should-fix 2。席位裁定:预览门变成数据集面唯一的拒绝,ObjectQL 策略在 `engine.aggregate` 之前调用它;引擎的文字不动。 - **风险与回滚**:接受集合不变(之前服务的仍服务,之前拒绝的仍 400 拒绝),只是拒绝文字变了。有一处要你知道:一个选择同时犯两个错(既把文本列按月分桶,又把同一列要两次)时,两个线上面以前答引擎的「非日期列不能分桶」(`INVALID_FIELD`),现在答本拒绝(`INVALID_QUERY`),与草稿预览原本的回答一致。见下文「一处优先级变化」。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/dataset-group-column-door.ts`(由 `preview-group-column-door.ts` 改名重写),`strategies/objectql-strategy.ts` 中 `execute` 和 `executeCrossObject` 在 `ctx.executeAggregate` 前各一行调用。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/preview-time-filter-fk-parity.test.ts`,预期 37 passed。 - **需要你做的**:无,席位落地;那处优先级变化由席位裁定。 ## What changes - **One refusal for every dataset face.** `preview-group-column-door.ts` becomes `dataset-group-column-door.ts`, and `assertPreviewGroupColumnsDistinct(query, cube)` becomes `assertDatasetGroupColumnsDistinct(object, groupBy, query, memberColumn)`. It judges the `groupBy` keys it is handed, so each caller hands it the keys it built: - `ObjectQLStrategy.execute` calls it on `aggregateGroupKeys(query, cube)`, right before `ctx.executeAggregate`; - `executeCrossObject` calls it on its own keys (an FK column for a cross-object dimension), right before its base `ctx.executeAggregate`; - the draft preview calls it where it called the old door, on `aggregateGroupKeys(q, cube)` over `aggregateObjectOf(cube)`. Each call sits right before the engine call, so every refusal the strategy gave before keeps its precedence. - **The sentence.** The head and tail are the engine's, so a reader of both sees the same shape. Only the prescription between them differs, and it names the selection's members over the column, read with the strategy's `resolveCubeFieldName`: - raw beside its bucket (either key the bare column): "The selection asks for 'closed_at' both raw and bucketed (`dimensions` 'closed_at'; `timeDimensions` 'OBJECT.closed_at' by month). Drop the raw dimension from `dimensions`, or drop the granularity from the `timeDimensions` entry." - two buckets: "The selection asks for 'closed_at' at two granularities (`timeDimensions` 'closed_at' by month, 'closed_at' by year). Ask one granularity of it in `timeDimensions`." A dimension bucketed by its own `timeDimensions` entry is not listed as raw. "Drop the granularity" rather than "drop the entry", because a `timeDimensions` entry without a granularity still windows the query. - **Surface.** `resolveCubeFieldName` becomes a module-level export of `objectql-strategy.ts`, as `aggregateGroupKeys` and `aggregateObjectOf` already are. The package root re-exports `ObjectQLStrategy` by name only, so the root surface is unchanged. `ObjectQLStrategy` gains no member. The door imports only a type from the strategy module, so there is no runtime import cycle. - **Untouched.** `packages/objectql` is untouched, and a direct `engine.aggregate` caller still gets the engine's "own 'alias'" prescription (pinned). `generateSql` (`/analytics/sql`, #28841) is untouched. - Tests: `src/__tests__/preview-time-filter-fk-parity.test.ts` (34 → 37 cases). Changeset: `.changeset/28798-dataset-column-twice-refusal-prescription.md`, `@objectstack/service-analytics` patch. ## Why Measured on `origin/main` `fa59f787` with a probe over real SQLite (all three faces): - **Every duplicate reaches the strategy.** Two keys over one column need a bucket, because the strategy names identical keys once (#28744). The native strategy declines every bucketed time dimension, so both live faces answered from the ObjectQL strategy's call into the engine. There is no native-own refusal on any of these selections. This held for raw beside a bucket of another spelling, a second dataset dimension over the column, and two buckets. - **Every face prescribed the alias.** All three answered `… The query was NOT run. Give one of the group entries its own 'alias'. …`, and a dataset selection has no alias. - **A direct `engine.aggregate`** with `['closed_at', { field: 'closed_at', dateGranularity: 'month' }]` answers the same refusal. That is the backstop this change leaves in place. ### One precedence change (for the seat's ruling) The engine runs its other `groupBy` doors before the duplicate-column door. One of them refuses a date bucket over a non-date column (`INVALID_FIELD`). Measured on `main`, the selection `dimensions: ['region']` with `timeDimensions: [{ dimension: 'zone', granularity: 'month' }]` (`zone` is declared over the text column `region`) answered: - both live faces: `INVALID_FIELD`, `groupBy.1.dateGranularity`; - the draft preview: the column refusal. With the door before `engine.aggregate`, as ruled, all three faces now answer the column refusal (`INVALID_QUERY`, `groupBy.1.field`). It is still a 400 refusal, and fixing it meets the other refusal. The new pin `[#28798] answers it on every face before the engine judges a bucket over a text column` holds this. No other measured selection changed. The os-dev-report carries this as an open question with options. ## Tests <details><summary>Red first, green, ablations</summary> - **Red on base.** `analytics-service.ts`, `strategies/objectql-strategy.ts` and `preview-group-column-door.ts` were restored to `fa59f787`, and the new door file was moved aside. The test stayed at HEAD. Result: `Tests 11 failed | 26 passed (37)`. - Every refusal case failed on the message: `Received: "… Give one of the group entries its own 'alias'. …"`. - The precedence pin failed on the live faces' `INVALID_FIELD`. - The direct-engine pin passed. Restored with `git checkout HEAD --`, then `git status` was clean. - **Green.** `37 passed (37)`. The full service-analytics suite gave `Test Files 203 passed (203)`, `Tests 4876 passed | 282 skipped`. `tsc --noEmit` and scoped eslint both exited 0. - **Ablations** used `node scripts/ablation-replace.mjs` with the fix committed first. Each anchor went 1 → 0, and each restore came back with blob == HEAD and an empty `git diff HEAD`: - A1: delete the `execute` call, so the engine answers again. Result: `9 failed | 27 passed`, every live-face refusal pin on the direct path. - A2: delete the `executeCrossObject` call. Result: `1 failed | 35 passed`, the cross-object pair. - A3: make the preview's call a no-op. Result: `9 failed | 27 passed`, because the preview serves both columns again. - A4: always use the two-granularity sentence. Result: `3 failed | 33 passed`, the raw-beside-bucket cases. - A5: list a dimension bucketed by its own entry as raw. Result: `1 failed | 36 passed`. - A6: compare a `timeDimensions` member's spelling instead of its column. Result: `5 failed | 32 passed`. </details> ## Out of scope (reported to the seat, not fixed) - On `main`, the draft preview serves a bucket over a text column (`timeDimensions: [{ dimension: 'zone', granularity: 'month' }]`) as one `null` bucket holding the total. Both live faces refuse it with `INVALID_FIELD`. That is a separate preview parity gap. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7a6cc7 commit 7e60dc2

6 files changed

Lines changed: 252 additions & 105 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a dataset selection asking one column twice is refused with a repair the selection can make, not the engine's "own 'alias'"
6+
7+
Clause-②: no — the accept set of `queryDataset` is unchanged on every face: each selection served on `main` is served, each refused is refused with status 400. Only the refusal's prescription moves, and the ObjectQL strategy now refuses these keys itself, right before `engine.aggregate`. Nothing is widened: no export, public member, answer member, error code or status is added.
8+
Metadata-protocol: none
9+
10+
A `queryDataset` selection that asks one column for two values under one name (the column raw beside its own bucket, such as `dimensions: ['closed_at']` with `timeDimensions: [{ dimension: 'OBJECT.closed_at', granularity: 'month' }]`, or two buckets of it) is refused `INVALID_QUERY` / 400 on the native strategy, the ObjectQL strategy and the draft preview, with the same `param`, `path`, `object` and `fields` as before. The message used to be the engine's, which tells the caller to "give one of the group entries its own 'alias'"; a dataset selection has no alias. It now names the selection's members over the column and a repair:
11+
12+
- raw beside its bucket: "The selection asks for 'closed_at' both raw and bucketed (`dimensions` 'closed_at'; `timeDimensions` 'OBJECT.closed_at' by month). Drop the raw dimension from `dimensions`, or drop the granularity from the `timeDimensions` entry."
13+
- two buckets: "The selection asks for 'closed_at' at two granularities (`timeDimensions` 'closed_at' by month, 'closed_at' by year). Ask one granularity of it in `timeDimensions`."
14+
15+
The three faces answer one refusal, `dataset-group-column-door.ts` (formerly the draft preview's copy of the engine's): the ObjectQL strategy calls it on the keys it built on its direct and cross-object paths, and the preview on the keys the direct path would build. The engine's own refusal in `@objectstack/objectql` is unchanged, and a direct `engine.aggregate` caller, who can alias, still gets its prescription.
16+
17+
One selection carrying two faults answers the other one now. Example: `dimensions: ['region']` beside `timeDimensions: [{ dimension: 'zone', granularity: 'month' }]`, where `zone` is declared over the text column `region`. The engine judges a bucket over a text column (`INVALID_FIELD`) before a column named twice, and both live faces answered that. They now answer the column refusal, as the draft preview already did. It is still a 400 refusal, and fixing it meets the other one.

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

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
* with that refusal. Before, all three faces kept the last granularity and
3636
* dropped the other entry silently (beside the raw dimension, the live faces
3737
* answered one `1970-01` bucket holding the whole total).
38+
* - #28798. All three faces answer those selections with one dataset refusal,
39+
* the ObjectQL strategy's, before the engine is asked: the engine's `code`,
40+
* status, `param`, `path`, `object` and `fields`, and a prescription naming
41+
* the selection's members over the column and a repair a selection can make
42+
* (drop the raw dimension or its bucket's granularity; ask one granularity).
43+
* Before, every face prescribed the engine's "own 'alias'", which no dataset
44+
* selection can spell. A direct `engine.aggregate` caller keeps that one.
3845
*/
3946

4047
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -350,10 +357,10 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
350357
);
351358
});
352359

353-
it("still hands the engine a column raw beside its bucket, which it refuses (two values under one name)", async () => {
360+
it("still refuses a column raw beside its bucket (two values under one name)", async () => {
354361
// `closed_at` raw and `OBJECT.closed_at` bucketed by month are different
355-
// keys over one column: nothing is merged, and the engine's
356-
// duplicate-column refusal stands on both live faces.
362+
// keys over one column: nothing is merged, and the duplicate-column
363+
// refusal (the dataset's since #28798) stands on both live faces.
357364
for (const face of LIVE) {
358365
await expect(
359366
ask(face, UNBACKED, {
@@ -377,38 +384,76 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
377384
});
378385
});
379386
});
380-
describe('#28765 — the draft preview refuses one column asked twice under one name, as the live faces do', () => {
381-
/** Every face refuses `selection`, and the preview's refusal is the ObjectQL face's, field for field. */
382-
const refusedAsLive = async (selection: Selection, path: string) => {
387+
describe('#28765 / #28798 — every face refuses one column asked twice under one name, in the dataset\'s words', () => {
388+
/** The refusal, with the prescription `remedy` between the engine's head and tail. */
389+
const askedTwice = (path: string, first: string, column: string, remedy: string) =>
390+
`aggregate('${OBJECT}'): ${path} names the column '${column}', which ${first} already names — one row cannot `
391+
+ `carry two values under one column. The query was NOT run. ${remedy} A repeated column name kept only the `
392+
+ 'last value written under it: a count overwritten by a sum, or the group keys replaced by the numbers, '
393+
+ 'while the answer looked served.';
394+
const rawAndBucketed = (dimensions: string, timeDimensions: string) =>
395+
`The selection asks for 'closed_at' both raw and bucketed (\`dimensions\` ${dimensions}; \`timeDimensions\` `
396+
+ `${timeDimensions}). Drop the raw dimension from \`dimensions\`, or drop the granularity from the `
397+
+ '`timeDimensions` entry.';
398+
const twoGranularities = (timeDimensions: string) =>
399+
`The selection asks for 'closed_at' at two granularities (\`timeDimensions\` ${timeDimensions}). Ask one `
400+
+ 'granularity of it in `timeDimensions`.';
401+
402+
/** Every face in `faces` refuses `selection` with `message`, and with one refusal, field for field. */
403+
const refusedAsLive = async (
404+
selection: Selection,
405+
path: string,
406+
message: string,
407+
{ dataset = UNBACKED, faces = FACES }: { dataset?: typeof UNBACKED; faces?: readonly Face[] } = {},
408+
) => {
383409
const refusals: Array<Record<string, unknown>> = [];
384-
for (const face of FACES) {
385-
const err = (await ask(face, UNBACKED, selection).then(
410+
for (const face of faces) {
411+
const err = (await ask(face, dataset, selection).then(
386412
() => { throw new Error(`${face}: expected a refusal, but the query resolved`); },
387413
(e: unknown) => e,
388414
)) as Error & Record<string, unknown>;
389415
expect(err.code, `${face}: ${err.message}`).toBe('INVALID_QUERY');
390416
expect(err.status, face).toBe(400);
391417
expect(err.path, face).toBe(path);
418+
expect(err.message, face).toBe(message);
392419
refusals.push({
393420
message: err.message, code: err.code, status: err.status, httpStatus: err.httpStatus,
394421
object: err.object, param: err.param, path: err.path, fields: err.fields,
395422
});
396423
}
397-
expect(refusals[2], 'preview vs ObjectQL').toEqual(refusals[1]);
398-
expect(refusals[2], 'preview vs native').toEqual(refusals[0]);
424+
for (const [i, refusal] of refusals.entries()) expect(refusal, `${faces[i]} vs ${faces[0]}`).toEqual(refusals[0]);
425+
expect(refusals[0]).toMatchObject({ httpStatus: 400, object: OBJECT, param: 'groupBy', fields: ['closed_at'] });
399426
};
400427

401428
it('refuses a column raw beside its own month bucket, where the preview served both', async () => {
402429
await refusedAsLive(
403430
{ dimensions: ['closed_at'], measures: ['total'], timeDimensions: [{ dimension: `${OBJECT}.closed_at`, granularity: 'month' }] },
404431
'groupBy.1.field',
432+
// Spelled out once in full; the cases below build the same sentence.
433+
"aggregate('os28690_deal'): groupBy.1.field names the column 'closed_at', which groupBy.0 already names — "
434+
+ 'one row cannot carry two values under one column. The query was NOT run. The selection asks for '
435+
+ "'closed_at' both raw and bucketed (`dimensions` 'closed_at'; `timeDimensions` 'os28690_deal.closed_at' "
436+
+ 'by month). Drop the raw dimension from `dimensions`, or drop the granularity from the `timeDimensions` '
437+
+ 'entry. A repeated column name kept only the last value written under it: a count overwritten by a sum, '
438+
+ 'or the group keys replaced by the numbers, while the answer looked served.',
405439
);
406440
});
407441

408442
it('refuses it after another dimension, naming the later position', async () => {
409443
await refusedAsLive(
410444
{ dimensions: ['region', 'closed_at'], measures: ['total'], timeDimensions: [{ dimension: `${OBJECT}.closed_at`, granularity: 'month' }] },
411445
'groupBy.2.field',
446+
askedTwice('groupBy.2.field', 'groupBy.1', 'closed_at', rawAndBucketed("'closed_at'", `'${OBJECT}.closed_at' by month`)),
447+
);
448+
});
449+
450+
it('[#28798] names only the raw member when the bucketed dimension is listed too', async () => {
451+
// `closed_at` is bucketed by its own entry, so its key is the bucket;
452+
// `OBJECT.closed_at` reads the same column raw.
453+
await refusedAsLive(
454+
{ dimensions: ['closed_at', `${OBJECT}.closed_at`], measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] },
455+
'groupBy.1',
456+
askedTwice('groupBy.1', 'groupBy.0.field', 'closed_at', rawAndBucketed(`'${OBJECT}.closed_at'`, "'closed_at' by month")),
412457
);
413458
});
414459

@@ -422,6 +467,7 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
422467
],
423468
},
424469
'groupBy.1.field',
470+
askedTwice('groupBy.1.field', 'groupBy.0.field', 'closed_at', twoGranularities(`'closed_at' by month, '${OBJECT}.closed_at' by year`)),
425471
);
426472
});
427473

@@ -432,6 +478,7 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
432478
await refusedAsLive(
433479
{ measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: g1 }, { dimension: 'closed_at', granularity: g2 }] },
434480
'groupBy.1.field',
481+
askedTwice('groupBy.1.field', 'groupBy.0.field', 'closed_at', twoGranularities(`'closed_at' by ${g1}, 'closed_at' by ${g2}`)),
435482
);
436483
});
437484

@@ -445,17 +492,21 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
445492
],
446493
},
447494
'groupBy.1.field',
495+
askedTwice('groupBy.1.field', 'groupBy.0.field', 'closed_at', twoGranularities(`'${OBJECT}.closed_at' by month, '${OBJECT}.closed_at' by year`)),
448496
);
449497
});
450498

451499
it('[#28799] refuses the pair beside the raw dimension, where the live faces answered one 1970-01 bucket', async () => {
500+
// The dimension is bucketed by its own entries, so nothing is raw: the
501+
// repair is one granularity.
452502
await refusedAsLive(
453503
{
454504
dimensions: ['closed_at'],
455505
measures: ['total'],
456506
timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }, { dimension: 'closed_at', granularity: 'year' }],
457507
},
458508
'groupBy.1.field',
509+
askedTwice('groupBy.1.field', 'groupBy.0.field', 'closed_at', twoGranularities("'closed_at' by month, 'closed_at' by year")),
459510
);
460511
});
461512

@@ -467,25 +518,56 @@ describe('[#28690–#28692] time-dimension buckets, column-spelled filter member
467518
timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }, { dimension: 'closed_at', granularity: 'year' }],
468519
},
469520
'groupBy.2.field',
521+
askedTwice('groupBy.2.field', 'groupBy.1.field', 'closed_at', twoGranularities("'closed_at' by month, 'closed_at' by year")),
470522
);
471523
});
472524

473525
it("[#28799] refuses the pair beside a cross-object 'account.name' dimension on both live faces", async () => {
474-
const refusals: Array<Record<string, unknown>> = [];
475-
for (const face of LIVE) {
476-
const err = (await ask(face, BACKED, {
526+
// `executeCrossObject` calls the door on its own keys (the FK column first).
527+
await refusedAsLive(
528+
{
477529
dimensions: ['account.name'],
478530
measures: ['total'],
479531
timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }, { dimension: 'closed_at', granularity: 'year' }],
532+
},
533+
'groupBy.2.field',
534+
askedTwice('groupBy.2.field', 'groupBy.1.field', 'closed_at', twoGranularities("'closed_at' by month, 'closed_at' by year")),
535+
{ dataset: BACKED, faces: LIVE },
536+
);
537+
});
538+
539+
it('[#28798] answers it on every face before the engine judges a bucket over a text column', async () => {
540+
// `zone` is declared over the text column `region`. The engine refuses
541+
// its month bucket (`INVALID_FIELD`) before its own duplicate-column
542+
// door, and both live faces answered that; the preview answered the
543+
// column refusal. The strategy's door runs before the engine now, so all
544+
// three answer the column refusal (still 400; fixing it meets the other).
545+
for (const face of FACES) {
546+
const err = (await ask(face, UNBACKED, {
547+
dimensions: ['region'],
548+
measures: ['total'],
549+
timeDimensions: [{ dimension: 'zone', granularity: 'month' }],
480550
}).then(
481551
() => { throw new Error(`${face}: expected a refusal, but the query resolved`); },
482552
(e: unknown) => e,
483553
)) as Error & Record<string, unknown>;
484-
expect(err.code, `${face}: ${err.message}`).toBe('INVALID_QUERY');
485-
expect(err.status, face).toBe(400);
486-
refusals.push({ message: err.message, path: err.path, fields: err.fields });
554+
expect(err, face).toMatchObject({ code: 'INVALID_QUERY', status: 400, path: 'groupBy.1.field', fields: ['region'] });
555+
expect(err.message, face).toBe(askedTwice('groupBy.1.field', 'groupBy.0', 'region',
556+
"The selection asks for 'region' both raw and bucketed (`dimensions` 'region'; `timeDimensions` 'zone' by month). "
557+
+ 'Drop the raw dimension from `dimensions`, or drop the granularity from the `timeDimensions` entry.'));
487558
}
488-
expect(refusals[1]).toEqual(refusals[0]);
559+
});
560+
561+
it("[#28798] leaves a direct engine.aggregate caller the engine's own 'alias' prescription", async () => {
562+
const err = (await engine.aggregate(OBJECT, {
563+
groupBy: ['closed_at', { field: 'closed_at', dateGranularity: 'month' }],
564+
aggregations: [{ field: 'amount', method: 'sum', alias: 'total' }],
565+
} as never).then(
566+
() => { throw new Error('expected a refusal, but the aggregate resolved'); },
567+
(e: unknown) => e,
568+
)) as Error & Record<string, unknown>;
569+
expect(err).toMatchObject({ code: 'INVALID_QUERY', status: 400, path: 'groupBy.1.field', fields: ['closed_at'] });
570+
expect(err.message).toBe(askedTwice('groupBy.1.field', 'groupBy.0', 'closed_at', "Give one of the group entries its own 'alias'."));
489571
});
490572

491573
it('[#28799] still serves one dimension asked twice at the same granularity, as one bucket', async () => {

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ import { assertCubeMeasureFieldTypesAccepted, type MeasureColumn } from './cube-
8585
import { AMOUNT_AGGREGATES, assertRecordCurrencyAggregatesGrouped } from './cube-record-currency-door.js';
8686
import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, DurationMeasureSpec, ReadScopeFilterJudge } from './strategies/types.js';
8787
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
88-
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
88+
import { ObjectQLStrategy, aggregateGroupKeys, aggregateObjectOf, resolveCubeFieldName } from './strategies/objectql-strategy.js';
8989
// [#5669] The `where` source-field gate reads the filter tree through the SAME
9090
// pair the strategies compile it with, so "the field the gate saw" and "the
9191
// column that reached SQL" cannot be two different things.
@@ -116,7 +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';
119+
import { assertDatasetGroupColumnsDistinct } from './dataset-group-column-door.js';
120120
// [#25778] The one lookup of a dataset duration measure by name.
121121
import { durationMeasureIn } from './duration-measure-sql.js';
122122
import { scopedMeasureFilter } from './scoped-measure-key.js';
@@ -2891,11 +2891,17 @@ export class AnalyticsService implements IAnalyticsService {
28912891
// under one `null` key (and a filter on one matched no row) where
28922892
// the live strategies join it. See {@link assertPreviewJoinsNothing}.
28932893
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);
2894+
// [#28765, #28798] …and a selection asking one column for two
2895+
// values under one name (raw beside its own bucket, or two buckets
2896+
// of it), with the refusal the ObjectQL strategy gives both live
2897+
// faces for the keys its direct path builds, where the evaluator
2898+
// served both columns.
2899+
assertDatasetGroupColumnsDistinct(
2900+
aggregateObjectOf(cube),
2901+
aggregateGroupKeys(q, cube),
2902+
q,
2903+
(member) => resolveCubeFieldName(cube, member, 'dimension'),
2904+
);
28992905
return evaluateAnalyticsQueryOverRows(
29002906
q,
29012907
previewEvaluationCube(q, cube),

0 commit comments

Comments
 (0)