Skip to content

Commit 5d5e679

Browse files
feat(spec)!: an analytics cube member's sql is a column reference, and the showcase done rate moves to its dataset (#20943) (#20998)
Closes #20943 Clause-②: yes (narrowing) Ruling D on #20943 (comment `5921156712`, execution parameters), dispatched by the claim `5921298734`: an analytics cube member's `sql` is a column reference, and a SQL expression there is refused at parse with a prescription naming the ADR-0021 dataset form. The showcase's one expression member, `done_rate`, moves to its dataset in the same PR. ## What changes **`@objectstack/spec`, `data/analytics.zod.ts`** - `MetricSchema.sql` and `DimensionSchema.sql` (every member of a cube's `measures` and `dimensions`) admit exactly the accept set the execution parameters name: a bare identifier (`amount`), a dotted identifier path (`account.amount`, `account.owner.region`), and `'*'`. Any other value is refused at `measures.METRIC.sql` / `dimensions.DIMENSION.sql` with code `invalid_format`. That covers a CASE expression, an aggregate or a ratio of aggregates, a quoted or `$`-prefixed spelling, an empty string and a broken path. A column reference parses byte-identically to before. - The identifier half is the pattern the readers already use to tell a column path from an expression: `IDENTIFIER_PATH` in `native-sql-strategy.ts`, and the field gate's bare-identifier / identifier-path pair. So the contract admits exactly the values those readers resolve to fields. - The rule is a `.regex()`, not a refinement, so the published JSON Schema carries it as a `pattern`. A first cut used refinements, and the build's dropped-refinement gate refused it: a JSON-Schema-validated document would have been judged differently from the parse. The pattern closes that gap, and `dropped-refinements.baseline.json` is untouched. - Each prescription opens with the contract sentence. It names the dataset form: a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. It also states the ratio's 0–1 scale. The dimension prescription says that a CASE bucket has no expression form in either layer. - Neighbouring prescriptions no longer offer "fold the condition into the metric's own `sql` expression" as a live channel. This covers the retired metric `filters` guidance, the analytics query `filters` guidance, the `metric-filters-removed` conversion summary, its D3 entry `cube-metric-filters-retired`, and its step-18 rationale fragment. All of them are unreleased major-18 text. **ADR-0087.** The D3 entry `cube-member-sql-expression-retired` (`migrations/entries/semantic/`) and its step-18 rationale fragment were written after merging a `main` that contains #20953, which landed as `c6b3a01d5d`. `registry.ts` was regenerated by `gen:migration-registry`, never edited by hand. There is no D2 conversion, because an expression has no mechanical rewrite into a dataset. There is no `RETIRED_KEYS_BY_MAJOR` row, because no key left the shape. **Liveness.** The `analytics_cube` rows `measures.sql` and `dimensions.sql` stay `live`, re-verified 2026-09-30, with the narrowing recorded and the field gate's `fieldsOfColumnSql` added to the evidence. **Generated.** Only `content/docs/references/data/analytics.mdx` moved. The `authorable-surface`, `api-surface` and `json-schema.manifest` ratchets are byte-identical, as expected for a value narrowing. `spec-changes.json` and the upgrade guide stay at protocol 17, so major-18 entries do not project yet, and both checks are green. **Showcase.** The `showcase_delivery` cube loses `done_rate`. The `showcase_task_metrics` dataset gains `done_count` (`aggregate: 'count'`, `filter: { status: 'done' }`) and `done_rate` (`derived: { op: 'ratio', of: ['done_count', 'task_count'] }`, `format: '0.0%'`), in the same shape as the existing `paid_rate`. No dashboard read the cube's `done_rate`, so there was nothing to re-pin there. `test/gap-fill.test.ts` is re-pinned: the cube's measure list, the parse of the shipped cube, and the dataset's filtered-count-over-count form plus its `DatasetSchema` parse. **`@objectstack/service-analytics`.** One test and the README, nothing else: - `cube-authored-format-granularity.test.ts` built its custom-SQL fixture with `CubeSchema.parse`, which now refuses it. The fixture is built unparsed instead, plus one assertion that the parse refuses it. The engine-path refusal it pins is unchanged. - `README.md` no longer tells a reader to fold a per-metric condition into the metric's own `sql` expression. That text ships in the package, so it gets a `patch` line in the changeset. This file is outside the claim's declared surface; see the Acceptance notes. The gate's stand-down branch in `analytics-service.ts` is not touched. #20965 remains open for it. ## The fork clause The fork clause did not fire. The one authored expression member in the tree is `done_rate`, and its structural equivalent exists and is measured (next section). A shape-level reading for the seat: a dimension CASE bucket has no dataset equivalent, because a dataset dimension names a field and its only bucketing is `dateGranularity`. No authored cube in the tree carries one. Two `service-analytics` gate fixtures do (`dimension-source-field-gate.test.ts`, `where-source-field-gate.test.ts`), but they are built without the parse to pin the runtime branch #20965 owns. Out-of-repo cubes are NOT MEASURED. ## The measure-level filter, verified before relying on it - **Code path.** `compileDataset` reads `m.filter` into `measureFilters`. `DatasetExecutor` splits filtered measures off with `splitMeasuresByFilter` and runs one supplementary query per filtered measure over the base filter combined with that measure's filter. It then evaluates `derived` on the merged row, with `ratio` as `a / b` and null on a zero denominator. - **Unit pins.** The existing `dataset-executor.test.ts` cases cover a supplementary query for a measure-scoped filter and a derived measure over filtered and unfiltered dependencies, and `dataset-compare-measure-filters.test.ts` covers the number a reader sees. All are green in the `service-analytics` run below. - **Live.** An ephemeral showcase boot on a private port (`pnpm dev -- --fresh`, torn down after) queried the task dataset through the analytics dataset door as the seeded admin. `done_count` and `done_rate` reconciled against the raw `showcase_task` rows in every priority bucket and in the ungrouped total. `done_rate` equalled `done_count / task_count`, and the column carried `format: '0.0%'` and `percentScale: 'fraction'`. The cube's meta listed its three remaining measures. ## Tests (final head `fa979c57ed` unless stated) - `@objectstack/spec`: - `vitest run --project local src/`: 544 files, 16268 passed, 1 todo. - `--project local scripts/`: 41 files, 950 passed, 1 skipped (at `a32fd122dc`; the second merge brought no `scripts/` change under `packages/spec`). - The relevant `--project repo` files: 10 files, 300 passed. These are `step18-rationale-merge`, `conversions-major18-merge`, `liveness/evidence`, `liveness/proof-registry`, `cube-member-inner-name-retirement`, `cube-refresh-key-retirement`, `retired-key-migrate-sentence`, `root-index`, `file-description` and `category-title`. - `typecheck` exit 0. - `@objectstack/service-analytics`: `test` gave 149 files and 3449 passed; `typecheck` exit 0, and the edited test is in its program (`--listFiles`). - `@objectstack/example-showcase`: `vitest run` gave 29 files and 387 passed, after rebuilding its closure (60 tasks); `typecheck` exit 0, and the three edited files are in its program. - Gates: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 116 families. All 116 were run, and `--ran` reconciled them as "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN", each with an exit code. - 115 of them exited 0, including `check:generated`, `check:liveness`, `check:adr-0087-registration` (reads the new marker), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring` and `check:nul-bytes`. - The other one is `pnpm check:platform-checklist`, which exited 1 on an anchor this diff does not touch. `areas/identity-auth.json` cites `plugin-auth/src/auth-plugin.ts#twoFactor`, and that symbol is absent. This branch's bytes for `packages/plugins/plugin-auth`, `docs/qa/platform-checklist`, `scripts/check-platform-checklist.mjs` and `scripts/symbol-anchors.mjs` are identical to the merge base `05be352596`, so it is `main`'s state, not this diff's. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 10 changed lintable files gave 10 files, 0 errors and 0 warnings. - The population comes from `eslint.config.mjs`: the `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. The other changed files are `.md`, `.mdx` and `.json`. - The file count comes from the JSON output. - Invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` is CI's. ## Ablations (each from the committed state; disk-verified through `scripts/ablation-replace.mjs`; restore proven by blob hash equal to HEAD and an empty `git diff HEAD`) | leg | mutation | result under mutation | restored | |---|---|---|---| | A1 | the member `sql` pattern admits anything | new spec pin: 9 failed / 6 passed (every refusal, door and pattern case red) | 15 / 15 green | | A2 | the metric `filters` guidance offers the `sql` expression channel again | 1 failed (the guidance case) | green | | A3 | the D3 id renamed in the generated registry region | 1 failed (the registration case) | green | | A4 | the showcase `done_count` loses its filter | `gap-fill`: 1 failed (the dataset-form case) | green | | A5 | the pattern admits anything, then `@objectstack/spec` rebuilt | `ablation-dist-preflight` marker present in 20 built files; the service-analytics parse assertion failed (`expected true to be false`) | rebuilt; marker absent from all 230 files; tree clean; 14 / 14 green | The expected direction was "turns red" in all five, and that is what was observed. No ablation file is left in the tree. ## Acceptance notes - **Surface breach, declared.** `packages/services/service-analytics/README.md` is outside the claim's file surface. The dev contract's rule says published text this change makes false is fixed in the same round, and the dev contract outranks the dispatch words where they conflict. The edit is one paragraph, and the changeset carries `'@objectstack/service-analytics': patch` for it. The seat can drop both if it prefers #20965 to carry the fix. - **Not fixed here, governed (Tier H).** `skills/objectstack-ui/rules/dashboards.md` still escalates past a dataset to "a hand-authored Cube (raw SQL / explicit joins)" (≈`:78`), and lists "any custom-SQL metric" as a dataset gap (≈`:70`). Both are now false: a cube member takes no SQL expression, and joins were already derived. The natural carrier is the separate Tier H docs PR the ruling names for ADR-0021's dated note. It is not mixed in here. - **Not fixed here, internal.** `docs/qa/platform-checklist/areas/dashboards.json` (the cube meta item, ≈`:976`, `:991`, `:1037`) names `done_rate` among "exactly its four measures" and as a variant. The cube now declares three. No gate reads that text. Carrier: the checklist's next revision. - **Boundary, left as ruled.** `'*'` is admitted on a dimension and on a non-count measure, because the execution parameters admit it on both members. Neither was ever a working query. Separately, the `number` / `string` / `boolean` measure types existed to carry an expression. With an identifier `sql`, the raw-SQL path emits the column unaggregated and the ObjectQL path refuses it, so retiring those three types is the natural follow-up. That is a decision for the seat, not something this PR does. - **Not added: a tree-scoped text pin.** The narrowing is enforced at every parse door. The one authored cube in the tree is parsed at import (`defineCube`) and pinned by the showcase test. The tree's other expression members are deliberate unparsed runtime fixtures in `service-analytics`, owned by #20965's cleanup. A text scan would have needed a directory exclusion and a new cross-package radius. - `tsc` does not catch an expression member, because `sql` is still `string`; the parse is the judge. Stored `analytics_cube` rows and built artifacts that carry an expression are NOT MEASURED here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebb66aa commit 5d5e679

15 files changed

Lines changed: 753 additions & 57 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': patch
4+
---
5+
6+
feat(spec)!: an analytics cube member's `sql` is a column reference — a SQL expression there is refused at parse, and a derived value is declared on an ADR-0021 dataset (#20943)
7+
8+
Clause-②: yes (narrowing)
9+
10+
**BREAKING** — shipped as `minor` under the launch-window convention
11+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
12+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
13+
never by the level).
14+
15+
`MetricSchema.sql` and `DimensionSchema.sql` — the `sql` of every member in an
16+
analytics cube's `measures` and `dimensions` — admit a column reference only: a
17+
field of the cube's object (`amount`), a relationship path of bare identifiers
18+
ending in one (`account.amount`, `account.owner.region`), or `'*'` for a count.
19+
Any other value — a `CASE WHEN …`, an aggregate or a ratio of aggregates, a quoted
20+
or `$`-prefixed spelling, an empty string — is refused at parse with a
21+
prescription. This is ADR-0021's "zero raw SQL / zero raw expressions" carried
22+
from the dataset layer to the cube members it compiles to (maintainer ruling D on
23+
the card): an expression names no single field, so no platform check can judge
24+
which fields it reads, and the two analytics strategies never agreed on it — the
25+
raw-SQL path ran it verbatim and the ObjectQL path refused it. The rule is a
26+
`pattern` in the published JSON Schema too, so a document validated against
27+
`json-schema/**` is judged as the parse judges it.
28+
29+
## FROM → TO
30+
31+
A derived value moves to an ADR-0021 dataset over the same object. A conditional
32+
count or sum is a dataset measure with its own structured `filter`; a ratio, sum,
33+
difference or product of measures is `derived: { op, of: [...] }` over measures
34+
named in the same dataset.
35+
36+
```
37+
FROM defineCube({ name: 'delivery', sql: 'task', measures: {
38+
done_rate: { label: 'Done Rate (%)', type: 'number',
39+
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)" },
40+
} })
41+
-> parsed; the expression ran verbatim on one strategy and was refused on the other
42+
TO -> ZodError at measures.done_rate.sql (invalid_format): `measures.<metric>.sql` is a
43+
column reference: a field of the cube's object (`amount`), a relationship path ending
44+
in one (`account.amount`), or `'*'` for a count. A SQL expression there was retired …
45+
46+
defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task',
47+
dimensions: [/* … */],
48+
measures: [
49+
{ name: 'task_count', aggregate: 'count' },
50+
{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } },
51+
{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' },
52+
] })
53+
```
54+
55+
**Mind the scale.** A `derived` ratio is a 0–1 fraction. An expression that
56+
multiplied by 100 returned percentage points; pair the ratio with a `%` numeral
57+
pattern (the server marks a ratio column's percent scale as a fraction) and
58+
re-check any consumer that read the old number raw.
59+
60+
**A dimension that bucketed a column with a CASE expression** has no expression
61+
form in the cube layer or the dataset layer: group by the column itself, or keep
62+
the bucket as a field of the object and name that field.
63+
64+
**The one-line fix:** parse each cube; every refusal at `…sql` is one member to
65+
move — replace it with the column it aggregates, or move the derived value to a
66+
dataset measure as above, and point the dashboards, reports and queries that named
67+
`<cube>.<member>` at the dataset measure.
68+
69+
**What an author who still writes it sees.** `CubeSchema`, `defineCube()`,
70+
`defineStack({ analyticsCubes })` (`STACK_SCHEMA_INVALID` / 422) and the
71+
`analytics_cube` write door refuse the member at its `sql` path with the
72+
prescription. `tsc` does not: the key's type is still `string`.
73+
74+
## The retirement kit
75+
76+
- **Schema.** `MetricSchema.sql` / `DimensionSchema.sql` carry the pattern and
77+
their prescriptions (`data/analytics.zod.ts`). A column reference parses
78+
byte-identically to before. The retired metric `filters` guidance and the
79+
analytics query's `filters` guidance no longer offer "fold the condition into
80+
the metric's own `sql` expression" as a live channel; the `metric-filters-removed`
81+
conversion summary and its D3 entry and step-18 rationale fragment say the same.
82+
- **ADR-0087.** The D3 entry `cube-member-sql-expression-retired`, with its
83+
step-18 rationale fragment. No D2 conversion — an expression has no mechanical
84+
rewrite into a dataset — and no `RETIRED_KEYS_BY_MAJOR` row: no key left the
85+
shape, so the authorable-surface, api-surface and JSON-schema manifest
86+
ratchets are unchanged.
87+
- **Liveness.** The `analytics_cube` ledger rows `measures.sql` and
88+
`dimensions.sql` stay `live`, re-verified, with the narrowing recorded.
89+
- **Docs.** The `data/analytics` reference page is regenerated.
90+
- **Example.** The showcase cube's `done_rate` expression member moves to the
91+
`showcase_task_metrics` dataset as `done_count` (a count filtered on
92+
`status: 'done'`) and `done_rate` (`ratio` over `done_count` and `task_count`,
93+
format `0.0%`).
94+
- **`@objectstack/service-analytics`** (README only): its query-body section no
95+
longer tells a reader to fold a per-metric condition into the metric's own
96+
`sql` expression. The runtime is unchanged: its expression branches remain for
97+
a cube that reaches the service without meeting the parse, and their deletion
98+
is a separate change.
99+
100+
## Reach, measured
101+
102+
- This repository: one authored expression member (the showcase `done_rate`),
103+
moved here. Test fixtures in `@objectstack/service-analytics` that build
104+
expression members WITHOUT the parse keep exercising the runtime's expression
105+
branches, unchanged.
106+
- Out-of-repo authored cubes: NOT MEASURED.
107+
108+
<!-- adr-0087: registered cube-member-sql-expression-retired -->

‎content/docs/references/data/analytics.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Type: `[string, string]`
147147
| **label** | `string` | ✅ | Human readable label |
148148
| **description** | `string` | optional | |
149149
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
150-
| **sql** | `string` | ✅ | SQL expression or field reference |
150+
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("amount"), a relationship path ending in one ("account.amount"), or "*" for a count. Never a SQL expression: a derived value is declared on an ADR-0021 dataset (a measure-scoped filter, or derived: `{ op, of }`). |
151151
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
152152

153153
### Nested Shape: `Cube.dimensions[string]`
@@ -158,7 +158,7 @@ Type: `[string, string]`
158158
| **label** | `string` | ✅ | Human readable label |
159159
| **description** | `string` | optional | |
160160
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
161-
| **sql** | `string` | ✅ | SQL expression or column reference |
161+
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("status") or a relationship path ending in one ("account.industry"). Never a SQL expression. |
162162
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | For a time dimension. A single interval is its default bucket: a query that groups by this dimension without stating a granularity is bucketed at it. Two or more intervals state no default. A granularity the query states always wins, listed or not. |
163163

164164
### Nested Shape: `Cube.joins[string]`
@@ -191,7 +191,7 @@ Type: `[string, string]`
191191
| **label** | `string` | ✅ | Human readable label |
192192
| **description** | `string` | optional | |
193193
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
194-
| **sql** | `string` | ✅ | SQL expression or column reference |
194+
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("status") or a relationship path ending in one ("account.industry"). Never a SQL expression. |
195195
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | For a time dimension. A single interval is its default bucket: a query that groups by this dimension without stating a granularity is bucketed at it. Two or more intervals state no default. A granularity the query states always wins, listed or not. |
196196

197197

@@ -220,7 +220,7 @@ Type: `[string, string]`
220220
| **label** | `string` | ✅ | Human readable label |
221221
| **description** | `string` | optional | |
222222
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
223-
| **sql** | `string` | ✅ | SQL expression or field reference |
223+
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("amount"), a relationship path ending in one ("account.amount"), or "*" for a count. Never a SQL expression: a derived value is declared on an ADR-0021 dataset (a measure-scoped filter, or derived: `{ op, of }`). |
224224
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
225225

226226

‎examples/app-showcase/src/data/analytics/showcase.cube.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,12 @@ export const DeliveryCube = defineCube({
3535
type: 'avg',
3636
sql: 'estimate_hours',
3737
},
38-
done_rate: {
39-
label: 'Done Rate (%)',
40-
type: 'number',
41-
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
42-
// A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a
43-
// percent, `.0` one decimal. The value above is in percentage points (0-100).
44-
format: '0.0%',
45-
},
38+
// No `done_rate` here: a member's `sql` names a column, never a SQL
39+
// expression (ADR-0021 "zero raw expressions", carried to the cube layer by
40+
// #20943). The done rate this cube used to compute with a CASE expression
41+
// is declared where the platform can judge every field it reads — the
42+
// `showcase_task_metrics` dataset (src/ui/datasets/chart-gallery.dataset.ts),
43+
// as a filtered count over a count.
4644
},
4745
dimensions: {
4846
status: {

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,18 @@ export const ShowcaseTranslationBundle = {
348348
},
349349
},
350350
},
351+
// Translated at birth, same rule as the widgets above: the done rate moved
352+
// here from the delivery cube (#20943) as two new dataset measures, born
353+
// under the ratchet. The task dataset's older labels predate it and stay in
354+
// the frozen baseline.
355+
datasets: {
356+
showcase_task_metrics: {
357+
measures: {
358+
done_count: { label: 'Done Tasks' },
359+
done_rate: { label: 'Done Rate' },
360+
},
361+
},
362+
},
351363
},
352364
'zh-CN': {
353365
objects: {
@@ -1018,6 +1030,16 @@ export const ShowcaseTranslationBundle = {
10181030
},
10191031
},
10201032
},
1033+
// The done rate's two measures, translated at birth (#20943) — see the `en`
1034+
// block. The other task-dataset labels stay in the frozen baseline.
1035+
datasets: {
1036+
showcase_task_metrics: {
1037+
measures: {
1038+
done_count: { label: '已完成任务' },
1039+
done_rate: { label: '完成率' },
1040+
},
1041+
},
1042+
},
10211043
// Page component copy became declared surface with `pages.<name>.components`
10221044
// (#6080), so these keys are born under the ratchet: leaving any of them
10231045
// untranslated widens the frozen baseline and fails `check-i18n-coverage`.

‎examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ export const ShowcaseTaskDataset = defineDataset({
2424
{ name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimate_hours', format: '0.0' },
2525
{ name: 'avg_estimate', label: 'Avg Estimate', aggregate: 'avg', field: 'estimate_hours', format: '0.0' },
2626
{ name: 'avg_progress', label: 'Avg Progress', aggregate: 'avg', field: 'progress', format: '0.0' },
27+
// The done rate, moved here from the `showcase_delivery` cube (#20943): a
28+
// cube member's `sql` is a column reference, and this is the declared
29+
// form of what its CASE expression computed — a count scoped by its own
30+
// structured filter, over the unfiltered count. `ratio` yields a 0–1
31+
// fraction (the cube's expression multiplied by 100), which the `%`
32+
// pattern displays as a percentage; the server annotates the column's
33+
// scale from the operator, as it does for `paid_rate`.
34+
{ name: 'done_count', label: 'Done Tasks', aggregate: 'count', filter: { status: 'done' } },
35+
{
36+
name: 'done_rate',
37+
label: 'Done Rate',
38+
derived: { op: 'ratio', of: ['done_count', 'task_count'] },
39+
format: '0.0%',
40+
},
2741
],
2842
});
2943

‎examples/app-showcase/test/gap-fill.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { existsSync } from 'node:fs';
44

55
import { describe, it, expect } from 'vitest';
66
import { SchemaRegistry } from '@objectstack/objectql';
7+
import { CubeSchema } from '@objectstack/spec/data';
8+
import { DatasetSchema } from '@objectstack/spec/ui';
79

810
import stack from '../objectstack.config.js';
911
import { DeliveryCube } from '../src/data/analytics/showcase.cube.js';
12+
import { ShowcaseTaskDataset } from '../src/ui/datasets/chart-gallery.dataset.js';
1013
import { AccountExtension } from '../src/data/extensions/account.extension.js';
1114
import { Account } from '../src/data/objects/account.object.js';
1215

@@ -26,7 +29,7 @@ describe('showcase gap fill — analytics cube', () => {
2629
it('declares measures and dimensions over the delivery backbone', () => {
2730
expect(DeliveryCube.sql).toBe('showcase_task');
2831
expect(Object.keys(DeliveryCube.measures ?? {})).toEqual(
29-
expect.arrayContaining(['count', 'total_estimate_hours', 'avg_estimate_hours', 'done_rate']),
32+
expect.arrayContaining(['count', 'total_estimate_hours', 'avg_estimate_hours']),
3033
);
3134
expect(Object.keys(DeliveryCube.dimensions ?? {})).toEqual(
3235
expect.arrayContaining(['status', 'priority', 'due_date']),
@@ -63,6 +66,36 @@ describe('showcase gap fill — analytics cube', () => {
6366
});
6467
});
6568

69+
describe('showcase gap fill — the done rate is a dataset measure, not a cube expression (#20943)', () => {
70+
it('the cube declares no expression member: the shipped literal passes the narrowed member `sql` contract', () => {
71+
// A cube member's `sql` names a column (ADR-0021 "zero raw expressions",
72+
// carried to the cube layer). `defineCube` already parsed this file at
73+
// import; re-parsing here pins that the shipped literal still passes the
74+
// narrowed contract, member by member.
75+
expect(CubeSchema.safeParse(DeliveryCube).success).toBe(true);
76+
expect(Object.keys(DeliveryCube.measures ?? {})).not.toContain('done_rate');
77+
});
78+
79+
it('the task dataset carries it as a filtered count over the unfiltered count, and parses', () => {
80+
const measures = new Map(ShowcaseTaskDataset.measures.map((m) => [m.name, m]));
81+
// The numerator: a count scoped by its OWN structured filter — the half of
82+
// the old CASE expression the platform can now judge (it names `status`).
83+
expect(measures.get('done_count')).toMatchObject({ aggregate: 'count', filter: { status: 'done' } });
84+
expect(measures.get('done_count')?.field).toBeUndefined();
85+
// The denominator is the dataset's existing unfiltered count.
86+
expect(measures.get('task_count')).toMatchObject({ aggregate: 'count' });
87+
expect(measures.get('task_count')?.filter).toBeUndefined();
88+
// The rate: a ratio of the two, a 0–1 fraction shown through the `%` pattern.
89+
expect(measures.get('done_rate')).toMatchObject({
90+
derived: { op: 'ratio', of: ['done_count', 'task_count'] },
91+
format: '0.0%',
92+
});
93+
// `defineDataset` is an identity helper, so the parse is asserted here: the
94+
// strict shape and its cross-measure refinement (every `of` name declared).
95+
expect(DatasetSchema.safeParse(ShowcaseTaskDataset).success).toBe(true);
96+
});
97+
});
98+
6699
describe('showcase gap fill — named import mapping (#2611)', () => {
67100
it('is wired into the stack definition', () => {
68101
const mappings = (stack as { mappings?: Array<{ name: string }> }).mappings ?? [];

‎packages/services/service-analytics/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ is rejected rather than dropped.
104104

105105
There is no `filters` key and no `aggregations` key. `filters` is rejected at the REST
106106
door with a 400 naming `where`. There is no per-metric filter key either — the cube
107-
metric's `filters` was removed (#10414: no strategy ever read it); fold a per-metric
108-
condition into the metric's own `sql` expression, or use an ADR-0021 dataset measure's
109-
structured `filter`.
107+
metric's `filters` was removed (#10414: no strategy ever read it). A measure that counts
108+
or sums only some rows is an ADR-0021 dataset measure with its own structured `filter`;
109+
a cube member's `sql` is a column reference (a field, a relationship path ending in one,
110+
or `'*'`), never a SQL expression (#20943).
110111

111112
```typescript
112113
const revenueByStatus = await analytics.query({

‎packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,13 +274,19 @@ describe('analytics_cube.dimensions.granularities — the declared single granul
274274
});
275275

276276
it('DECLARED NARROWING: a custom-SQL measure grouped by a declared-default dimension gets the refusal a stated granularity gets', async () => {
277-
const withExpression: Cube = CubeSchema.parse({
277+
// NOT parsed: since #20943 the cube contract admits a column reference
278+
// only, so `CubeSchema` refuses this expression member at every authoring
279+
// door. The engine path's refusal it pins is still owed to a cube that
280+
// reaches the service without meeting that parse (a host registering one
281+
// in-process), so the member is built directly on the parsed cube.
282+
const withExpression: Cube = {
278283
...authored,
279284
measures: {
280285
...authored.measures,
281286
done_rate: { label: 'Done', type: 'number', sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 1.0 / COUNT(*)" },
282287
},
283-
});
288+
};
289+
expect(CubeSchema.safeParse(withExpression).success).toBe(false);
284290
const aggregated: string[] = [];
285291
const sqls: string[] = [];
286292
const service = new AnalyticsService({

0 commit comments

Comments
 (0)