Skip to content

Commit 92a1638

Browse files
fix(service-analytics): the draft preview groups a bucketed time dimension and reads a qualified filter member as the live faces do; ObjectQL serves a backed RELATION.id dimension (#28742)
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 #28690 Fixes #28691 Fixes #28692 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` describes how the draft preview groups a time dimension or reads a qualified filter member, or says the ObjectQL strategy refuses a `RELATION.id` dimension. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:数据集查询三条执行面(原生 SQL、ObjectQL、草稿预览)的三处不一致收齐。① 草稿预览对带粒度的 `timeDimensions` 按桶分组,不再要求它同时出现在 `dimensions` 里,`fields` 里也带上这一列。② 草稿预览遇到 `对象名.region`、`数据集名.region`、或声明在 `region` 上的维度 `zone` 这类筛选成员,改为读它真正对应的列(按该列的声明类型比较),不再返回 `[]`。③ ObjectQL 策略对 `account.id` 维度、以及同一关系上的两个维度(`account.id` + `account.name`,或 `account` + `account.name`),groupBy 只写一次那一列,不再被引擎以「重复列」拒绝。 - **为什么改**:卡 #28690、#28691、#28692(p3),是 #28555 开发时在真 SQLite 上测到的同族缺口:作者在草稿里看到一个总数,发布后变成按月分组;草稿里一个能用的筛选什么都不返回;同一个查询原生面能答、ObjectQL 面 400。 - **风险与回滚**:只放宽,不收窄:之前给错数或 `[]` 的现在给线上同样的答案,之前 `INVALID_QUERY` 的现在服务。引擎的重复 groupBy 拒绝原样保留(改在策略里,不碰 `packages/objectql`)。预览仍拒绝经关系路径读的成员(#28555)。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/preview-evaluator.ts` 分组段(改用共享的 `projectedDimensions`)与新的 `whereColumns` 参数;`analytics-service.ts` 新的 `previewWhereColumns`;`strategies/objectql-strategy.ts` 的 `executeCrossObject` 与 `resolveFkAttr` 两处 groupBy。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/preview-time-filter-fk-parity.test.ts`,预期 17 passed。 - **需要你做的**:无,席位落地。 ## What changes - **#28690 — `preview-evaluator.ts`.** The grouping set is now `projectedDimensions(query)` from `order-key-door.ts`, the one definition the ObjectQL strategy's grouping, row mapping and `buildFieldMeta` already read: every `dimensions` entry, then every `timeDimensions` entry with a granularity that is not already one of them. The evaluator's `fields` list reads the same set, so the bucket column carries the cube dimension's type (`time` for a `date` dataset dimension), as on the live faces. A `timeDimensions` entry without a granularity still only contributes its window. - **#28691 — resolve, not refuse.** The order allowed either. Both live strategies serve these members (measured below), so the preview now resolves them. `previewWhereColumns(query, cube)` (new, module-level in `analytics-service.ts`) reads the `where` leaves with the reader the strategies compile with (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`, as `relationshipMembers` reads them) and resolves each with `declaredMemberEntry(cube, member, 'any')`, the strategies' filter-member lookup that `assertWhereFields` and the dimension face's `previewEvaluationCube` already use. Each member spelled other than its bare column (`OBJECT.region`, `CUBE.region`, `zone` over `region`) maps to that column. The evaluator takes the map as a new optional last argument `whereColumns`: the `where` match reads each listed member from its column, and the declared-type reader used by the `where` lowering and the storage-form comparison reads the column's declaration. No `where` tree is rewritten or walked a second time; the time window and grouping read columns as before. A member that resolves to no declared entry, an expression or a relationship path is not listed (the latter is refused earlier by #28555's gates). - **#28692 — `strategies/objectql-strategy.ts`, where the duplicate enters.** Two places built a `groupBy` naming one column twice, and the engine's guard refused it (`groupBy.1 names the column '…', which groupBy.0 already names`, `INVALID_QUERY`): `resolveFkAttr` grouped the related object by `['id', attr]`, which is `['id', 'id']` for `RELATION.id`; and `executeCrossObject` pushed a cross-object dimension's FK column once per dimension, so `account.id` beside `account.name` (both FK `account`) pushed `account` twice, and a base dimension over the FK column (`account` beside `account.name`) did the same. Now `resolveFkAttr` groups `['id']` when `attr` is `id`, and the cross-object `groupBy` names an FK column once (a base dimension over a column already grouped as an FK reuses it). The re-bucket reads the one column for each dimension, as before. Nothing in `packages/objectql` changes, so its duplicate-groupBy refusal is unchanged and there are no other callers of a changed function to measure. - Tests: new `src/__tests__/preview-time-filter-fk-parity.test.ts` (17 cases, real SQLite, the #28201 / #28258 / #28555 harness). - Changeset `.changeset/28690-preview-time-buckets-filter-columns-fk-id.md`, `@objectstack/service-analytics` patch. ## Why Measured on `origin/main` `d62d9737` (real SQLite, every face), with a probe printing each face's answer per selection. Dataset over a deal object (`region`, `amount`, `closed_at` datetime, `account` lookup), rows amer/2026-03-01/100/a1, amer/2026-04-01/50/a2, emea/2026-04-02/30/a1; dimensions `region`, `closed_at` (date), `zone` (field `region`). <details><summary>Before / after per selection</summary> | selection | native | ObjectQL | preview before | preview / ObjectQL after | |:--|:--|:--|:--|:--| | td `closed_at` month, no dims | 2026-03:100, 2026-04:80 | same | one row, total 180 | preview = live | | dim `region` + td `closed_at` month | (amer,03,100) (amer,04,50) (emea,04,30) | same | grouped by region only | preview = live | | td `OBJECT.closed_at` month | keyed `OBJECT.closed_at`, 03:100, 04:80 | same | one row, total 180 | preview = live | | td `closed_at` year + April window | `2026`:80 | same | total 80, no key | preview = live | | td in dims too; td window only; `dateGranularity` | agree | agree | agreed already | unchanged | | filter `OBJECT.region` / `CUBE.region` / `zone` = emea | emea 30 | same | `[]` | preview = live | | `$or` [`OBJECT.region` $in emea, amount 50] | amer 50, emea 30 | same | amer 50 only | preview = live | | `OBJECT.closed_at` $gte 2026-04-01 | amer 50, emea 30 | same | `[]` | preview = live | | backed dim `account.id` | a1:130, a2:50 | `INVALID_QUERY` (groupBy 'id' twice on the account object) | refused (#28555) | ObjectQL = native; preview still refuses | | backed `account.id` + `account.name` | served | `INVALID_QUERY` (groupBy 'account' twice) | refused | ObjectQL = native | | backed `account` + `account.name` (either order) | served | `INVALID_QUERY` | refused | ObjectQL = native | </details> ## Tests <details><summary>Red first, green, ablations</summary> - Red on base: the three production files restored to `d62d9737` (`git checkout d62d973 -- …`), the new test at HEAD: `Tests 15 failed | 2 passed (17)`. The two green are the controls (a window-only time dimension, the preview's #28555 refusal). Each #28690/#28691 case fails on the preview leg, each #28692 case on the ObjectQL leg with the engine's duplicate-column message. Restored with `git checkout HEAD --`, `git status` clean. - Green: `17 passed (17)`. - Ablations at `de8aeea7` (`node scripts/ablation-replace.mjs`, fix committed first, each anchor 1 → 0, restore blob == HEAD and `git diff HEAD` empty; the suite imports `src`, no build between): - A1 grouping set back to `query.dimensions ?? []`: `5 failed | 12 passed` (the four bucket cases and the `zone` + bucket case). - A2 `--delete` the `previewWhereColumns(q, cube)` argument: `7 failed | 10 passed` (the six filter cases and the `zone` + bucket case). - A2b the `where` type reader back to `declaredType` (members read from their columns but typed as written): `1 failed | 16 passed` (the `OBJECT.closed_at` instant comparison with an offset comparand). - A3a `resolveFkAttr` back to `['id', attr]`: `2 failed | 15 passed` (`account.id`, `account.id` + `account.name`). - A3b the FK column pushed once per cross dimension again: `2 failed | 15 passed` (`account.id` + `account.name`, `account` before `account.name`). - A3c the base-dimension reuse of an FK column off: `1 failed | 16 passed` (`account` after `account.name`). - `pnpm --filter @objectstack/service-analytics exec vitest run` at `de8aeea7`: `Test Files 203 passed (203)`, `Tests 4856 passed | 282 skipped`. - `pnpm --filter @objectstack/rest exec vitest run src/analytics` (after `--filter '@objectstack/rest^...' --filter @objectstack/service-analytics` build): `Test Files 23 passed (23)`, `Tests 298 passed | 22 skipped`. - `pnpm --filter @objectstack/service-analytics typecheck` (includes `__tests__`, after a full build) exit 0; `eslint` on the four touched source and test files exit 0. </details> ## Acceptance notes - #28691 went the "resolve" way: both live strategies serve every measured spelling, so a refusal would have narrowed the preview below them. The resolution is `declaredMemberEntry`, the lookup the strategies mirror and the preview's dimension face already uses; the leaves come from the strategies' own reader. No second walker: the `where` tree is not rewritten, the evaluator reads a listed member from its column at the match. - #28692's fix is in the strategy, where the duplicate enters. `packages/objectql` is not touched, so its duplicate-groupBy refusal stands and no other caller moves. - Out of scope, measured on `400154ac` (real SQLite), not fixed: two dataset dimensions over one base column (`region` and `zone` over `region`) on the ObjectQL strategy's direct path answer `INVALID_QUERY` / 400 (`groupBy.1 names the column 'region'`), while native and the preview serve amer 150 / emea 30 keyed by both names. Same root as #28692 on the non-cross-object path; reported to the seat. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 families derived on `de8aeea7` (5 paths vs merge base `4bf48a8cc`); 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 (1 anchor judged, 0 lost); `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. - Head is `400154ac`: `de8aeea7` with the changeset's two placeholder spellings changed (`RELATION.id`), no code change. On `400154ac`, the six changeset- and citation-reading gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset` with `--base origin/main`, `check:pm-changeset-deadline-census`, `check:issue-citations`, `check-closing-keyword-parity`) were run again, each exit 0. - 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. (Three cards, one PR, by the dispatch order.) - [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 7e6e8a4 commit 92a1638

5 files changed

Lines changed: 403 additions & 8 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the draft preview groups a bucketed time dimension and reads a qualified filter member as the live faces do, and the ObjectQL strategy serves a backed `RELATION.id` dimension
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Three answers `queryDataset` gave differently depending on the face, measured on real SQLite:
11+
12+
- **A bucketed time dimension in the draft preview.** A `timeDimensions` entry with a `granularity` is now grouped by its bucket whether or not the member is also in `dimensions`, and the bucket is a column of the answer and of `fields` (type `time` for a `date` dimension). This is the set both live strategies project. Before, the preview grouped by `dimensions` alone: one total where the live faces answered one row per month, regions without months, and a windowed `year` bucket with no key.
13+
- **A filter member spelled other than its column, in the draft preview.** A `runtimeFilter` (or dataset filter) member such as `OBJECT.region`, `CUBE.region` or a dataset dimension `zone` declared over `region` is now read from the column it names, with that column's declared type, through the member resolution the live strategies use. Before, the preview matched no drafted row: `[]`, or under an `$or` only the other arm's rows, where both live faces served the rows.
14+
- **A backed `RELATION.id` dimension on the ObjectQL strategy.** `account.id` (with `include: ['account']`), and two dimensions through one relationship (`account.id` beside `account.name`, or the FK column `account` beside `account.name`), are now served with the native strategy's groups. Before, the strategy handed the engine a `groupBy` naming one column twice and answered `INVALID_QUERY` / 400. The engine's duplicate-column refusal is unchanged; the strategy now names each column once.
15+
16+
Nothing is refused that was served, and no export, key, error code or status is added. The draft preview still refuses a relationship-path member (#28555).
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28690, #28691, #28692] Three answers the faces of `queryDataset` gave
5+
* differently, on the native strategy, the ObjectQL strategy and the draft
6+
* preview, on real SQLite (the #28201 / #28258 / #28555 harness).
7+
*
8+
* ## What these pins hold
9+
*
10+
* - #28690. A `timeDimensions` entry with a granularity is grouped by its
11+
* bucket whether or not the member is also in `dimensions`, on every face,
12+
* with the bucket in `fields`. Before, the preview grouped by `dimensions`
13+
* alone: one total where the live faces answered one group per month, and
14+
* regions without months.
15+
* - #28691. A `runtimeFilter` member spelled other than its column
16+
* (`OBJECT.region`, `CUBE.region`, a dataset dimension `zone` over
17+
* `region`, `OBJECT.closed_at`) is read from that column by the preview, as
18+
* both live strategies read it. Before, the preview matched no drafted row
19+
* (`[]`, or under an `$or` only the other arm's rows).
20+
* - #28692. The ObjectQL strategy answers a backed `account.id` dimension, and
21+
* two dimensions through one relationship, with the native strategy's
22+
* groups. Before, it handed the engine a groupBy naming one column twice and
23+
* answered `INVALID_QUERY`.
24+
*/
25+
26+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27+
import { ObjectQL } from '@objectstack/objectql';
28+
import { SqlDriver } from '@objectstack/driver-sql';
29+
import { DatasetSchema } from '@objectstack/spec/ui';
30+
import type { AnalyticsService } from '../analytics-service.js';
31+
import { AnalyticsServicePlugin } from '../plugin.js';
32+
import { installDefaultOrganization } from './default-organization.testkit.js';
33+
34+
const OBJECT = 'os28690_deal';
35+
const ACCOUNT = 'os28690_account';
36+
37+
const ACCOUNTS = [
38+
{ id: 'a1', name: 'Acme' },
39+
{ id: 'a2', name: 'Globex' },
40+
] as const;
41+
const ROWS = [
42+
{ id: 'd1', region: 'amer', amount: 100, account: 'a1', closed_at: '2026-03-01T00:00:00.000Z' },
43+
{ id: 'd2', region: 'amer', amount: 50, account: 'a2', closed_at: '2026-04-01T00:00:00.000Z' },
44+
{ id: 'd3', region: 'emea', amount: 30, account: 'a1', closed_at: '2026-04-02T00:00:00.000Z' },
45+
] as const;
46+
47+
const shape = {
48+
label: 'Deals',
49+
object: OBJECT,
50+
dimensions: [
51+
{ name: 'region', field: 'region', type: 'string' },
52+
{ name: 'closed_at', field: 'closed_at', type: 'date' },
53+
{ name: 'zone', field: 'region', type: 'string' },
54+
],
55+
measures: [{ name: 'total', aggregate: 'sum', field: 'amount' }],
56+
};
57+
/** Declares no relationship. */
58+
const UNBACKED = DatasetSchema.parse({ name: 'os28690_deals', ...shape });
59+
/** Declares `account`. */
60+
const BACKED = DatasetSchema.parse({ name: 'os28690_deals_by_account', include: ['account'], ...shape });
61+
62+
type Face = 'native' | 'objectql' | 'preview';
63+
const FACES: readonly Face[] = ['native', 'objectql', 'preview'];
64+
const LIVE: readonly Face[] = ['native', 'objectql'];
65+
66+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } };
67+
68+
type Selection = {
69+
dimensions?: string[];
70+
measures: string[];
71+
runtimeFilter?: Record<string, unknown>;
72+
timeDimensions?: Array<Record<string, unknown>>;
73+
};
74+
type Answer = { rows: Array<Record<string, unknown>>; fields: string[] };
75+
76+
/** A `'protocol'` service answering one pending seed draft, which routes `previewDrafts` onto the preview path. */
77+
const pendingSeedProtocol = {
78+
getMetaItems: async () => [{ name: 'os28690_seed', object: OBJECT }],
79+
getMetaItem: async () => ({ item: { records: ROWS } }),
80+
};
81+
82+
describe('[#28690–#28692] time-dimension buckets, column-spelled filter members and FK-id groups on every face', () => {
83+
let engine: ObjectQL;
84+
const services: Partial<Record<'native' | 'objectql', AnalyticsService>> = {};
85+
86+
const ask = (face: Face, dataset: typeof UNBACKED, selection: Selection) =>
87+
services[face === 'preview' ? 'objectql' : face]!.queryDataset(
88+
dataset,
89+
{ ...selection } as never,
90+
undefined,
91+
face === 'preview' ? { previewDrafts: true } : undefined,
92+
);
93+
94+
/** One face's rows (measures as numbers, sorted) and its `fields` as `name:type`. */
95+
const answerOf = async (face: Face, dataset: typeof UNBACKED, selection: Selection): Promise<Answer> => {
96+
const result = await ask(face, dataset, selection);
97+
const rows = (result.rows as Array<Record<string, unknown>>)
98+
.map((r) => ({ ...r, total: Number(r.total) }))
99+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
100+
return { rows, fields: (result.fields ?? []).map((f: { name: string; type: string }) => `${f.name}:${f.type}`) };
101+
};
102+
103+
/** Every face in `faces` answers `expected`, rows and fields alike. */
104+
const answeredAlike = async (faces: readonly Face[], dataset: typeof UNBACKED, selection: Selection, expected: Answer) => {
105+
for (const face of faces) {
106+
expect(await answerOf(face, dataset, selection), face).toEqual(expected);
107+
}
108+
};
109+
110+
beforeAll(async () => {
111+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
112+
engine = new ObjectQL({ logger: quiet } as never);
113+
engine.registerDriver(driver as never, true);
114+
await engine.init();
115+
engine.registry.registerObject({
116+
name: ACCOUNT,
117+
label: 'Account',
118+
fields: { name: { name: 'name', type: 'text' } },
119+
} as never);
120+
engine.registry.registerObject({
121+
name: OBJECT,
122+
label: 'Deal',
123+
fields: {
124+
region: { name: 'region', type: 'text' },
125+
amount: { name: 'amount', type: 'number' },
126+
closed_at: { name: 'closed_at', type: 'datetime' },
127+
account: { name: 'account', type: 'lookup', reference: ACCOUNT },
128+
},
129+
} as never);
130+
await engine.syncSchemas();
131+
await installDefaultOrganization(engine as never);
132+
for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as never);
133+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never);
134+
135+
for (const [face, caps] of [
136+
['native', undefined],
137+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
138+
] as const) {
139+
const registered: Record<string, unknown> = {};
140+
const provided: Record<string, unknown> = { data: engine, protocol: pendingSeedProtocol };
141+
await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({
142+
getService: (name: string) => provided[name] ?? registered[name],
143+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
144+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
145+
hook: () => {},
146+
logger: quiet,
147+
} as never);
148+
services[face] = registered.analytics as AnalyticsService;
149+
}
150+
});
151+
152+
afterAll(async () => {
153+
try { await engine?.destroy(); } catch { /* noop */ }
154+
});
155+
156+
describe('#28690 — a bucketed time dimension is grouped whether or not it is also a dimension', () => {
157+
it('groups by month with no dimensions, where the preview answered one total', async () => {
158+
await answeredAlike(FACES, UNBACKED, { measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] }, {
159+
rows: [{ closed_at: '2026-03', total: 100 }, { closed_at: '2026-04', total: 80 }],
160+
fields: ['closed_at:time', 'total:number'],
161+
});
162+
});
163+
164+
it('groups by region and month, where the preview grouped by region alone', async () => {
165+
await answeredAlike(
166+
FACES,
167+
UNBACKED,
168+
{ dimensions: ['region'], measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }] },
169+
{
170+
rows: [
171+
{ region: 'amer', closed_at: '2026-03', total: 100 },
172+
{ region: 'amer', closed_at: '2026-04', total: 50 },
173+
{ region: 'emea', closed_at: '2026-04', total: 30 },
174+
],
175+
fields: ['region:string', 'closed_at:time', 'total:number'],
176+
},
177+
);
178+
});
179+
180+
it('keys an OBJECT-qualified time dimension by its spelling, as the live faces do', async () => {
181+
const member = `${OBJECT}.closed_at`;
182+
await answeredAlike(FACES, UNBACKED, { measures: ['total'], timeDimensions: [{ dimension: member, granularity: 'month' }] }, {
183+
rows: [{ [member]: '2026-03', total: 100 }, { [member]: '2026-04', total: 80 }],
184+
fields: [`${member}:time`, 'total:number'],
185+
});
186+
});
187+
188+
it('buckets a windowed year, where the preview answered the total with no key', async () => {
189+
await answeredAlike(
190+
FACES,
191+
UNBACKED,
192+
{ measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'year', dateRange: ['2026-04-01', '2026-04-30'] }] },
193+
{ rows: [{ closed_at: '2026', total: 80 }], fields: ['closed_at:time', 'total:number'] },
194+
);
195+
});
196+
197+
it('still projects nothing for a time dimension with no granularity (a window only)', async () => {
198+
await answeredAlike(
199+
FACES,
200+
UNBACKED,
201+
{ measures: ['total'], timeDimensions: [{ dimension: 'closed_at', dateRange: ['2026-04-01', '2026-04-30'] }] },
202+
{ rows: [{ total: 80 }], fields: ['total:number'] },
203+
);
204+
});
205+
});
206+
207+
describe('#28691 — a filter member spelled other than its column is read from that column', () => {
208+
const emeaOnly: Answer = { rows: [{ region: 'emea', total: 30 }], fields: ['region:string', 'total:number'] };
209+
const amerAprilAndEmea: Answer = {
210+
rows: [{ region: 'amer', total: 50 }, { region: 'emea', total: 30 }],
211+
fields: ['region:string', 'total:number'],
212+
};
213+
214+
it.each([
215+
['OBJECT.region', `${OBJECT}.region`],
216+
['CUBE.region', `${UNBACKED.name}.region`],
217+
['the dimension zone over region', 'zone'],
218+
])('matches %s, where the preview answered []', async (_label, member) => {
219+
await answeredAlike(FACES, UNBACKED, { dimensions: ['region'], measures: ['total'], runtimeFilter: { [member]: 'emea' } }, emeaOnly);
220+
});
221+
222+
it("matches OBJECT.region's arm under an $or, where the preview answered only the other arm's rows", async () => {
223+
await answeredAlike(
224+
FACES,
225+
UNBACKED,
226+
{ dimensions: ['region'], measures: ['total'], runtimeFilter: { $or: [{ [`${OBJECT}.region`]: { $in: ['emea'] } }, { amount: 50 }] } },
227+
amerAprilAndEmea,
228+
);
229+
});
230+
231+
it('reads an OBJECT-qualified datetime member as the datetime column, where the preview answered []', async () => {
232+
await answeredAlike(
233+
FACES,
234+
UNBACKED,
235+
{ dimensions: ['region'], measures: ['total'], runtimeFilter: { [`${OBJECT}.closed_at`]: { $gte: '2026-04-01' } } },
236+
amerAprilAndEmea,
237+
);
238+
});
239+
240+
it("compares an OBJECT-qualified datetime member's instants, as the column's declaration reads them", async () => {
241+
// 2026-04-02T01:00+02:00 is 2026-04-01T23:00Z, so d3 (2026-04-02T00:00Z)
242+
// is after it. Compared as text, as an undeclared column would be, it is not.
243+
await answeredAlike(
244+
FACES,
245+
UNBACKED,
246+
{ dimensions: ['region'], measures: ['total'], runtimeFilter: { [`${OBJECT}.closed_at`]: { $gte: '2026-04-02T01:00:00+02:00' } } },
247+
{ rows: [{ region: 'emea', total: 30 }], fields: ['region:string', 'total:number'] },
248+
);
249+
});
250+
251+
it('filters on zone beside a time-dimension-only bucket', async () => {
252+
await answeredAlike(
253+
FACES,
254+
UNBACKED,
255+
{ measures: ['total'], timeDimensions: [{ dimension: 'closed_at', granularity: 'month' }], runtimeFilter: { zone: 'amer' } },
256+
{ rows: [{ closed_at: '2026-03', total: 100 }, { closed_at: '2026-04', total: 50 }], fields: ['closed_at:time', 'total:number'] },
257+
);
258+
});
259+
});
260+
261+
describe('#28692 — the ObjectQL strategy groups a backed FK path once', () => {
262+
it("serves a backed 'account.id' with the native strategy's groups, where it answered INVALID_QUERY", async () => {
263+
await answeredAlike(LIVE, BACKED, { dimensions: ['account.id'], measures: ['total'] }, {
264+
rows: [{ 'account.id': 'a1', total: 130 }, { 'account.id': 'a2', total: 50 }],
265+
fields: ['account.id:string', 'total:number'],
266+
});
267+
});
268+
269+
it("serves 'account.id' beside 'account.name', where it answered INVALID_QUERY", async () => {
270+
await answeredAlike(LIVE, BACKED, { dimensions: ['account.id', 'account.name'], measures: ['total'] }, {
271+
rows: [
272+
{ 'account.id': 'a1', 'account.name': 'Acme', total: 130 },
273+
{ 'account.id': 'a2', 'account.name': 'Globex', total: 50 },
274+
],
275+
fields: ['account.id:string', 'account.name:string', 'total:number'],
276+
});
277+
});
278+
279+
it.each([
280+
['after', ['account.name', 'account']],
281+
['before', ['account', 'account.name']],
282+
])("serves the FK column 'account' %s 'account.name'", async (_label, dimensions) => {
283+
await answeredAlike(LIVE, BACKED, { dimensions, measures: ['total'] }, {
284+
rows: [
285+
{ account: 'a1', 'account.name': 'Acme', total: 130 },
286+
{ account: 'a2', 'account.name': 'Globex', total: 50 },
287+
],
288+
fields: [...dimensions.map((d) => `${d}:string`), 'total:number'],
289+
});
290+
});
291+
292+
it('leaves the draft preview refusing a backed relationship path (#28555)', async () => {
293+
await expect(ask('preview', BACKED, { dimensions: ['account.id'], measures: ['total'] })).rejects.toMatchObject({
294+
code: 'INVALID_FIELD',
295+
status: 400,
296+
member: 'account.id',
297+
});
298+
});
299+
});
300+
});

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2895,6 +2895,9 @@ export class AnalyticsService implements IAnalyticsService {
28952895
seedRows!,
28962896
(field) => this.sourceFieldMeta?.(dataset.object, field)?.type,
28972897
compiled.durations,
2898+
// [#28691] …and each `where` member the live faces read from a
2899+
// column spelled otherwise is read from that column.
2900+
previewWhereColumns(q, cube),
28982901
);
28992902
},
29002903
} as IAnalyticsService;
@@ -4777,6 +4780,43 @@ function previewEvaluationCube(query: AnalyticsQuery, cube: Cube): Cube {
47774780
return Object.keys(aliases).length > 0 ? { ...cube, dimensions: { ...cube.dimensions, ...aliases } } : cube;
47784781
}
47794782

4783+
/**
4784+
* [#28691] The column each `where` member resolves to, for every member
4785+
* spelled other than its column, which the draft-preview evaluator matches
4786+
* against the drafted rows (they carry columns). `OBJECT.region`,
4787+
* `CUBE.region` and a dataset dimension `zone` over `region` all read
4788+
* `region`, as both live strategies read them. Measured before: each matched
4789+
* no drafted row (`[]`, or under an `$or` only the other arm's rows) where
4790+
* both live faces served the rows.
4791+
*
4792+
* The leaves are read with the reader the strategies compile with
4793+
* (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`, as
4794+
* {@link relationshipMembers} reads them), and each is resolved by
4795+
* {@link declaredMemberEntry}'s `'any'` arm, the strategies' filter-member
4796+
* lookup. A member that resolves to no declared entry, or to an expression or
4797+
* a relationship path, is not listed: it is read as written, or was refused
4798+
* before the evaluator runs.
4799+
*/
4800+
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+
}
4809+
const out: Record<string, string> = {};
4810+
for (const member of leaves) {
4811+
if (out[member] !== undefined) continue;
4812+
const entry = declaredMemberEntry(cube, member, 'any');
4813+
if (!entry) continue;
4814+
const column = typeof entry.sql === 'string' ? entry.sql.trim() : entry.key;
4815+
if (column !== member && BARE_IDENTIFIER.test(column)) out[member] = column;
4816+
}
4817+
return out;
4818+
}
4819+
47804820
/**
47814821
* [#28172] The measures `measures` names that `cube` (registered as
47824822
* `cubeName`) does not declare, minted

0 commit comments

Comments
 (0)