Skip to content

Commit a2e7556

Browse files
fix(service-analytics)!: a dataset dimension spelled through a relationship path the dataset does not declare is refused INVALID_FIELD on the native, ObjectQL and draft-preview faces (#28553)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSpYuanGsc8M8sJU9TUdUDH)_ Fixes #28258 Clause-②: no (narrowing) — a dimension named through a relationship path the dataset's `include` does not declare is now refused `INVALID_FIELD` / 400: in a `queryDataset` selection on all three faces, and in an `/analytics/query` or `/analytics/sql` request over a registered dataset cube, where the ObjectQL strategy also served it before (the gate sits in the call context those doors share). The draft preview (`previewDrafts: true`) served it as one `null` group, and the ObjectQL strategy served a real lookup (`account.name`) by joining it anyway; both now refuse. The native strategy refused it `DATASET_INVALID` / 400 and now answers `INVALID_FIELD` / 400 for this case only. Nothing is widened: no export, answer member, error code or status is added; both codes were already on the wire. Metadata-protocol: none Skills-docs: none — `content/docs/data-modeling/analytics.mdx` already says "Only declared paths are joinable", which this diff makes true on every face; no file under `skills/**` or `content/docs` names the code a selection-side refusal answers. **BREAKING** accept-set narrowing on `queryDataset` (`@objectstack/service-analytics`), and a code change on one native refusal. Card thread: `session_01FEkQ5pG4c8xoSCyPjcztJc`. ## 维护者速读 - **改了什么**:数据集查询里,维度若经由数据集 `include` 没声明的关系路径书写(如 `对象名.bogus`、或没 include `account` 时写 `account.name`),三条执行面现在都在服务层同一道闸门按 400 `INVALID_FIELD` 拒,消息逐字相同,点名该维度、第一个没声明的路径、已声明路径和可用维度。原生 SQL 面这一种情形从 `DATASET_INVALID` 改为 `INVALID_FIELD`;草稿预览不再给一个 `null` 分组;ObjectQL 面不再绕过 include 去 join。 - **为什么改**:卡 #28258(分诊 6532297703,席位裁定 `INVALID_FIELD`):同一个写错的维度三面三种答复,草稿里还显示一个看似合理的总数。 - **风险与回滚**:只影响「选择里经未声明关系路径的维度」;已声明路径(`include: ['account']` 下的 `account.name`)、解析到已声明维度的点号写法(`对象名.region`)照旧服务;`runtimeFilter` 经未声明路径、手写 cube 的配置白名单仍走原生面的 `DATASET_INVALID`;读不了的关联对象仍先给 403。仓内唯一读到这个错误码的消费方(`@object-ui/data-objectstack` 适配器)对任何具名错误码一视同仁。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/analytics-service.ts` 的新方法 `assertDimensionJoinsBacked` 及其两处调用(`callCtx` 里字段读闸门之后、`answerDataset` 预览的闸门列表末尾);`dataset-compiler.ts` 的 `unbackedRelationshipPath`。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/dotted-dimension-unbacked-join-parity.test.ts`,预期 9 passed。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **One gate, three faces** (`analytics-service.ts`, new private `assertDimensionJoinsBacked`). For each `dimensions` entry that resolves to no declared dimension (`declaredMemberEntry`, the strategies' own `lookupMember` mirror) and is a dotted identifier path, it walks the path hop by hop with `unbackedRelationshipPath` (new export of `dataset-compiler.ts`, beside `joinAlias`; not re-exported from the package entry) against the compiled dataset's `allowedRelationships`, the set the native join allowlist reads. The first undeclared path is refused through `invalidMemberError` (`dataset-refusal.ts`), the member-level `INVALID_FIELD` / 400 constructor the other member refusals use, carrying `member`, `param: 'dimensions'` and `cube`: ``` Dimension 'account.owner.name' on cube 'os28258_deals_by_account' traverses relationship path 'account.owner', which the dataset does not declare in its `include`. Only fields along a declared relationship path are joinable — declared paths: account. Valid dimensions: region. ``` It is asked in two places, one message, no copy: 1. `callCtx` (shared by `query()` and `generateSql()`, so both strategies and the SQL echo), right after the object, stored-body, internal-field and field-level verdicts and before the order-key door and any strategy. 2. The draft preview's gate list in `answerDataset`, at the same point: after `assertFieldsReadable`, before the evaluator. **Why after the admission verdicts, not in `ensureCube`.** The first cut asked it in `ensureCube` beside the dimension-field gate. `packages/rest/src/analytics-relationship-path-admission.test.ts` (#20933) then went red on both compositions: a dimension through an undeclared path to an object the caller may not read must answer that object's 403, as a declared one does. Asking the gate after `callCtx`'s admission keeps that 403 (the same reason the order-key door sits there). **Scope kept to the ruling.** - Only `dimensions`. A dotted `timeDimensions` entry already answers one `INVALID_FIELD` on both live strategies (measured below), so it is left alone. - Only on a compiled dataset (`allowed` defined). A hand-authored cube's configured `getAllowedRelationships` hook still reaches the native strategy's `DATASET_INVALID`. - The native strategy's allowlist (`native-sql-strategy.ts`) is untouched; it still answers `DATASET_INVALID` for a `runtimeFilter` member through an undeclared path, and for every other reason it is raised today. `dataset-compiler.ts`'s own `assertDeclared` is untouched. **One pin moved.** `packages/rest/src/analytics-dataset-refusal-envelope.test.ts`'s row "native-sql-strategy: a selection naming a join outside the allowlist" used a dotted `dimensions` entry as its trigger. That is exactly this card's case, now refused ahead of the strategy, so the row's trigger becomes a `runtimeFilter` member (`{ 'account.region': 'emea' }`) that still reaches the strategy allowlist: its audit trail (the producer's `DATASET_INVALID` envelope, `listEntry: 'not backed by a declared relationship'`) is kept, with the same message. ## Tests New `packages/services/service-analytics/src/__tests__/dotted-dimension-unbacked-join-parity.test.ts`: real `ObjectQL` + `SqlDriver` (better-sqlite3, in memory) through `AnalyticsServicePlugin.init`, the native face, the ObjectQL face and the preview face (a pending seed draft), the #28201 harness. - Refused three ways, `INVALID_FIELD` / 400, `param: 'dimensions'`, `member`, `cube`, message first sentence, and the native, ObjectQL and preview messages byte-equal: `os28258_deal.bogus` (the card's spelling), `account.name` (a real lookup, no `include`), `account.owner.name` (only `account` included, refused naming `account.owner`), and `account.name` beside the declared `region`. - Controls: backed `account.name` serves Acme 130 / Globex 50 on both live strategies and is not refused on the preview; `os28258_deal.region` (resolves to the declared `region`) serves amer 150 / emea 30 on both live strategies and is not refused on the preview; undeclared plain `bogus` keeps #28201's refusal ("groups by field 'bogus'") on every face; `nope` beside `account.name` is answered about the measure on every face; a `runtimeFilter` member `account.name` keeps the native `DATASET_INVALID` / 400. <details><summary>Red first on main eeec88d, and the per-face answers measured there</summary> The file run against the unchanged base: `Tests 4 failed | 5 passed (9)`; the four refusal cases red ("expected a refusal, but the query resolved": the preview served), the five controls green. A probe on the same harness, same base, recorded each face: | Selection (dataset) | native | ObjectQL | preview | |:---|:---|:---|:---| | `os28258_deal.bogus` (no include) | `DATASET_INVALID` / 400 "join "os28258_deal" is not backed…" | `INVALID_FIELD` / 400 from the driver ("groupBy … names 'os28258_deal.bogus'") | served `[{ "os28258_deal.bogus": null, total: 180 }]` | | `account.name` (no include) | `DATASET_INVALID` / 400 | **served** Acme 130 / Globex 50 | served one `null` group, 180 | | `account.name` (include account) | served | served | served one `null` group, 180 | | `os28258_deal.region` (no include) | served amer/emea | served amer/emea | served one `null` group, 180 | </details> <details><summary>Green, ablation, suites</summary> - Green: the new file 9/9; with the three preview-parity siblings (`preview-undeclared-dimension-parity`, `preview-filter-field-json-dimension-parity`, `preview-undeclared-measure-parity`) 37/37. - Ablation (`node scripts/ablation-replace.mjs --delete`, anchor the preview's `this.assertDimensionJoinsBacked(q, cube, compiled.allowedRelationships);`, i.e. the `null`-group behaviour put back): anchor 1 → 0, blob `9b761f324397` → `5c8914397b7f`; `Tests 4 failed | 5 passed (9)`, the four refusal pins red (the preview resolved), controls green; restore blob equals HEAD, `git diff HEAD` empty. The suite imports the service from `src/`, so no build sits between the mutation and the run. - `pnpm --filter @objectstack/service-analytics exec vitest run`: Test Files 201 passed (201), Tests 4823 passed | 282 skipped (5105). - `pnpm --filter @objectstack/rest exec vitest run src/analytics` (after building rest's dependency closure): Test Files 23 passed (23), Tests 298 passed | 22 skipped (320). - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. </details> ## Acceptance notes - **Wire-change readers of `DATASET_INVALID` for this case** (measured before changing it, per the order): `@object-ui/data-objectstack`'s `queryDataset` adapter is the only runtime reader, and it sends every named non-permission code to one generic "Dataset query failed: 400 — detail" error (branch ⑤), so `INVALID_FIELD` lands identically; its two tests use the code as fixture text only. No reader in `packages/client`, the console, HotCRM (its one `DATASET_INVALID` mention is a currency comment), `examples/`, `skills/**` or `content/docs` names this case. In-repo test pins: the rest refusal-envelope row (moved, above); service-analytics `dataset-refusal-envelope.test.ts` ⑤ and `native-sql-rls.test.ts` call the strategy directly and are unchanged; `dataset-rls-integration.test.ts` uses a declared dimension under a configured hook and is unchanged. No real consumer, so both halves land. - **ObjectQL face narrows too.** The card measured `OBJECT.bogus`; a real lookup the dataset does not include (`account.name`) was served on the ObjectQL strategy by joining outside the allowlist. The ruling's contract covers it ("a dimension spelled through a join the dataset does not back"), so it is refused there now and pinned; the changeset says so. Because the gate sits in `callCtx`, the same narrowing reaches `/analytics/query` and `/analytics/sql` over a registered dataset cube (the ObjectQL strategy served there too); the Clause-② line and the changeset name both doors (contract review, prose-only follow-up). - **Preview vs a denied related object.** The preview reads no related object (`cubeObjects` only), so it asks no admission for the path's object; a path that is both undeclared and through an unreadable object answers the field gate's verdict, then this gate's 400 on the preview, where the live faces answer the object's 403. Not measured here; it needs a security-wired preview harness. - **Out of scope, measured on the same harness at `eeec88d14`, not fixed here** (in the report for the seat): - The preview joins nothing, so a BACKED dotted dimension (`account.name` with `include: ['account']`) and a dotted spelling that resolves to a declared dimension (`OBJECT.region`, `CUBE.region`) serve one `null` group on the preview where both live faces serve real groups. - A dotted `timeDimensions` entry (`account.created_at`, no include): both live faces `INVALID_FIELD` / 400; the preview serves `[{ total: 180 }]`, dropping the time dimension. - A `runtimeFilter` member through an undeclared path (`account.name`): native `DATASET_INVALID`, ObjectQL `INVALID_FIELD` ("cannot evaluate a cross-object filter"), preview `[]`. - A declared path to a column the related object lacks (`account.bogus`, include account): native `DATABASE_ERROR` / 500, ObjectQL `INVALID_FIELD` / 400, preview one `null` group. ## Verification Readings on `9b932230a` (code head: the union, the dogfood battery and the full build ran on it). Head `6001927ad` adds one commit that only respells two placeholders in the changeset prose (`OBJECT.bogus` for an angle-bracket form); on it the changeset-reading families were re-run, all exit 0: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:issue-citations`, `check-closing-keyword-parity`, `check-commit-card-trailers` (4 commits); the derived list on the new head is byte-identical to the one run. Head `0fb1c3aa9` adds one more prose-only commit (the changeset's Clause-② sentence and "Who sees it" name `/analytics/query` and `/analytics/sql`, per the contract review); the same eight families re-run there, all exit 0 (first reads exit 3 in a worktree without `node_modules`, re-read after `pnpm install`), and the derived list is again identical. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 62 families, each run with `< /dev/null` and its exit code captured before any pipe; `--ran` reconciliation: `✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)`. One first read was a container prerequisite, re-read after meeting it: `check:dual-build-cjs-loads` exit 3 ("Run `pnpm build` first") → full `pnpm build --concurrency=2` (117 tasks, exit 0) → exit 0. `check:type-check-debt` exit 0 (`0 raw tsc error(s) total, none above its recorded number`). - **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; `pnpm exec eslint --no-inline-config packages/services/service-analytics/src packages/rest/src/analytics-dataset-refusal-envelope.test.ts` exit 0, no output (neither package has a `lint` script; the repo-wide `pnpm lint` is CI's). - **Open PRs touching these files:** none of the 14 open code PRs at push time (#28512–#28531) changes `analytics-service.ts`, `dataset-compiler.ts` or the rest refusal-envelope test. - **Full dogfood** (owed by the `!` and the `(narrowing)` arm): after building its closure, under the lock, `pnpm --filter @objectstack/dogfood test` → Test Files 363 passed | 3 skipped (366), Tests 2932 passed | 28 skipped (2960); no failure. - **CI** families (Test Core, lint farm, type-check lanes) are read off this PR's check-runs by the seat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5pG4c8xoSCyPjcztJc --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pG4c8xoSCyPjcztJc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bca27ad commit a2e7556

5 files changed

Lines changed: 356 additions & 4 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)!: a dataset dimension spelled through a relationship path the dataset does not declare is refused INVALID_FIELD on the native, ObjectQL and draft-preview faces
6+
7+
Clause-②: no (narrowing) — a dimension named through a relationship path the dataset's `include` does not declare is now refused `INVALID_FIELD` / 400: in a `queryDataset` selection on all three faces, and in an `/analytics/query` or `/analytics/sql` request over a registered dataset cube, where the ObjectQL strategy also served it before (the gate sits in the call context those doors share). The draft preview served it as one `null` group and the ObjectQL strategy served a real lookup by joining it anyway; both now refuse. The native strategy refused it `DATASET_INVALID` / 400 and now answers `INVALID_FIELD` / 400 for this case only. Nothing is widened: no export, answer member, error code or status is added; both codes were 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 changes: a selection that names an undeclared relationship path is refused on the faces that served it, and the native face's refusal takes the member-level code the other dimension gates answer. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on `queryDataset` (`@objectstack/service-analytics`), and a code change on one native refusal.
13+
14+
- **Before.** A selected dimension spelled through a relationship the dataset does not include (`OBJECT.bogus`, or a real lookup such as `account.name` with no `include: ['account']`) got three answers: the native strategy refused `DATASET_INVALID` / 400 ("join … is not backed by a declared relationship"), the ObjectQL strategy refused `OBJECT.bogus` `INVALID_FIELD` from the driver and served `account.name` grouped by the related record, and the draft preview (`previewDrafts: true`) served one `null` group with the whole total.
15+
- **Now.** The service asks one gate on every strategy and on the preview, after the object, body, internal-field and field-level verdicts: the member is refused `INVALID_FIELD` / 400 with one message naming it, the first undeclared path hop by hop (`account.owner` when only `account` is included), the declared paths and the valid dimensions. The error carries `member`, `param: 'dimensions'` and `cube`.
16+
- **Unchanged.** A dimension through a declared path, and a dotted spelling that resolves to a declared dimension (`CUBE.region`, `OBJECT.region`), serve as before. An undeclared plain dimension keeps its own refusal. A path through an object the caller may not read keeps its 403. A `runtimeFilter` member through an undeclared path, and a hand-authored cube's configured allowlist, still reach the native strategy's `DATASET_INVALID`.
17+
- **Who sees it.** A widget or caller grouping by a related field the dataset forgot to include, through `queryDataset` or through `/analytics/query` and `/analytics/sql` naming a registered dataset cube: every face now gives the refusal, instead of a blank group in the draft, a served chart or statement on an ObjectQL driver, and `DATASET_INVALID` on a SQL driver.
18+
19+
**The remedy.** Add the relationship to the dataset's `include`, or group by a dimension the dataset declares.

‎packages/rest/src/analytics-dataset-refusal-envelope.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,16 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env
225225
},
226226
{
227227
// The caller-shaped trigger: the DATASET declares no `include`, and the
228-
// SELECTION names a dotted dimension, which `lookupMember`'s synthetic
229-
// relation fallback turns into a join at alias `account`.
228+
// SELECTION filters on a dotted member, which the strategy compiles into
229+
// a join at alias `account`. [#28258] A dotted `dimensions` entry was the
230+
// trigger here until the service learned to refuse it ahead of either
231+
// strategy (`INVALID_FIELD` / 400, one answer on all three dataset
232+
// faces); a `runtimeFilter` member still reaches the strategy's own
233+
// allowlist, and this row's audit trail is about that producer.
230234
name: 'native-sql-strategy: a selection naming a join outside the allowlist',
231235
listEntry: 'not backed by a declared relationship',
232236
analytics: nativeAnalytics,
233-
body: { dataset, selection: { dimensions: ['account.region'], measures: ['revenue'] } },
237+
body: { dataset, selection: { dimensions: ['stage'], measures: ['revenue'], runtimeFilter: { 'account.region': 'emea' } } },
234238
message: /join "account" is not backed by a declared relationship on cube "pipeline"/,
235239
},
236240
];
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28258] A dimension spelled through a relationship path the dataset does not
5+
* declare in its `include`, through `queryDataset` on the native strategy, the
6+
* ObjectQL strategy and the draft preview, on real SQLite.
7+
*
8+
* ## What these pins hold
9+
*
10+
* - A selected dimension whose relationship path the dataset does not back is
11+
* refused `INVALID_FIELD` / 400 on every face, with one message naming the
12+
* member and the path. Before, the native strategy refused it
13+
* `DATASET_INVALID` (its join allowlist), the ObjectQL strategy refused
14+
* `<object>.bogus` `INVALID_FIELD` from the driver and SERVED a real lookup
15+
* (`account.name`) by joining it anyway, and the draft preview served one
16+
* `null` group.
17+
* - A multi-hop spelling is judged hop by hop: with only `account` included,
18+
* `account.owner.name` is refused naming `account.owner`.
19+
* - CONTROL: a backed dotted dimension still serves on every face (the live
20+
* strategies the same groups); a dotted spelling that resolves to a declared
21+
* dimension (`<object>.region`) still serves; an undeclared plain dimension
22+
* (`bogus`, #28201) keeps its own refusal; a `runtimeFilter` member through an
23+
* unbacked path keeps the native strategy's `DATASET_INVALID`.
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 = 'os28258_deal';
35+
const ACCOUNT = 'os28258_account';
36+
const PERSON = 'os28258_person';
37+
38+
const PEOPLE = [{ id: 'p1', name: 'Ada' }] as const;
39+
const ACCOUNTS = [
40+
{ id: 'a1', name: 'Acme', owner: 'p1' },
41+
{ id: 'a2', name: 'Globex', owner: 'p1' },
42+
] as const;
43+
const ROWS = [
44+
{ id: 'd1', region: 'amer', amount: 100, account: 'a1' },
45+
{ id: 'd2', region: 'amer', amount: 50, account: 'a2' },
46+
{ id: 'd3', region: 'emea', amount: 30, account: 'a1' },
47+
] as const;
48+
49+
const shape = {
50+
label: 'Deals',
51+
object: OBJECT,
52+
dimensions: [{ name: 'region', field: 'region', type: 'string' }],
53+
measures: [{ name: 'total', aggregate: 'sum', field: 'amount' }],
54+
};
55+
/** Declares no relationship at all. */
56+
const UNBACKED = DatasetSchema.parse({ name: 'os28258_deals', ...shape });
57+
/** Declares `account`, and nothing past it. */
58+
const BACKED = DatasetSchema.parse({ name: 'os28258_deals_by_account', include: ['account'], ...shape });
59+
60+
type Face = 'native' | 'objectql' | 'preview';
61+
const FACES: readonly Face[] = ['native', 'objectql', 'preview'];
62+
63+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } };
64+
65+
type Refusal = Error & { code?: string; status?: number; param?: string; field?: string; member?: string; cube?: string };
66+
type Selection = { dimensions?: string[]; measures: string[]; runtimeFilter?: Record<string, unknown> };
67+
68+
const refusalOf = (p: Promise<unknown>): Promise<Refusal> =>
69+
p.then<never, Refusal>(
70+
() => {
71+
throw new Error('expected a refusal, but the query resolved');
72+
},
73+
(e) => e as Refusal,
74+
);
75+
76+
/** A `'protocol'` service answering one pending seed draft, which routes `previewDrafts` onto the preview path. */
77+
const pendingSeedProtocol = {
78+
getMetaItems: async () => [{ name: 'os28258_seed', object: OBJECT }],
79+
getMetaItem: async () => ({ item: { records: ROWS } }),
80+
};
81+
82+
describe('[#28258] a dotted dimension through a relationship path the dataset does not back', () => {
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+
const groupsOf = (rows: unknown, dimension: string) =>
95+
(rows as Array<Record<string, unknown>>)
96+
.map((r) => ({ key: r[dimension], total: Number(r.total) }))
97+
.sort((a, b) => String(a.key).localeCompare(String(b.key)));
98+
99+
beforeAll(async () => {
100+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
101+
engine = new ObjectQL({ logger: quiet } as never);
102+
engine.registerDriver(driver as never, true);
103+
await engine.init();
104+
engine.registry.registerObject({ name: PERSON, label: 'Person', fields: { name: { name: 'name', type: 'text' } } } as never);
105+
engine.registry.registerObject({
106+
name: ACCOUNT,
107+
label: 'Account',
108+
fields: {
109+
name: { name: 'name', type: 'text' },
110+
owner: { name: 'owner', type: 'lookup', reference: PERSON },
111+
},
112+
} as never);
113+
engine.registry.registerObject({
114+
name: OBJECT,
115+
label: 'Deal',
116+
fields: {
117+
region: { name: 'region', type: 'text' },
118+
amount: { name: 'amount', type: 'number' },
119+
account: { name: 'account', type: 'lookup', reference: ACCOUNT },
120+
},
121+
} as never);
122+
await engine.syncSchemas();
123+
await installDefaultOrganization(engine as never);
124+
for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as never);
125+
for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as never);
126+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never);
127+
128+
for (const [face, caps] of [
129+
['native', undefined],
130+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
131+
] as const) {
132+
const registered: Record<string, unknown> = {};
133+
const provided: Record<string, unknown> = { data: engine, protocol: pendingSeedProtocol };
134+
await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({
135+
getService: (name: string) => provided[name] ?? registered[name],
136+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
137+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
138+
hook: () => {},
139+
logger: quiet,
140+
} as never);
141+
services[face] = registered.analytics as AnalyticsService;
142+
}
143+
});
144+
145+
afterAll(async () => {
146+
try { await engine?.destroy(); } catch { /* noop */ }
147+
});
148+
149+
for (const [label, dataset, member, path] of [
150+
["'os28258_deal.bogus' (the object's own name as a hop)", UNBACKED, 'os28258_deal.bogus', 'os28258_deal'],
151+
["'account.name' (a real lookup the dataset does not include)", UNBACKED, 'account.name', 'account'],
152+
["'account.owner.name' (one hop past the included 'account')", BACKED, 'account.owner.name', 'account.owner'],
153+
] as const) {
154+
it(`refuses ${label} INVALID_FIELD / 400 with one message on every face`, async () => {
155+
const refusals = await Promise.all(
156+
FACES.map((face) => refusalOf(ask(face, dataset, { dimensions: [member], measures: ['total'] }))),
157+
);
158+
for (const [i, err] of refusals.entries()) {
159+
expect(err.code, `${FACES[i]}: ${err.message}`).toBe('INVALID_FIELD');
160+
expect(err.status).toBe(400);
161+
expect(err.param).toBe('dimensions');
162+
expect(err.member).toBe(member);
163+
expect(err.cube).toBe(dataset.name);
164+
expect(err.message).toContain(`Dimension '${member}' on cube '${dataset.name}' traverses relationship path '${path}'`);
165+
}
166+
const [native, objectql, preview] = refusals;
167+
expect(objectql.message).toBe(native.message);
168+
expect(preview.message).toBe(native.message);
169+
});
170+
}
171+
172+
it("refuses an unbacked dotted dimension beside a declared one with one message on every face", async () => {
173+
const refusals = await Promise.all(
174+
FACES.map((face) => refusalOf(ask(face, UNBACKED, { dimensions: ['region', 'account.name'], measures: ['total'] }))),
175+
);
176+
for (const err of refusals) {
177+
expect(err.code, err.message).toBe('INVALID_FIELD');
178+
expect(err.status).toBe(400);
179+
expect(err.member).toBe('account.name');
180+
}
181+
expect(refusals[1].message).toBe(refusals[0].message);
182+
expect(refusals[2].message).toBe(refusals[0].message);
183+
});
184+
185+
it("CONTROL: a backed 'account.name' serves on every face, the same groups on both live strategies", async () => {
186+
const expected = [{ key: 'Acme', total: 130 }, { key: 'Globex', total: 50 }];
187+
for (const face of ['native', 'objectql'] as const) {
188+
const result = await ask(face, BACKED, { dimensions: ['account.name'], measures: ['total'] });
189+
expect(groupsOf(result.rows, 'account.name'), face).toEqual(expected);
190+
}
191+
// The preview serves it (it is not refused); its seed rows carry no join.
192+
const preview = await ask('preview', BACKED, { dimensions: ['account.name'], measures: ['total'] });
193+
expect(Array.isArray(preview.rows)).toBe(true);
194+
});
195+
196+
it("CONTROL: '<object>.region', which resolves to the declared 'region', still serves on every face", async () => {
197+
const expected = [{ key: 'amer', total: 150 }, { key: 'emea', total: 30 }];
198+
for (const face of ['native', 'objectql'] as const) {
199+
const result = await ask(face, UNBACKED, { dimensions: ['os28258_deal.region'], measures: ['total'] });
200+
expect(groupsOf(result.rows, 'os28258_deal.region'), face).toEqual(expected);
201+
}
202+
const preview = await ask('preview', UNBACKED, { dimensions: ['os28258_deal.region'], measures: ['total'] });
203+
expect(Array.isArray(preview.rows)).toBe(true);
204+
});
205+
206+
it("CONTROL: an undeclared plain 'bogus' (#28201) keeps its own refusal on every face", async () => {
207+
const refusals = await Promise.all(
208+
FACES.map((face) => refusalOf(ask(face, UNBACKED, { dimensions: ['bogus'], measures: ['total'] }))),
209+
);
210+
for (const err of refusals) {
211+
expect(err.code, err.message).toBe('INVALID_FIELD');
212+
expect(err.status).toBe(400);
213+
expect(err.field).toBe('bogus');
214+
expect(err.message).toContain("Dimension 'bogus' on cube 'os28258_deals' groups by field 'bogus'");
215+
}
216+
});
217+
218+
it("CONTROL: a measure mistake beside an unbacked dotted dimension is answered about the measure on every face", async () => {
219+
const refusals = await Promise.all(
220+
FACES.map((face) => refusalOf(ask(face, UNBACKED, { dimensions: ['account.name'], measures: ['nope'] }))),
221+
);
222+
for (const err of refusals) {
223+
expect(err.code, err.message).toBe('INVALID_FIELD');
224+
expect(err.param).toBe('measures');
225+
}
226+
expect(refusals[2].message).toBe(refusals[0].message);
227+
});
228+
229+
it("CONTROL: a runtimeFilter member through an unbacked path keeps the native strategy's DATASET_INVALID", async () => {
230+
const err = await refusalOf(
231+
ask('native', UNBACKED, { dimensions: ['region'], measures: ['total'], runtimeFilter: { 'account.name': 'Acme' } }),
232+
);
233+
expect(err.code, err.message).toBe('DATASET_INVALID');
234+
expect(err.status).toBe(400);
235+
});
236+
});

0 commit comments

Comments
 (0)