Skip to content

Commit c01690e

Browse files
fix(service-analytics)!: members read through a relationship path answer one refusal on the native, ObjectQL and draft-preview faces (#28686)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB1hsVtuncZ5F5C4zpiftHC)_ Fixes #28555 Fixes #28556 Fixes #28557 Fixes #28558 Clause-②: no (narrowing) — the draft preview (`queryDataset` with `previewDrafts: true`) now refuses `INVALID_FIELD` / 400 any member it would read through a relationship path (a dimension, time dimension, measure or filter member), which it served as one `null` group, `[]` or a total of `0`, because it joins nothing; a `timeDimensions` entry and a `runtimeFilter` member spelled through a relationship path the dataset's `include` does not declare are refused `INVALID_FIELD` / 400 on every face, where the native strategy answered `DATASET_INVALID` / 400 and the preview served; and a declared path to a column the related object lacks is refused `INVALID_FIELD` / 400 before SQL, where the native strategy answered `DATABASE_ERROR` / 500. Nothing is widened: no export, answer member, error code or status is added; every code was already on the wire. Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` says the draft preview joins a related object or names the code a selection-side relationship refusal answers; `content/docs/data-modeling/analytics.mdx`'s "Only declared paths are joinable" is now true of time dimensions and filter members too. **BREAKING** accept-set narrowing on the `queryDataset` draft preview (`@objectstack/service-analytics`), and a code change on two native refusals. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:数据集查询里经关系路径(点号)读字段的成员,三条执行面(原生 SQL、ObjectQL、草稿预览)现在给一致的答复。草稿预览只读本对象的草稿种子行、不做 join,所以遇到 `account.name` 这类已声明的关联字段(维度、时间维度、度量或筛选)改为 400 `INVALID_FIELD` 明确拒绝并说明「预览不 join 关联对象,请发布后查询或去掉该成员」,不再给一个 `null` 分组;`对象名.region`、`数据集名.region` 这种解析到已声明维度的写法在预览里给出与线上一致的分组。未在 `include` 声明的路径写在时间维度或 `runtimeFilter` 里,三面同一条 400 `INVALID_FIELD` 消息;关联对象上不存在的列(`account.bogus`)在发 SQL 前拒绝,不再是原生面的 500。 - **为什么改**:卡 #28555(p2)、#28556、#28557、#28558(p3),都是 #28258 开发者在真 SQLite 上测出的同族缺口:作者在草稿里看到一个看似合理的总数,发布后数字却变了;同一个写错的筛选三面三种答复;一个拼写错误变成服务器 500。 - **风险与回滚**:线上两条策略对已声明路径照旧服务(ObjectQL 面自身的跨对象限制不变);读不了的关联对象仍先给 403;手写 cube 的配置白名单和其它所有原因仍是 `DATASET_INVALID`。预览对关联字段由「错的数」变为「明确拒绝」,这是收窄,需先过合约评审。仓内唯一读 `DATASET_INVALID` 的运行时消费方(`@object-ui/data-objectstack` 的 `queryDataset` 适配器)对任何具名错误码一视同仁。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/analytics-service.ts` 的 `relationshipMembers`、`assertDimensionJoinsBacked`(扩到时间维度和筛选叶子)、新的 `assertRelationshipColumnsExist` 与 `assertPreviewJoinsNothing`,以及预览闸门列表末尾的三处调用;`previewEvaluationCube`。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/dotted-path-preview-parity.test.ts`,预期 15 passed。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes All in `packages/services/service-analytics/src/analytics-service.ts`, reusing PR #28553's gate, its `unbackedRelationshipPath` and `invalidMemberError`; no second judgement of "is this path backed". - **`relationshipMembers(query, cube, withMeasures)`** (new, module-level). Every request entry that reads a column through a relationship path, in request order: `dimensions`, `timeDimensions`, optionally `measures`, then the `where` leaves read through the lowering both strategies compile with (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`, as `namedQueryFields` reads them, so the dataset's own `filter` and a measure's `filter`, which `DatasetExecutor` folds into the query's `where`, are included). Each is resolved with `declaredMemberEntry` (the strategies' `lookupMember` mirror), so `OBJECT.region` / `CUBE.region` and expressions are not in it. - **#28556, #28557: `assertDimensionJoinsBacked` covers every position.** It now iterates `relationshipMembers` instead of `dimensions` alone, so an undeclared dotted `timeDimensions` entry (`param: 'timeDimensions'`) and `where` leaf (`param: 'where'`) are refused with the same message shape (`Time dimension '…'` / `Filter member '…'` … `traverses relationship path '…', which the dataset does not declare in its include…`). The `dimensions` message is byte-unchanged. Same two call sites as before (`callCtx` after the field-level verdicts, and the preview's gate list), so the three faces answer byte-equal messages and #20933's 403 order holds. - **#28558: `assertRelationshipColumnsExist`** (new private, called right after the join gate at both sites). For a member whose path IS backed, it resolves the last hop's object with `resolvePathHops` and `this.hopReference` (the resolver the admission and strategies read) and checks the column against `getObjectFieldNames` (plus `id` / `created_at` / `updated_at`, as the base-object gates admit). Missing ⇒ `invalidMemberError` with `member` / `param` / `cube`, plus `field` and `object`, the shape `assertDimensionFields` / `assertWhereFields` attach. Same stand-downs as those gates (no probe, no answer for that object). - **#28555: the preview.** `assertPreviewJoinsNothing` (new private, last in the preview's gate list) refuses any member `relationshipMembers(…, true)` names, `INVALID_FIELD` / 400 naming the member, saying the preview reads the base object's drafted seed rows alone and joins no related object. `previewEvaluationCube` (new, module-level) hands the evaluator a cube carrying an alias entry for each requested dimension / time-dimension spelling that resolves to a differently-keyed declared dimension, so `OBJECT.region` reads `region`; every gate still reads the plain cube. - **Which way #28555 went, and why.** The order allowed joining or refusing. I refused. A join in the preview would read the RELATED object's rows, which can live in its own pending seed, in the published table, or both, keyed differently in each (a seed row references a lookup by its external id, a stored row by `id`), and it would read an object the preview branch neither admits nor scopes today (`cubeObjects` names only the base object and declared joins, and no read scope is applied to seed rows). That is a second query engine with its own admission, not a fix in the dataset compiler's path. The refusal names the member and the remedy; the `null` group looked like an answer. A dotted spelling that resolves to a declared dimension is not a join, so it is now answered, not refused. - Comments only: the native join allowlist's docblock (`native-sql-strategy.ts`) and the preview evaluator's header (`preview-evaluator.ts`), which this diff made false. - Tests: new `src/__tests__/dotted-path-preview-parity.test.ts` (15 cases, real SQLite, the #28201 / #28258 harness). `dotted-dimension-unbacked-join-parity.test.ts`: the backed-path CONTROL now pins the preview's refusal, and the `runtimeFilter` CONTROL that pinned native `DATASET_INVALID` now pins the #28557 refusal. `packages/rest/src/analytics-dataset-refusal-envelope.test.ts`: the allowlist row's `runtimeFilter` trigger now answers `INVALID_FIELD` from the service gate (`code` override and message), audit trail kept in its comment. - Changeset `.changeset/28555-dotted-path-preview-parity.md`, `@objectstack/service-analytics` patch. ## Why Measured on `origin/main` `0b6a52e8` (real SQLite, every face), with a probe printing each face's answer per selection: <details><summary>Before / after per selection (dataset over a deal object, `include: ['account']` where "backed")</summary> | selection | native before | ObjectQL before | preview before | after | |:--|:--|:--|:--|:--| | backed `account.name` dim | groups | groups | one `null` group, total 180 | live faces unchanged; preview refuses (joins nothing) | | declared `account_name` over `account.name` | groups | groups | one `null` group | same | | `OBJECT.region`, `CUBE.region` dim | groups | groups | one `null` group | preview answers the same groups | | backed `account.bogus` dim | `DATABASE_ERROR` / 500 | `INVALID_FIELD` (engine groupBy guard) | one `null` group | `INVALID_FIELD` / 400, one message, `field: bogus`, `object` the account object | | backed `account.bogus` runtimeFilter | `DATABASE_ERROR` / 500 | `INVALID_FIELD` (cross-object filter) | `[]` | same one refusal, `param: where` | | unbacked `account.founded_at` time dim, bucketed | `INVALID_FIELD` (cross-object bucket) | same | axis dropped, total 180 | one unbacked-path message, `param: timeDimensions` | | unbacked time dim, windowed only | `DATASET_INVALID` (allowlist) | `INVALID_FIELD` (cross-object bucket) | total 0 | same one message | | backed time dim, windowed only | total 130 | `INVALID_FIELD` (cross-object bucket) | total 0 | native unchanged; preview refuses | | unbacked `account.name` runtimeFilter | `DATASET_INVALID` | `INVALID_FIELD` (cross-object filter) | `[]` | one unbacked-path message, `param: where` | | …under `$or` with `region: emea` | `DATASET_INVALID` | `INVALID_FIELD` | the emea rows | same one message | | backed `account.name` runtimeFilter | rows | `INVALID_FIELD` (cross-object filter) | `[]` | live unchanged; preview refuses | | backed `account.id` dim | groups | `INVALID_QUERY` (duplicate groupBy column) | one `null` group | live unchanged; preview refuses | </details> ## Tests <details><summary>Red first, green, ablations</summary> - Red on base: the three production files restored to `0b6a52e8` (`git restore --source`), tests at HEAD `44e4ddbc`: `Tests 15 failed | 9 passed (24)` across the new file and the updated #28258 file; every new refusal pin red, the controls green. Restored with `git checkout HEAD --`, `git diff HEAD` empty. - Green at `44e4ddbc`: the two files `24 passed (24)`. - Ablations (`node scripts/ablation-replace.mjs`, each anchor 1 → 0, restore blob == HEAD `c9f3025c6ad8` and `git diff HEAD` empty; the suite imports `src`, no build between): - A1 `--delete` the preview's `assertPreviewJoinsNothing` call: `6 failed | 18 passed` (the five #28555 refusals and the #28258 backed CONTROL). - A2 the evaluator handed `cube` instead of `previewEvaluationCube(q, cube)`: `2 failed` (`OBJECT.region`, `CUBE.region`). - A3 `--delete` the `callCtx` call of `assertRelationshipColumnsExist`: `2 failed` (#28558 dimension and runtimeFilter). - A4 `--delete` the `where`-leaf read in `relationshipMembers`: `5 failed` (#28557 ×2, #28558 runtimeFilter, the backed runtimeFilter preview case, the #28258 runtimeFilter pin). - A5 `--delete` the `timeDimensions` read in `relationshipMembers`: `3 failed` (#28556 ×2, the backed windowed preview case). - `pnpm --filter @objectstack/service-analytics exec vitest run` at `44e4ddbc`: `Test Files 202 passed (202)`, `Tests 4838 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`. - `tsc --noEmit` for service-analytics exit 0; `eslint --no-inline-config packages/services/service-analytics/src packages/rest/src/analytics-dataset-refusal-envelope.test.ts` exit 0. </details> ## Acceptance notes - `DATASET_INVALID` readers for the two native code moves (#28557's `runtimeFilter` member, #28556's windowed time dimension), measured with `git grep` across the tree, `ui/` included: the only runtime reader is `@object-ui/data-objectstack`'s `queryDataset` adapter, whose branch ⑤ treats every named non-permission code identically; its two tests use the code as fixture text. No reader in `packages/client`, `packages/cli`, `packages/runtime` or dogfood. The one in-repo pin was the rest refusal-envelope row (moved, above). So no real consumer, and both halves land. - On the dataset route, nothing a request names reaches the native join allowlist any more; it stays for a hand-authored cube's configured hook. `DATASET_INVALID` stays for every other reason it is raised. - Out of scope, measured, reported to the seat (not fixed here): the preview drops a bucketed `timeDimensions` entry that is not also in `dimensions` (plain `closed_at`, live faces group by month, preview one row); the preview answers a `runtimeFilter` member spelled `OBJECT.region` with `[]` (it matches row keys as written); the ObjectQL strategy answers a backed `account.id` dimension `INVALID_QUERY` / 400 about a duplicate groupBy column. ## Verification Head is `efa6f756` (delta on `c4f1e6c2`, delta review PASS in PR comment 6559465368). The readings below were taken on `c4f1e6c2` unless marked. - **Correction:** the local `tsc --noEmit` reading under Tests (exit 0) was wrong for `c4f1e6c2`. CI's `Type Check · workspace` concluded failure there: TS2352 at `dotted-path-preview-parity.test.ts` (213,61) and (252,48), since the package typecheck includes `__tests__`. `efa6f756` fixes it (the case tables use `satisfies`, no cast): `pnpm --filter @objectstack/service-analytics typecheck` exit 2 on the old file, exit 0 on the fix. - `efa6f756` also refuses a dataset measure over a backed path to a missing related column before SQL (native answered `DATABASE_ERROR` / 500; red leg by ablation, 1 failed | 15 passed, restored), and the rest refusal-envelope row and five-entries pin now say the allowlist row is a string-level audit. At `efa6f756`: service-analytics `Test Files 202 passed (202)`, `Tests 4839 passed | 282 skipped`; rest `src/analytics` `23 passed (23)`; eslint exit 0; gates 62 derived, 61 run exit 0, `check:dual-build-cjs-loads` NOT MEASURED locally (exit 3, prerequisite: no full build), read from CI. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 families derived on `c4f1e6c2` (7 paths vs merge base `0b6a52e8a`); 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 0b6a52e..HEAD` exit 0. - Dogfood battery (Clause-② carries a narrowing arm), on `c4f1e6c2`: `pnpm --filter @objectstack/dogfood test` under the lock, VERDICT command-exit 0, `Test Files 369 passed | 3 skipped (372)`, `Tests 2981 passed | 28 skipped (3009)`, no failure. - `git merge-tree --write-tree origin/main HEAD` against `e4ec4f22` exit 0; the only open PR naming `service-analytics` is the version-packages PR #26517. - 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. (Four 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 36fa4ff commit c01690e

7 files changed

Lines changed: 593 additions & 46 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)!: members read through a relationship path answer one refusal on the native, ObjectQL and draft-preview faces
6+
7+
Clause-②: no (narrowing) — the draft preview (`queryDataset` with `previewDrafts: true`) now refuses `INVALID_FIELD` / 400 any member it would read through a relationship path (a dimension, time dimension, measure or filter member), which it served as one `null` group, `[]` or a total of `0`, because it joins nothing; a `timeDimensions` entry and a `runtimeFilter` member spelled through a relationship path the dataset's `include` does not declare are refused `INVALID_FIELD` / 400 on every face, where the native strategy answered `DATASET_INVALID` / 400 and the preview served; and a declared path to a column the related object lacks is refused `INVALID_FIELD` / 400 before SQL, where the native strategy answered `DATABASE_ERROR` / 500. Nothing is widened: no export, answer member, error code or status is added; every code was already on the wire.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a selection that reads through a relationship path is refused by the preview that cannot join it, a selection naming an undeclared path or a missing related column is refused on the faces that served it or failed on it, and the native face's refusal takes the member-level code the other member gates answer. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on the `queryDataset` draft preview (`@objectstack/service-analytics`), and a code change on two native refusals.
13+
14+
- **Before.** Measured on real SQLite over a dataset `include: ['account']`: the preview answered a backed `account.name` (or a declared dimension over it, or a declared measure over a related field) with one `null` group holding the whole total, a backed `runtimeFilter` member with `[]`, and a windowed backed time dimension with a total of `0`, while the live strategies served the real answer. `OBJECT.region` and `CUBE.region`, which both live strategies resolve to the declared `region`, also came back as one `null` group. A time dimension through an undeclared path was dropped by the preview and refused `DATASET_INVALID` by the native strategy when only windowed; a `runtimeFilter` member through an undeclared path got `DATASET_INVALID` (native), the cross-object filter refusal (ObjectQL) and `[]` or, under an `$or`, the other arm's rows (preview). `account.bogus` reached SQL on the native strategy as a 500.
15+
- **Now.** The service's join gate (the one `dimensions` already asked since the previous fix) also judges `timeDimensions` entries and `where` leaves, so an undeclared path is refused on every face with one message naming the member and the path. A second gate refuses a declared path to a column the related object does not have, naming the member, the column (`field`) and the object (`object`), on every face with one message. The preview then refuses any remaining relationship-path member with a message saying it joins no related object, and answers `OBJECT.region` / `CUBE.region` with the declared dimension's groups.
16+
- **Unchanged.** The live strategies serve a backed path as before (the ObjectQL strategy keeps its own cross-object refusals for a filter, a bucketed time dimension or a measure it cannot join). A path through an object the caller may not read keeps its 403. A hand-authored cube's configured allowlist still answers `DATASET_INVALID`, and so does every other reason that code is raised.
17+
- **Who sees it.** An author previewing a draft dataset that groups, filters or measures through a related object: the preview now says it cannot join instead of showing a number that changes after publish. A caller naming a related field the dataset does not include, or a related column that does not exist: one `INVALID_FIELD` / 400 on every face.
18+
19+
**The remedy.** Publish the draft to query a related field through the live strategies, or preview without it; add the relationship to the dataset's `include`; check the related column's spelling.

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env
153153
body: unknown;
154154
analytics: () => AnalyticsService;
155155
message: RegExp;
156-
/** Defaults to `DATASET_INVALID`; see the one row that legitimately differs. */
156+
/** Defaults to `DATASET_INVALID`; see the two rows that legitimately differ. */
157157
code?: string;
158158
}> = [
159159
{
@@ -225,17 +225,22 @@ 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 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.
234-
name: 'native-sql-strategy: a selection naming a join outside the allowlist',
228+
// SELECTION filters on a dotted member, which the strategy would compile
229+
// into a join at alias `account`. [#28258] A dotted `dimensions` entry
230+
// was the trigger here until the service learned to refuse it ahead of
231+
// either strategy; [#28557] a `runtimeFilter` member (and [#28556] a
232+
// `timeDimensions` entry) is now refused by the same service gate,
233+
// `INVALID_FIELD` / 400 naming the member, one answer on all three
234+
// dataset faces. No request member reaches the strategy's allowlist on
235+
// this route any more (it stays for a hand-authored cube's configured
236+
// hook). The row keeps its audit trail: the input this list entry used to
237+
// classify answers 400 from a declared envelope, never from a message.
238+
name: 'native-sql-strategy allowlist input (now refused by the service join gate, not the strategy): a selection naming a join outside the allowlist',
235239
listEntry: 'not backed by a declared relationship',
236240
analytics: nativeAnalytics,
237241
body: { dataset, selection: { dimensions: ['stage'], measures: ['revenue'], runtimeFilter: { 'account.region': 'emea' } } },
238-
message: /join "account" is not backed by a declared relationship on cube "pipeline"/,
242+
message: /Filter member 'account\.region' on cube 'pipeline' traverses relationship path 'account', which the dataset does not declare/,
243+
code: 'INVALID_FIELD',
239244
},
240245
];
241246

@@ -256,6 +261,11 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env
256261
});
257262
}
258263

264+
// The five LIST ENTRIES, not five live producers: since #28557 the
265+
// `not backed by a declared relationship` row is answered by the service's
266+
// join gate ahead of the native strategy, so its producer (the strategy's
267+
// allowlist) is no longer driven through this route. This pin is the
268+
// string-level audit that every deleted entry still has a row.
259269
it('covers exactly the five entries #5367 deleted from the message list', () => {
260270
expect(CASES.map((c) => c.listEntry).sort()).toEqual([
261271
'is not a subset of the selected dimensions',

‎packages/services/service-analytics/src/__tests__/dotted-dimension-unbacked-join-parity.test.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
* `null` group.
1717
* - A multi-hop spelling is judged hop by hop: with only `account` included,
1818
* `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`.
19+
* - CONTROL: a backed dotted dimension still serves on both live strategies
20+
* (the same groups), and the draft preview refuses it with its own refusal
21+
* (#28555, `dotted-path-preview-parity.test.ts`); a dotted spelling that
22+
* resolves to a declared dimension (`<object>.region`) still serves; an
23+
* undeclared plain dimension (`bogus`, #28201) keeps its own refusal. A
24+
* `runtimeFilter` member through an unbacked path is refused by the same
25+
* gate since #28557 (pinned in that file too).
2426
*/
2527

2628
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -182,15 +184,18 @@ describe('[#28258] a dotted dimension through a relationship path the dataset do
182184
expect(refusals[2].message).toBe(refusals[0].message);
183185
});
184186

185-
it("CONTROL: a backed 'account.name' serves on every face, the same groups on both live strategies", async () => {
187+
it("CONTROL: a backed 'account.name' serves on both live strategies, the same groups", async () => {
186188
const expected = [{ key: 'Acme', total: 130 }, { key: 'Globex', total: 50 }];
187189
for (const face of ['native', 'objectql'] as const) {
188190
const result = await ask(face, BACKED, { dimensions: ['account.name'], measures: ['total'] });
189191
expect(groupsOf(result.rows, 'account.name'), face).toEqual(expected);
190192
}
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);
193+
// [#28555] The preview joins nothing, so it refuses the backed path with
194+
// its own refusal rather than this gate's (which judges only unbacked ones).
195+
const err = await refusalOf(ask('preview', BACKED, { dimensions: ['account.name'], measures: ['total'] }));
196+
expect(err.code, err.message).toBe('INVALID_FIELD');
197+
expect(err.status).toBe(400);
198+
expect(err.message).not.toContain('which the dataset does not declare');
194199
});
195200

196201
it("CONTROL: '<object>.region', which resolves to the declared 'region', still serves on every face", async () => {
@@ -226,11 +231,13 @@ describe('[#28258] a dotted dimension through a relationship path the dataset do
226231
expect(refusals[2].message).toBe(refusals[0].message);
227232
});
228233

229-
it("CONTROL: a runtimeFilter member through an unbacked path keeps the native strategy's DATASET_INVALID", async () => {
234+
it("[#28557] a runtimeFilter member through an unbacked path is refused by the same gate, param 'where'", async () => {
230235
const err = await refusalOf(
231236
ask('native', UNBACKED, { dimensions: ['region'], measures: ['total'], runtimeFilter: { 'account.name': 'Acme' } }),
232237
);
233-
expect(err.code, err.message).toBe('DATASET_INVALID');
238+
expect(err.code, err.message).toBe('INVALID_FIELD');
234239
expect(err.status).toBe(400);
240+
expect(err.param).toBe('where');
241+
expect(err.member).toBe('account.name');
235242
});
236243
});

0 commit comments

Comments
 (0)