From 293124451c878ea125e613a06a29cb7fee8cbbb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:25:33 +0000 Subject: [PATCH 1/6] wip(spec): a cube member's sql is a column reference; the showcase done rate moves to its dataset Narrow MetricSchema.sql / DimensionSchema.sql to a column reference (a bare identifier or a dotted identifier path, and '*' on a count measure). Any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form. The showcase cube's done_rate expression moves to the task dataset as a filtered count over the unfiltered count. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/data/analytics/showcase.cube.ts | 14 +- .../src/ui/datasets/chart-gallery.dataset.ts | 14 + examples/app-showcase/test/gap-fill.test.ts | 35 ++- .../cube-authored-format-granularity.test.ts | 10 +- packages/spec/liveness/analytics_cube.json | 12 +- packages/spec/src/conversions/registry.ts | 4 +- packages/spec/src/data/analytics.zod.ts | 136 +++++++- .../cube-member-sql-column-reference.test.ts | 294 ++++++++++++++++++ .../18.cube-member-sql-expression-retired.ts | 56 ++++ .../18.cube-metric-filters-retired.ts | 17 +- 10 files changed, 552 insertions(+), 40 deletions(-) create mode 100644 packages/spec/src/data/cube-member-sql-column-reference.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index 3d9e7401458..b9fbf9f4fbb 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -35,14 +35,12 @@ export const DeliveryCube = defineCube({ type: 'avg', sql: 'estimate_hours', }, - done_rate: { - label: 'Done Rate (%)', - type: 'number', - sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)", - // A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a - // percent, `.0` one decimal. The value above is in percentage points (0-100). - format: '0.0%', - }, + // No `done_rate` here: a member's `sql` names a column, never a SQL + // expression (ADR-0021 "zero raw expressions", carried to the cube layer by + // #20943). The done rate this cube used to compute with a CASE expression + // is declared where the platform can judge every field it reads — the + // `showcase_task_metrics` dataset (src/ui/datasets/chart-gallery.dataset.ts), + // as a filtered count over a count. }, dimensions: { status: { diff --git a/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts b/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts index 31348e15e31..cdd2d48ac0b 100644 --- a/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts +++ b/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts @@ -24,6 +24,20 @@ export const ShowcaseTaskDataset = defineDataset({ { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimate_hours', format: '0.0' }, { name: 'avg_estimate', label: 'Avg Estimate', aggregate: 'avg', field: 'estimate_hours', format: '0.0' }, { name: 'avg_progress', label: 'Avg Progress', aggregate: 'avg', field: 'progress', format: '0.0' }, + // The done rate, moved here from the `showcase_delivery` cube (#20943): a + // cube member's `sql` is a column reference, and this is the declared + // form of what its CASE expression computed — a count scoped by its own + // structured filter, over the unfiltered count. `ratio` yields a 0–1 + // fraction (the cube's expression multiplied by 100), which the `%` + // pattern displays as a percentage; the server annotates the column's + // scale from the operator, as it does for `paid_rate`. + { name: 'done_count', label: 'Done Tasks', aggregate: 'count', filter: { status: 'done' } }, + { + name: 'done_rate', + label: 'Done Rate', + derived: { op: 'ratio', of: ['done_count', 'task_count'] }, + format: '0.0%', + }, ], }); diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index 749a0b0dce2..df92d34c49a 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -4,9 +4,12 @@ import { existsSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; +import { CubeSchema } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; import stack from '../objectstack.config.js'; import { DeliveryCube } from '../src/data/analytics/showcase.cube.js'; +import { ShowcaseTaskDataset } from '../src/ui/datasets/chart-gallery.dataset.js'; import { AccountExtension } from '../src/data/extensions/account.extension.js'; import { Account } from '../src/data/objects/account.object.js'; @@ -26,7 +29,7 @@ describe('showcase gap fill — analytics cube', () => { it('declares measures and dimensions over the delivery backbone', () => { expect(DeliveryCube.sql).toBe('showcase_task'); expect(Object.keys(DeliveryCube.measures ?? {})).toEqual( - expect.arrayContaining(['count', 'total_estimate_hours', 'avg_estimate_hours', 'done_rate']), + expect.arrayContaining(['count', 'total_estimate_hours', 'avg_estimate_hours']), ); expect(Object.keys(DeliveryCube.dimensions ?? {})).toEqual( expect.arrayContaining(['status', 'priority', 'due_date']), @@ -63,6 +66,36 @@ describe('showcase gap fill — analytics cube', () => { }); }); +describe('showcase gap fill — the done rate is a dataset measure, not a cube expression (#20943)', () => { + it('the cube declares no expression member: the shipped literal passes the narrowed member `sql` contract', () => { + // A cube member's `sql` names a column (ADR-0021 "zero raw expressions", + // carried to the cube layer). `defineCube` already parsed this file at + // import; re-parsing here pins that the shipped literal still passes the + // narrowed contract, member by member. + expect(CubeSchema.safeParse(DeliveryCube).success).toBe(true); + expect(Object.keys(DeliveryCube.measures ?? {})).not.toContain('done_rate'); + }); + + it('the task dataset carries it as a filtered count over the unfiltered count, and parses', () => { + const measures = new Map(ShowcaseTaskDataset.measures.map((m) => [m.name, m])); + // The numerator: a count scoped by its OWN structured filter — the half of + // the old CASE expression the platform can now judge (it names `status`). + expect(measures.get('done_count')).toMatchObject({ aggregate: 'count', filter: { status: 'done' } }); + expect(measures.get('done_count')?.field).toBeUndefined(); + // The denominator is the dataset's existing unfiltered count. + expect(measures.get('task_count')).toMatchObject({ aggregate: 'count' }); + expect(measures.get('task_count')?.filter).toBeUndefined(); + // The rate: a ratio of the two, a 0–1 fraction shown through the `%` pattern. + expect(measures.get('done_rate')).toMatchObject({ + derived: { op: 'ratio', of: ['done_count', 'task_count'] }, + format: '0.0%', + }); + // `defineDataset` is an identity helper, so the parse is asserted here: the + // strict shape and its cross-measure refinement (every `of` name declared). + expect(DatasetSchema.safeParse(ShowcaseTaskDataset).success).toBe(true); + }); +}); + describe('showcase gap fill — named import mapping (#2611)', () => { it('is wired into the stack definition', () => { const mappings = (stack as { mappings?: Array<{ name: string }> }).mappings ?? []; diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts index d32f0c8a77a..92355294d70 100644 --- a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -274,13 +274,19 @@ describe('analytics_cube.dimensions.granularities — the declared single granul }); it('DECLARED NARROWING: a custom-SQL measure grouped by a declared-default dimension gets the refusal a stated granularity gets', async () => { - const withExpression: Cube = CubeSchema.parse({ + // NOT parsed: since #20943 the cube contract admits a column reference + // only, so `CubeSchema` refuses this expression member at every authoring + // door. The engine path's refusal it pins is still owed to a cube that + // reaches the service without meeting that parse (a host registering one + // in-process), so the member is built directly on the parsed cube. + const withExpression: Cube = { ...authored, measures: { ...authored.measures, done_rate: { label: 'Done', type: 'number', sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 1.0 / COUNT(*)" }, }, - }); + }; + expect(CubeSchema.safeParse(withExpression).success).toBe(false); const aggregated: string[] = []; const sqls: string[] = []; const service = new AnalyticsService({ diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 85bbabb94e3..d132a0685e3 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -60,10 +60,10 @@ }, "sql": { "status": "live", - "verifiedAt": "2026-09-17", - "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column (or expression) the aggregate is applied to, and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path.", + "verifiedAt": "2026-09-30", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column the aggregate is applied to (`'*'` the COUNT(*) form), and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "REQUIRED, and the one place a cube author writes physical SQL. `'*'` is special-cased to a bare `*` (the COUNT(*) form); anything with a dot is either a relationship path or a SQL expression, and `IDENTIFIER_PATH` is what tells them apart (#4157)." + "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'` on a `count` measure — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. The runtime's expression branches (verbatim emit on the raw-SQL path, the gate's stand-down) remain for a cube that reaches the service without meeting the parse; their deletion is the services-lane follow-up. The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset." }, "format": { "status": "live", @@ -104,10 +104,10 @@ }, "sql": { "status": "live", - "verifiedAt": "2026-09-17", - "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path.", + "verifiedAt": "2026-09-30", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "REQUIRED. Same dotted-path / expression split as `measures.sql`." + "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path; a SQL expression — a CASE bucket included — and `'*'` are refused at parse. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." }, "granularities": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 8fad6b00af1..8fc4255898b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7796,8 +7796,8 @@ const metricFiltersRemoved: MetadataConversion = { summary: "cube metric key 'filters' removed (#10414, ADR-0049 — no strategy ever read it: the " + 'authored raw-SQL condition was parsed and dropped, and the query returned the ' - + "unfiltered aggregate. Filter at query time with `where`, fold the condition into the " - + "metric's own `sql` expression, or use an ADR-0021 dataset measure's structured `filter`)", + + "unfiltered aggregate. Filter at query time with `where`, or use an ADR-0021 dataset " + + "measure's structured `filter`; a metric's own `sql` is a column reference)", apply(stack, emit) { return mapCollection(stack, 'analyticsCubes', (cube, path) => { const measures = cube.measures; diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 91d0e9f4fb7..ee1b0d38809 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -192,6 +192,82 @@ const cubeMemberNameRemoved = (qualifiedKey: string, bag: 'measures' | 'dimensio const CUBE_METRIC_NAME_REMOVED = cubeMemberNameRemoved('measures..name', 'measures', 'metric'); const CUBE_DIMENSION_NAME_REMOVED = cubeMemberNameRemoved('dimensions..name', 'dimensions', 'dimension'); +/** + * A cube member's `sql` is a COLUMN REFERENCE, never a SQL expression — the + * expression half RETIRED (#20943, maintainer ruling D on 2026-09-30: ADR-0021's + * "zero raw SQL / zero raw expressions" carried from the dataset layer down to + * the cube members it compiles to; ADR-0049 enforce-or-remove). + * + * Admitted, and parsed byte-identically to before: + * - a column of the cube's object — `amount`; + * - a relationship path of bare identifiers ending in one — `account.amount`, + * `account.owner.region` — the chain + * `NativeSQLStrategy#qualifyAndRegisterJoin` lowers into its LEFT JOINs; + * - on a `count` measure only, the row wildcard `'*'` (checked on + * {@link MetricSchema}, which sees the member's `type`). + * + * {@link CUBE_MEMBER_COLUMN_PATH} 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-level read gate's bare-identifier / + * identifier-path pair in `analytics-service.ts` — so the contract now admits + * exactly the values those readers resolve to fields. + * + * Refused at parse: everything else — `CASE WHEN …`, `SUM(…) / COUNT(*)`, a + * quoted identifier, a `$`-prefixed spelling, an empty string. Such a value + * names no single field, so no platform check could judge which fields it + * reads, and the two strategies never agreed on it: the raw-SQL path emitted + * it verbatim, and `ObjectQLStrategy#resolveMeasureAggregation` refused it. + * A derived value has a declared home the platform CAN judge — an ADR-0021 + * dataset, where a conditional count or sum is a measure with its own + * structured `filter`, and a ratio / sum / difference / product of measures is + * `derived: { op, of: [...] }` over measures named in the same dataset. + * + * No D2 conversion, deliberately: an expression has no mechanical rewrite — it + * moves to another metadata type (`dataset`), and a ratio's value may change + * scale on the way (a `derived` ratio is a 0–1 fraction). The D3 entry + * `cube-member-sql-expression-retired` carries what the upgrader still owes. + * The runtime's expression branches (the gate's stand-down, the raw-SQL + * verbatim emit) are left as they are here; they become unreachable for any + * cube that met this parse, and their deletion is the services lane's + * follow-up, not this schema's. + */ +const CUBE_MEMBER_COLUMN_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/; + +const CUBE_MEMBER_SQL_RETIRED = + 'A SQL expression there was retired in @objectstack/spec 17 (ADR-0021 zero raw expressions; ' + + 'ADR-0049 enforce-or-remove) — an expression names no single field, so no platform check can ' + + 'judge which fields it reads, and the two analytics strategies never agreed on it: one ran it ' + + 'verbatim, the other refused it.'; + +const CUBE_METRIC_SQL_EXPRESSION_REFUSED = + '`measures..sql` is a column reference: a field of the cube\'s object (`amount`), a ' + + 'relationship path ending in one (`account.amount`), or `\'*\'` on a `count` measure. ' + + `${CUBE_MEMBER_SQL_RETIRED} Name the column the measure aggregates, or declare the derived ` + + 'value on an ADR-0021 dataset, where the platform judges every field it reads: a conditional ' + + 'count or sum is a dataset measure with its own structured `filter` ' + + '(`{ name: \'done_count\', aggregate: \'count\', filter: { status: \'done\' } }`), and a ratio, ' + + 'sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in ' + + 'the same dataset (`{ name: \'done_rate\', derived: { op: \'ratio\', of: [\'done_count\', ' + + '\'task_count\'] }, format: \'0.0%\' }` — a 0–1 fraction, which the `%` pattern displays as a ' + + 'percentage).'; + +const cubeMetricSqlStarRefused = (type: string) => + '`measures..sql` is `\'*\'` only on a `count` measure — `\'*\'` counts rows and names no ' + + `column, so a \`${type}\` measure has nothing to aggregate. Name the column it aggregates ` + + '(`amount`, or `account.amount` across a relationship), or declare the measure `type: \'count\'`.'; + +const cubeDimensionSqlRefused = (value: string) => ( + value === '*' + ? '`dimensions..sql` is a column reference: a field of the cube\'s object (`status`) ' + + 'or a relationship path ending in one (`account.industry`). `\'*\'` counts rows on a `count` ' + + 'measure and names no column to group by — name the column.' + : '`dimensions..sql` is a column reference: a field of the cube\'s object (`status`) ' + + `or a relationship path ending in one (\`account.industry\`). ${CUBE_MEMBER_SQL_RETIRED} ` + + 'Group by the column itself. A bucket computed over a column\'s values (a CASE over them) ' + + 'has no expression form in the cube layer or the dataset layer: keep the bucket as a field ' + + 'of the object, and name that field here or in an ADR-0021 dataset dimension\'s `field`.' +); + /** * Metric Schema * A quantitative measurement (e.g., "Total Revenue", "Average Order Value"). @@ -220,9 +296,11 @@ export const MetricSchema = lazySchema(() => strictObject( // both SQL strategies aggregate `sql` and never read it, so a // hand-authored condition parsed, registered, and silently returned the // UNFILTERED aggregate under the author's metric name (the #10298 shape, - // one level up). What actually filters: the query's `where`, the - // condition folded into the metric's own `sql` expression, or an - // ADR-0021 dataset measure's structured `filter` (#10411). The nested + // one level up). What actually filters: the query's `where`, or an + // ADR-0021 dataset measure's structured `filter` (#10411). A third + // channel this text used to name — folding the condition into the + // metric's own `sql` expression — went with #20943: a member's `sql` is + // a column reference (see `CUBE_MEMBER_COLUMN_PATH`). The nested // `strictObject` the key carried (closed by #4001 batch D) is gone with // it — strictness on a shape nothing reads was fake compliance either way. filters: @@ -231,8 +309,9 @@ export const MetricSchema = lazySchema(() => strictObject( + 'both aggregate the metric\'s `sql` and ignore `filters`), so an authored ' + '`filters: [{ sql: … }]` parsed clean and the query returned the UNFILTERED aggregate. ' + 'Delete the key. To filter what a metric measures: filter at query time with `where` ' - + '(canonical Query DSL FilterCondition), fold the condition into the metric\'s own `sql` ' - + 'expression, or use an ADR-0021 dataset measure\'s structured `filter`. ' + + '(canonical Query DSL FilterCondition), or declare the measure on an ADR-0021 dataset, whose ' + + 'measure takes a structured `filter` — a metric\'s own `sql` is a column reference and ' + + 'carries no condition. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }, }, @@ -246,8 +325,20 @@ export const MetricSchema = lazySchema(() => strictObject( type: AggregationMetricType, - /** Source Calculation */ - sql: z.string().describe('SQL expression or field reference'), + /** + * The column the measure aggregates — a field of the cube's object, a + * relationship path ending in one, or `'*'` on a `count`. A SQL expression + * is refused at parse (#20943, ruling D; see `CUBE_MEMBER_COLUMN_PATH`): + * a derived value is declared on an ADR-0021 dataset instead. + */ + sql: z.string().superRefine((value, ctx) => { + if (value === '*' || CUBE_MEMBER_COLUMN_PATH.test(value)) return; + ctx.addIssue({ code: 'custom', message: CUBE_METRIC_SQL_EXPRESSION_REFUSED }); + }).describe( + 'Column reference: a field of the cube\'s object ("amount"), a relationship path ending in one ' + + '("account.amount"), or "*" on a count measure. Never a SQL expression: a derived value is ' + + 'declared on an ADR-0021 dataset (a measure-scoped filter, or derived: { op, of }).', + ), // `filters` was REMOVED here (#10414) — see the `guidance` entry above for // the full story and the replacement channels. The raw-SQL fragment shape @@ -268,7 +359,14 @@ export const MetricSchema = lazySchema(() => strictObject( + 'Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta.', ), }, -)); +).superRefine((metric, ctx) => { + // [#20943] `'*'` is the row wildcard of a COUNT. Every other aggregate needs + // a column: `SUM(*)` is no statement any dialect runs, so a non-count `'*'` + // is refused here, where the member's `type` is in view. + if (metric.sql === '*' && metric.type !== 'count') { + ctx.addIssue({ code: 'custom', path: ['sql'], message: cubeMetricSqlStarRefused(metric.type) }); + } +})); /** * Dimension Schema @@ -301,8 +399,18 @@ export const DimensionSchema = lazySchema(() => strictObject( type: DimensionType, - /** Source Column */ - sql: z.string().describe('SQL expression or column reference'), + /** + * The column the dimension groups by — a field of the cube's object, or a + * relationship path ending in one. A SQL expression is refused at parse + * (#20943, ruling D; see `CUBE_MEMBER_COLUMN_PATH`). + */ + sql: z.string().superRefine((value, ctx) => { + if (CUBE_MEMBER_COLUMN_PATH.test(value)) return; + ctx.addIssue({ code: 'custom', message: cubeDimensionSqlRefused(value) }); + }).describe( + 'Column reference: a field of the cube\'s object ("status") or a relationship path ending in one ' + + '("account.industry"). Never a SQL expression.', + ), /** * For a time dimension: the intervals it is bucketed at. A SINGLE interval @@ -788,11 +896,13 @@ export const AnalyticsQuerySchema = lazySchema(() => strictObject( guidance: { // The second sentence used to point at the cube metric's own `filters` — // a key #10414 removed (never suggest a key the schema cannot accept; - // the `triggerPhrase` lesson in strict-object.ts). + // the `triggerPhrase` lesson in strict-object.ts). It also used to offer + // folding the condition into the metric's own `sql` expression, which + // #20943 retired (a member's `sql` is a column reference). filters: '`filters` is not an AnalyticsQuery field — use `where` (canonical Query DSL ' + 'FilterCondition, the same shape find() takes). There is no per-metric filter key ' - + 'either: fold the condition into the metric\'s own `sql` expression, or use ' - + 'an ADR-0021 dataset measure\'s structured `filter`.', + + 'either: a measure that counts or sums only some rows is an ADR-0021 dataset measure ' + + 'with its own structured `filter`.', }, // No `extraKeys`: the one extension (`AnalyticsQueryRequestSchema`) adds // only the #3878 `retiredKey` tombstones, and a tombstone must never be diff --git a/packages/spec/src/data/cube-member-sql-column-reference.test.ts b/packages/spec/src/data/cube-member-sql-column-reference.test.ts new file mode 100644 index 00000000000..38bd29e01f7 --- /dev/null +++ b/packages/spec/src/data/cube-member-sql-column-reference.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A cube member's `sql` is a COLUMN REFERENCE — the expression half RETIRED + * (#20943, maintainer ruling D: ADR-0021's "zero raw SQL / zero raw + * expressions" carried to the cube layer; ADR-0049 enforce-or-remove). + * + * What is pinned here, door by door: + * 1. The accept set: a bare identifier, a dotted identifier path, and `'*'` + * on a `count` measure parse byte-identically to before. + * 2. The refusal: every other value — an expression, a quoted or + * `$`-prefixed spelling, an empty string, a broken path — is refused at + * `…sql` with the prescription, whose first sentence states the contract + * and whose body names the ADR-0021 dataset form. + * 3. `'*'` belongs to a count: on any other measure type, and on a + * dimension, it is refused with its own sentence. + * 4. Every door that carries a cube refuses it: `CubeSchema`, the + * `analytics_cube` write-door binding, `defineCube()` and `defineStack()` + * (the last with its STACK_SCHEMA_INVALID / 422 envelope). + * 5. The structural equivalent the prescription points at is real: the + * retired showcase `done_rate` expression is refused, and its dataset form + * (a filtered count over a count, as a `ratio`) parses. + * 6. No other prescription in the cube family still sends an author to a + * metric's own `sql` expression. + * 7. ADR-0087: the family's D3 entry is registered under step 18. No D2 + * conversion and no `RETIRED_KEYS_BY_MAJOR` row — no key was removed, and + * an expression has no mechanical rewrite into a dataset. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, pinned with its `code` and `status`. + */ + +import { describe, expect, it } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { DatasetSchema } from '../ui/dataset.zod'; +import { + AnalyticsQuerySchema, + AggregationMetricType, + CubeSchema, + DimensionSchema, + MetricSchema, + defineCube, +} from './analytics.zod'; + +const D3_ID = 'cube-member-sql-expression-retired'; + +/** The expression the showcase `done_rate` measure carried until this retirement. */ +const DONE_RATE_EXPRESSION = "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)"; + +const METRIC_FIRST_SENTENCE = + "`measures..sql` is a column reference: a field of the cube's object (`amount`), a " + + "relationship path ending in one (`account.amount`), or `'*'` on a `count` measure."; +const DIMENSION_FIRST_SENTENCE = + "`dimensions..sql` is a column reference: a field of the cube's object (`status`) " + + 'or a relationship path ending in one (`account.industry`).'; + +/** The body of an expression refusal: why, and the ADR-0021 dataset form it points at. */ +const EXPRESSION_PRESCRIPTION = + /A SQL expression there was retired in @objectstack\/spec 17 \(ADR-0021 zero raw expressions; ADR-0049 enforce-or-remove\) — an expression names no single field/s; +const DATASET_FORM = /ADR-0021 dataset.*structured `filter`.*`derived: \{ op, of: \[\.\.\.\] \}`/s; + +const COUNT = { label: 'Tasks', type: 'count', sql: '*' } as const; +const STATUS = { label: 'Status', type: 'string', sql: 'status' } as const; +const CUBE = { + name: 'delivery', + sql: 'task', + measures: { count: COUNT }, + dimensions: { status: STATUS }, +} as const; + +/** Values that name no single column — every one of them used to parse. */ +const EXPRESSIONS = [ + DONE_RATE_EXPRESSION, + 'SUM(amount)', + 'amount * 2', + 'SUM(account.amount) / 2', + "CASE WHEN annual_revenue > 0 THEN 'yes' ELSE 'no' END", + '"amount"', + '$amount', + '${CUBE}.amount', + '', + ' amount', + 'account.', + 'account..amount', + '1', +]; + +const refusalOf = (schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } } }, value: unknown) => { + const r = schema.safeParse(value); + expect(r.success, JSON.stringify(value)).toBe(false); + return r.error!.issues; +}; + +describe('cube member sql — the accept set is unchanged for every column reference', () => { + it('a measure admits a bare column, a relationship path, and `*` on a count — parsed byte-identically', () => { + const admitted = [ + { label: 'Total', type: 'sum', sql: 'amount' }, + { label: 'Account total', type: 'sum', sql: 'account.amount' }, + { label: 'Region spread', type: 'count_distinct', sql: 'account.owner.region' }, + { label: 'Mixed case', type: 'max', sql: 'Amount_2' }, + { label: 'Filled', type: 'count', sql: 'closed_at' }, + COUNT, + ]; + for (const metric of admitted) { + const r = MetricSchema.safeParse(metric); + expect(r.success, metric.sql).toBe(true); + if (r.success) expect(r.data).toEqual(metric); + } + }); + + it('a dimension admits a bare column and a relationship path — parsed byte-identically', () => { + for (const sql of ['status', 'account.industry', 'account.owner.region', '_private']) { + const dim = { label: 'D', type: 'string', sql }; + const r = DimensionSchema.safeParse(dim); + expect(r.success, sql).toBe(true); + if (r.success) expect(r.data).toEqual(dim); + } + }); +}); + +describe('cube member sql — an expression is refused at parse, with the prescription', () => { + it('a measure refuses every non-column value at `sql`, naming the contract first and the dataset form after', () => { + for (const sql of EXPRESSIONS) { + const issues = refusalOf(MetricSchema, { label: 'M', type: 'number', sql }); + expect(issues, sql).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['sql']); + expect(issues[0]!.message.startsWith(METRIC_FIRST_SENTENCE), sql).toBe(true); + expect(issues[0]!.message).toMatch(EXPRESSION_PRESCRIPTION); + expect(issues[0]!.message).toMatch(DATASET_FORM); + } + }); + + it('the refusal does not depend on the measure type — an aggregate type is refused the same way', () => { + for (const type of AggregationMetricType.options) { + const issues = refusalOf(MetricSchema, { label: 'M', type, sql: 'SUM(amount)' }); + expect(issues.map((i) => i.path), type).toEqual([['sql']]); + expect(issues[0]!.message.startsWith(METRIC_FIRST_SENTENCE)).toBe(true); + } + }); + + it('a dimension refuses every non-column value at `sql` — a CASE bucket included', () => { + for (const sql of EXPRESSIONS) { + const issues = refusalOf(DimensionSchema, { label: 'D', type: 'string', sql }); + expect(issues, sql).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['sql']); + expect(issues[0]!.message.startsWith(DIMENSION_FIRST_SENTENCE), sql).toBe(true); + expect(issues[0]!.message).toMatch(EXPRESSION_PRESCRIPTION); + // A bucket has no expression form anywhere: the prescription says where it goes. + expect(issues[0]!.message).toMatch(/keep the bucket as a field of the object/); + } + }); +}); + +describe("cube member sql — `'*'` is a count's row wildcard", () => { + it('a non-count measure refuses `*` at `sql`, with its own sentence', () => { + for (const type of AggregationMetricType.options.filter((t) => t !== 'count')) { + const issues = refusalOf(MetricSchema, { label: 'M', type, sql: '*' }); + expect(issues, type).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['sql']); + expect(issues[0]!.message).toMatch( + new RegExp(`^\`measures\\.\\.sql\` is \`'\\*'\` only on a \`count\` measure — .*a \`${type}\` measure has nothing to aggregate`, 's'), + ); + } + }); + + it('a dimension refuses `*` — it names no column to group by', () => { + const issues = refusalOf(DimensionSchema, { label: 'D', type: 'string', sql: '*' }); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['sql']); + expect(issues[0]!.message.startsWith(DIMENSION_FIRST_SENTENCE)).toBe(true); + expect(issues[0]!.message).toMatch(/names no column to group by/); + }); +}); + +describe('cube member sql — every door that carries a cube refuses an expression member', () => { + const withExpression = { + ...CUBE, + measures: { ...CUBE.measures, done_rate: { label: 'Done Rate (%)', type: 'number', sql: DONE_RATE_EXPRESSION } }, + }; + + it('the cube schema refuses it at measures..sql and dimensions..sql — one issue per member', () => { + const issues = refusalOf(CubeSchema, { + ...withExpression, + dimensions: { ...CUBE.dimensions, bucket: { label: 'Bucket', type: 'string', sql: "CASE WHEN a > 0 THEN 'x' END" } }, + }); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', ['measures', 'done_rate', 'sql']], + ['custom', ['dimensions', 'bucket', 'sql']], + ]); + }); + + it('the `analytics_cube` write door (the registry binding) refuses it', () => { + // `getMetadataTypeSchema('analytics_cube')` is what a `PUT /api/v1/meta/analytics_cube` + // body is validated against; a rebinding to another shape would pass the + // pins above and still accept the expression in production. + const door = getMetadataTypeSchema('analytics_cube'); + expect(door).toBe(CubeSchema); + const issues = refusalOf(door!, withExpression); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['measures', 'done_rate', 'sql']); + expect(issues[0]!.message.startsWith(METRIC_FIRST_SENTENCE)).toBe(true); + }); + + it('`defineCube()` refuses it with the prescription', () => { + expect(() => defineCube(withExpression as never)).toThrow(EXPRESSION_PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope', () => { + const stack = (cube: Record) => ({ + manifest: { id: 'com.example.cube-member-sql', name: 'cube_member_sql', version: '1.0.0', type: 'app' }, + analyticsCubes: [cube], + }); + let thrown: unknown; + try { + defineStack(stack(withExpression) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['analyticsCubes', 0, 'measures', 'done_rate', 'sql']); + expect(refusal.issues?.[0]?.message.startsWith(METRIC_FIRST_SENTENCE)).toBe(true); + // CONTROL: the same stack without the expression member is accepted by the same door. + expect(() => defineStack(stack({ ...CUBE }) as never)).not.toThrow(); + }); + + it('CONTROL: the cube without the expression member passes every door, every member intact', () => { + const r = CubeSchema.safeParse(CUBE); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.measures.count).toEqual(COUNT); + expect(r.data.dimensions.status).toEqual(STATUS); + expect(defineCube(CUBE).measures.count).toEqual(COUNT); + }); +}); + +describe('cube member sql — the dataset form the prescription names is the structural equivalent', () => { + it('the retired done-rate expression is refused, and its dataset form — a filtered count over a count — parses', () => { + expect(MetricSchema.safeParse({ label: 'Done Rate (%)', type: 'number', sql: DONE_RATE_EXPRESSION }).success).toBe(false); + const dataset = { + name: 'task_metrics', + label: 'Task Metrics', + object: 'task', + dimensions: [{ name: 'priority', field: 'priority' }], + measures: [ + { name: 'task_count', aggregate: 'count' }, + { name: 'done_count', aggregate: 'count', filter: { status: 'done' } }, + { name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }, + ], + }; + const r = DatasetSchema.safeParse(dataset); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); +}); + +describe('cube member sql — no neighbouring prescription still sends an author to an expression', () => { + it("the retired metric `filters` guidance names `where` and the dataset `filter`, never the metric's own `sql` expression", () => { + const issues = refusalOf(MetricSchema, { ...COUNT, filters: [] }); + const message = issues.find((i) => /filters/.test(i.message))?.message ?? ''; + expect(message).toMatch(/`measures\.\.filters` was removed/); + expect(message).toMatch(/structured `filter`/); + expect(message).not.toMatch(/fold the condition/); + }); + + it("the analytics query's `filters` guidance does the same", () => { + const issues = refusalOf(AnalyticsQuerySchema, { cube: 'delivery', measures: ['count'], filters: {} }); + const message = issues.find((i) => /filters/.test(i.message))?.message ?? ''; + expect(message).toMatch(/use `where`/); + expect(message).toMatch(/structured `filter`/); + expect(message).not.toMatch(/fold the condition/); + }); +}); + +describe('cube member sql — ADR-0087 registration', () => { + it('carries the family D3 entry under step 18, with no D2 conversion and no retired-key row', () => { + const step = MIGRATIONS_BY_MAJOR[18]!; + const d3 = step.semantic.find((s) => s.id === D3_ID); + expect(d3, 'the family D3 entry').toBeDefined(); + expect(d3!.reason.length).toBeGreaterThan(0); + expect(d3!.acceptanceCriteria.length).toBeGreaterThan(0); + // No key left the shape, so no `${defKey}:${name}` entry is owed. + expect(RETIRED_KEYS_BY_MAJOR[18]).not.toContain('data/Metric:sql'); + expect(RETIRED_KEYS_BY_MAJOR[18]).not.toContain('data/Dimension:sql'); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts new file mode 100644 index 00000000000..d9a2e2bb7cc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20943, maintainer ruling D — a cube member's `sql` is a column reference; +// the expression half is retired at the contract (ADR-0021's zero raw +// expressions, carried from the dataset layer to the cube members it compiles +// to). Semantic only, with no D2 conversion: an expression has no mechanical +// rewrite — it moves to another metadata type, and a ratio changes scale on +// the way — so the upgrader owes the judgement this entry states. +export const entry: SemanticMigration = { + id: 'cube-member-sql-expression-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql ' + + '(data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE ' + + 'expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling — and ' + + "the row wildcard '*' on anything but a count measure", + replacement: + 'a column reference: a field of the cube\'s object (`amount`), a relationship path ending ' + + 'in one (`account.amount`), or `\'*\'` on a `count` measure. A derived value moves to an ' + + 'ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure ' + + 'with its own structured `filter` (`{ name: \'done_count\', aggregate: \'count\', filter: ' + + '{ status: \'done\' } }`), and a ratio, sum, difference or product of measures is ' + + '`derived: { op, of: [...] }` over measures named in the same dataset (`{ name: ' + + '\'done_rate\', derived: { op: \'ratio\', of: [\'done_count\', \'task_count\'] }, format: ' + + '\'0.0%\' }`). A dimension that bucketed a column with a CASE expression has no expression ' + + 'form in either layer: group by the column itself, or keep the bucket as a field of the ' + + 'object and name that field', + reason: + 'Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose ' + + '`sql` is an expression names no single field, so no platform check can judge which fields ' + + 'it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it ' + + 'verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the ' + + 'author surface ("zero raw SQL / zero raw expressions"); it governed the dataset layer and ' + + 'left the cube members it compiles to open, which is the gap this closes. The dataset form ' + + 'is the declared home of a derived value because every field it reads is named: a measure ' + + 'filter names its fields, and a derived measure references other measures by name only. ' + + 'There is no D2 conversion: the rewrite moves a member to a different metadata type and ' + + 'cannot be derived from the expression text in general, so only the author can say which ' + + 'dataset measures express what the expression meant. A ratio also changes SCALE on the way: ' + + 'a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned ' + + 'percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio ' + + 'column\'s percent scale as a fraction) and re-check any consumer that read the old number ' + + 'raw. ADR-0021 / ADR-0049 / ADR-0087', + acceptanceCriteria: + 'Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack ' + + 'refuse an expression member at its `sql` with a prescription that names the dataset form, ' + + 'so the sweep is mechanical — parse each cube, and each refusal is one member to move. For ' + + 'each moved measure, a dataset over the same object declares it, and a query over a fixture ' + + 'where the condition excludes rows returns the same figure the expression returned (a ' + + 'ratio: the same value divided by 100 when the expression returned percentage points). ' + + 'Every dashboard, report or saved query that named the cube member now names the dataset ' + + 'measure. A cube member that aggregates a column parses byte-identically to before.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts index a7884c09534..1e45952ad7b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts @@ -12,22 +12,23 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'cube-metric-filters-retired', surface: 'analyticsCubes[].measures..filters — the per-metric raw-SQL filter list', - replacement: 'One of three filters that ARE applied: a `where` condition at query time, the ' - + 'condition folded into the metric\'s own `sql` expression (a conditional aggregate), or an ' - + 'ADR-0021 dataset measure with a structured `filter`.', + replacement: 'One of the two filters that ARE applied: a `where` condition at query time, or an ' + + 'ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition ' + + 'into the metric\'s own `sql` expression — left with `cube-member-sql-expression-retired`: a ' + + 'member\'s `sql` is a column reference.)', reason: 'The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and ' + 'the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a ' + 'metric authored with `filters: [{ sql: "stage = \'closed_won\'" }]` already returned the ' + 'UNFILTERED aggregate under the author\'s metric name, and still does. That is exactly why the ' + 'strip does not finish the job. The author wrote a condition because they wanted a filtered ' + 'number; every dashboard, report and export reading that metric has been showing a larger ' - + 'one. Only the author can say which of the three live mechanisms expresses the condition ' - + 'they meant — a query-time `where` changes every query, a conditional aggregate changes the ' - + 'metric, a dataset measure moves it to the governed layer — and whether numbers already ' - + 'published from the unfiltered metric need to be revisited.', + + 'one. Only the author can say which of the two live mechanisms expresses the condition ' + + 'they meant — a query-time `where` changes every query, a dataset measure moves the metric ' + + 'to the governed layer — and whether numbers already published from the unfiltered metric ' + + 'need to be revisited.', acceptanceCriteria: 'No cube metric carries `filters`; the parse refuses the key by name. For ' + 'each metric that carried one, the author has either re-expressed the condition through one ' - + 'of the three live mechanisms or decided the unfiltered aggregate is what they want — and ' + + 'of the two live mechanisms or decided the unfiltered aggregate is what they want — and ' + 'renamed the metric if its name promised the filter. With the condition re-expressed, a query ' + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', From 55dd092daa9f8f143e242fc136dbeca15e4c3cc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:28:39 +0000 Subject: [PATCH 2/6] wip(spec): the member sql rule is a regex, so the published JSON Schema carries it as a pattern The accept set follows the ruling's execution parameters: an identifier, a dotted identifier path, and '*', on a measure and a dimension alike. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/analytics.zod.ts | 75 +++++++------------ .../cube-member-sql-column-reference.test.ts | 58 +++++++------- 2 files changed, 56 insertions(+), 77 deletions(-) diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index ee1b0d38809..1e1206255d5 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -198,19 +198,22 @@ const CUBE_DIMENSION_NAME_REMOVED = cubeMemberNameRemoved('dimensions. - '`measures..sql` is `\'*\'` only on a `count` measure — `\'*\'` counts rows and names no ' - + `column, so a \`${type}\` measure has nothing to aggregate. Name the column it aggregates ` - + '(`amount`, or `account.amount` across a relationship), or declare the measure `type: \'count\'`.'; - -const cubeDimensionSqlRefused = (value: string) => ( - value === '*' - ? '`dimensions..sql` is a column reference: a field of the cube\'s object (`status`) ' - + 'or a relationship path ending in one (`account.industry`). `\'*\'` counts rows on a `count` ' - + 'measure and names no column to group by — name the column.' - : '`dimensions..sql` is a column reference: a field of the cube\'s object (`status`) ' - + `or a relationship path ending in one (\`account.industry\`). ${CUBE_MEMBER_SQL_RETIRED} ` - + 'Group by the column itself. A bucket computed over a column\'s values (a CASE over them) ' - + 'has no expression form in the cube layer or the dataset layer: keep the bucket as a field ' - + 'of the object, and name that field here or in an ADR-0021 dataset dimension\'s `field`.' -); +const CUBE_DIMENSION_SQL_EXPRESSION_REFUSED = + '`dimensions..sql` is a column reference: a field of the cube\'s object (`status`) ' + + `or a relationship path ending in one (\`account.industry\`). ${CUBE_MEMBER_SQL_RETIRED} ` + + 'Group by the column itself. A bucket computed over a column\'s values (a CASE over them) ' + + 'has no expression form in the cube layer or the dataset layer: keep the bucket as a field ' + + 'of the object, and name that field here or in an ADR-0021 dataset dimension\'s `field`.'; /** * Metric Schema @@ -300,7 +293,7 @@ export const MetricSchema = lazySchema(() => strictObject( // ADR-0021 dataset measure's structured `filter` (#10411). A third // channel this text used to name — folding the condition into the // metric's own `sql` expression — went with #20943: a member's `sql` is - // a column reference (see `CUBE_MEMBER_COLUMN_PATH`). The nested + // a column reference (see `CUBE_MEMBER_SQL`). The nested // `strictObject` the key carried (closed by #4001 batch D) is gone with // it — strictness on a shape nothing reads was fake compliance either way. filters: @@ -327,16 +320,13 @@ export const MetricSchema = lazySchema(() => strictObject( /** * The column the measure aggregates — a field of the cube's object, a - * relationship path ending in one, or `'*'` on a `count`. A SQL expression - * is refused at parse (#20943, ruling D; see `CUBE_MEMBER_COLUMN_PATH`): - * a derived value is declared on an ADR-0021 dataset instead. + * relationship path ending in one, or `'*'` for a count. A SQL expression + * is refused at parse (#20943, ruling D; see `CUBE_MEMBER_SQL`): a derived + * value is declared on an ADR-0021 dataset instead. */ - sql: z.string().superRefine((value, ctx) => { - if (value === '*' || CUBE_MEMBER_COLUMN_PATH.test(value)) return; - ctx.addIssue({ code: 'custom', message: CUBE_METRIC_SQL_EXPRESSION_REFUSED }); - }).describe( + sql: z.string().regex(CUBE_MEMBER_SQL, { error: () => CUBE_METRIC_SQL_EXPRESSION_REFUSED }).describe( 'Column reference: a field of the cube\'s object ("amount"), a relationship path ending in one ' - + '("account.amount"), or "*" on a count measure. Never a SQL expression: a derived value is ' + + '("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 }).', ), @@ -359,14 +349,7 @@ export const MetricSchema = lazySchema(() => strictObject( + 'Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta.', ), }, -).superRefine((metric, ctx) => { - // [#20943] `'*'` is the row wildcard of a COUNT. Every other aggregate needs - // a column: `SUM(*)` is no statement any dialect runs, so a non-count `'*'` - // is refused here, where the member's `type` is in view. - if (metric.sql === '*' && metric.type !== 'count') { - ctx.addIssue({ code: 'custom', path: ['sql'], message: cubeMetricSqlStarRefused(metric.type) }); - } -})); +)); /** * Dimension Schema @@ -401,13 +384,11 @@ export const DimensionSchema = lazySchema(() => strictObject( /** * The column the dimension groups by — a field of the cube's object, or a - * relationship path ending in one. A SQL expression is refused at parse - * (#20943, ruling D; see `CUBE_MEMBER_COLUMN_PATH`). + * relationship path ending in one (`'*'` is admitted with the measure's + * accept set). A SQL expression is refused at parse (#20943, ruling D; see + * `CUBE_MEMBER_SQL`). */ - sql: z.string().superRefine((value, ctx) => { - if (CUBE_MEMBER_COLUMN_PATH.test(value)) return; - ctx.addIssue({ code: 'custom', message: cubeDimensionSqlRefused(value) }); - }).describe( + sql: z.string().regex(CUBE_MEMBER_SQL, { error: () => CUBE_DIMENSION_SQL_EXPRESSION_REFUSED }).describe( 'Column reference: a field of the cube\'s object ("status") or a relationship path ending in one ' + '("account.industry"). Never a SQL expression.', ), diff --git a/packages/spec/src/data/cube-member-sql-column-reference.test.ts b/packages/spec/src/data/cube-member-sql-column-reference.test.ts index 38bd29e01f7..b4df04f92a9 100644 --- a/packages/spec/src/data/cube-member-sql-column-reference.test.ts +++ b/packages/spec/src/data/cube-member-sql-column-reference.test.ts @@ -6,14 +6,16 @@ * expressions" carried to the cube layer; ADR-0049 enforce-or-remove). * * What is pinned here, door by door: - * 1. The accept set: a bare identifier, a dotted identifier path, and `'*'` - * on a `count` measure parse byte-identically to before. + * 1. The accept set the ruling's execution parameters name — a bare + * identifier, a dotted identifier path, and `'*'` — parses + * byte-identically to before, on a measure and a dimension alike. * 2. The refusal: every other value — an expression, a quoted or * `$`-prefixed spelling, an empty string, a broken path — is refused at * `…sql` with the prescription, whose first sentence states the contract * and whose body names the ADR-0021 dataset form. - * 3. `'*'` belongs to a count: on any other measure type, and on a - * dimension, it is refused with its own sentence. + * 3. The rule is a `pattern` in the published JSON Schema too, so a + * document validated against `json-schema/**` is judged as the parse + * judges it. * 4. Every door that carries a cube refuses it: `CubeSchema`, the * `analytics_cube` write-door binding, `defineCube()` and `defineStack()` * (the last with its STACK_SCHEMA_INVALID / 422 envelope). @@ -32,6 +34,7 @@ */ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; @@ -53,7 +56,7 @@ const DONE_RATE_EXPRESSION = "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * const METRIC_FIRST_SENTENCE = "`measures..sql` is a column reference: a field of the cube's object (`amount`), a " - + "relationship path ending in one (`account.amount`), or `'*'` on a `count` measure."; + + "relationship path ending in one (`account.amount`), or `'*'` for a count."; const DIMENSION_FIRST_SENTENCE = "`dimensions..sql` is a column reference: a field of the cube's object (`status`) " + 'or a relationship path ending in one (`account.industry`).'; @@ -96,7 +99,7 @@ const refusalOf = (schema: { safeParse: (v: unknown) => { success: boolean; erro }; describe('cube member sql — the accept set is unchanged for every column reference', () => { - it('a measure admits a bare column, a relationship path, and `*` on a count — parsed byte-identically', () => { + it('a measure admits a bare column, a relationship path, and `*` — parsed byte-identically', () => { const admitted = [ { label: 'Total', type: 'sum', sql: 'amount' }, { label: 'Account total', type: 'sum', sql: 'account.amount' }, @@ -113,7 +116,8 @@ describe('cube member sql — the accept set is unchanged for every column refer }); it('a dimension admits a bare column and a relationship path — parsed byte-identically', () => { - for (const sql of ['status', 'account.industry', 'account.owner.region', '_private']) { + // `'*'` is in the accept set the execution parameters name for both members. + for (const sql of ['status', 'account.industry', 'account.owner.region', '_private', '*']) { const dim = { label: 'D', type: 'string', sql }; const r = DimensionSchema.safeParse(dim); expect(r.success, sql).toBe(true); @@ -127,7 +131,7 @@ describe('cube member sql — an expression is refused at parse, with the prescr for (const sql of EXPRESSIONS) { const issues = refusalOf(MetricSchema, { label: 'M', type: 'number', sql }); expect(issues, sql).toHaveLength(1); - expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.code).toBe('invalid_format'); expect(issues[0]!.path).toEqual(['sql']); expect(issues[0]!.message.startsWith(METRIC_FIRST_SENTENCE), sql).toBe(true); expect(issues[0]!.message).toMatch(EXPRESSION_PRESCRIPTION); @@ -147,7 +151,7 @@ describe('cube member sql — an expression is refused at parse, with the prescr for (const sql of EXPRESSIONS) { const issues = refusalOf(DimensionSchema, { label: 'D', type: 'string', sql }); expect(issues, sql).toHaveLength(1); - expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.code).toBe('invalid_format'); expect(issues[0]!.path).toEqual(['sql']); expect(issues[0]!.message.startsWith(DIMENSION_FIRST_SENTENCE), sql).toBe(true); expect(issues[0]!.message).toMatch(EXPRESSION_PRESCRIPTION); @@ -157,25 +161,19 @@ describe('cube member sql — an expression is refused at parse, with the prescr }); }); -describe("cube member sql — `'*'` is a count's row wildcard", () => { - it('a non-count measure refuses `*` at `sql`, with its own sentence', () => { - for (const type of AggregationMetricType.options.filter((t) => t !== 'count')) { - const issues = refusalOf(MetricSchema, { label: 'M', type, sql: '*' }); - expect(issues, type).toHaveLength(1); - expect(issues[0]!.code).toBe('custom'); - expect(issues[0]!.path).toEqual(['sql']); - expect(issues[0]!.message).toMatch( - new RegExp(`^\`measures\\.\\.sql\` is \`'\\*'\` only on a \`count\` measure — .*a \`${type}\` measure has nothing to aggregate`, 's'), - ); - } - }); - - it('a dimension refuses `*` — it names no column to group by', () => { - const issues = refusalOf(DimensionSchema, { label: 'D', type: 'string', sql: '*' }); - expect(issues).toHaveLength(1); - expect(issues[0]!.path).toEqual(['sql']); - expect(issues[0]!.message.startsWith(DIMENSION_FIRST_SENTENCE)).toBe(true); - expect(issues[0]!.message).toMatch(/names no column to group by/); +describe('cube member sql — the rule reaches the published JSON Schema as a pattern', () => { + it('both members carry the same `pattern` on `sql`, and it judges values as the parse does', () => { + const metricSql = (z.toJSONSchema(MetricSchema, { io: 'input', unrepresentable: 'any' }) as { + properties: Record; + }).properties.sql!; + const dimensionSql = (z.toJSONSchema(DimensionSchema, { io: 'input', unrepresentable: 'any' }) as { + properties: Record; + }).properties.sql!; + expect(metricSql.pattern).toBeDefined(); + expect(dimensionSql.pattern).toBe(metricSql.pattern); + const pattern = new RegExp(metricSql.pattern!); + for (const sql of ['amount', 'account.amount', '*']) expect(pattern.test(sql), sql).toBe(true); + for (const sql of EXPRESSIONS) expect(pattern.test(sql), sql).toBe(false); }); }); @@ -191,8 +189,8 @@ describe('cube member sql — every door that carries a cube refuses an expressi dimensions: { ...CUBE.dimensions, bucket: { label: 'Bucket', type: 'string', sql: "CASE WHEN a > 0 THEN 'x' END" } }, }); expect(issues.map((i) => [i.code, i.path])).toEqual([ - ['custom', ['measures', 'done_rate', 'sql']], - ['custom', ['dimensions', 'bucket', 'sql']], + ['invalid_format', ['measures', 'done_rate', 'sql']], + ['invalid_format', ['dimensions', 'bucket', 'sql']], ]); }); From a32fd122dc7f1331ebfcf46ba3bee18607648c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:33:28 +0000 Subject: [PATCH 3/6] feat(spec): register cube-member-sql-expression-retired under step 18, and regenerate the registry and reference docs The D3 semantic entry and its step-18 rationale fragment, the metric-filters fragment no longer naming a metric's own sql expression as a live channel, the regenerated migration registry, and the analytics reference page. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/data/analytics.mdx | 8 +- .../18.cube-member-sql-expression-retired.ts | 6 +- packages/spec/src/migrations/registry.ts | 93 ++++++++++++++++--- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 52d129948cb..dd7ba809eb2 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -147,7 +147,7 @@ Type: `[string, string]` | **label** | `string` | ✅ | Human readable label | | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | -| **sql** | `string` | ✅ | SQL expression or field reference | +| **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 }`). | | **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. | ### Nested Shape: `Cube.dimensions[string]` @@ -158,7 +158,7 @@ Type: `[string, string]` | **label** | `string` | ✅ | Human readable label | | **description** | `string` | optional | | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | | -| **sql** | `string` | ✅ | SQL expression or column reference | +| **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. | | **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. | ### Nested Shape: `Cube.joins[string]` @@ -191,7 +191,7 @@ Type: `[string, string]` | **label** | `string` | ✅ | Human readable label | | **description** | `string` | optional | | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | | -| **sql** | `string` | ✅ | SQL expression or column reference | +| **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. | | **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. | @@ -220,7 +220,7 @@ Type: `[string, string]` | **label** | `string` | ✅ | Human readable label | | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | -| **sql** | `string` | ✅ | SQL expression or field reference | +| **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 }`). | | **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. | diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts index d9a2e2bb7cc..7016e12c8f1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts @@ -15,11 +15,11 @@ export const entry: SemanticMigration = { surface: 'analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql ' + '(data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE ' - + 'expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling — and ' - + "the row wildcard '*' on anything but a count measure", + + 'expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any ' + + 'other value that is not a column reference', replacement: 'a column reference: a field of the cube\'s object (`amount`), a relationship path ending ' - + 'in one (`account.amount`), or `\'*\'` on a `count` measure. A derived value moves to an ' + + 'in one (`account.amount`), or `\'*\'` for a count. A derived value moves to an ' + 'ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure ' + 'with its own structured `filter` (`{ name: \'done_count\', aggregate: \'count\', filter: ' + '{ status: \'done\' } }`), and a ratio, sum, difference or product of measures is ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cdb5280de3c..ce0ba2a3ef5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5271,6 +5271,24 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling ' + 'they meant.', }, + { + id: 'cube-member-sql-expression-retired', + order: 53, + text: + 'It also narrows an analytics cube member\'s `sql` — `measures..sql` and ' + + '`dimensions..sql` — to a column reference: a field of the cube\'s object, a ' + + 'relationship path ending in one, or `\'*\'` (maintainer ruling D, ADR-0021 "zero raw ' + + 'SQL / zero raw expressions" carried from the dataset layer to the cube members it ' + + 'compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single ' + + 'field, so no platform check could judge which fields it reads, and the two analytics ' + + 'strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path ' + + 'refused it. It is now refused at parse with a prescription naming the ADR-0021 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. No D2 conversion: an expression has no mechanical rewrite into a dataset, so ' + + 'the semantic entry `cube-member-sql-expression-retired` carries the move, including ' + + 'the scale change a ratio makes (a `derived` ratio is a 0–1 fraction).', + }, { id: 'cube-metric-filters-retired', order: 10, @@ -5285,9 +5303,9 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'platform\'s structured-FilterCondition direction — it cannot be parameterized, ' + 're-targeted per driver dialect, or walked by the lint filter rules. The mechanical ' + 'conversion strips the key from old sources (pure lossless delete — it never had an ' - + 'effect to lose); filter at query time with `where`, fold the condition into the ' - + 'metric\'s own `sql` expression, or use an ADR-0021 dataset measure\'s structured ' - + '`filter`.', + + 'effect to lose); filter at query time with `where`, or use an ADR-0021 dataset ' + + 'measure\'s structured `filter` (a metric\'s own `sql` is a column reference, see ' + + '`cube-member-sql-expression-retired`).', }, { id: 'cube-refresh-key-retired', @@ -8229,6 +8247,58 @@ const step18: MigrationStep = { + 'name and updated every query, dashboard and report that names `.`. ' + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', }, + // #20943, maintainer ruling D — a cube member's `sql` is a column reference; + // the expression half is retired at the contract (ADR-0021's zero raw + // expressions, carried from the dataset layer to the cube members it compiles + // to). Semantic only, with no D2 conversion: an expression has no mechanical + // rewrite — it moves to another metadata type, and a ratio changes scale on + // the way — so the upgrader owes the judgement this entry states. + { + id: 'cube-member-sql-expression-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql ' + + '(data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE ' + + 'expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any ' + + 'other value that is not a column reference', + replacement: + 'a column reference: a field of the cube\'s object (`amount`), a relationship path ending ' + + 'in one (`account.amount`), or `\'*\'` for a count. A derived value moves to an ' + + 'ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure ' + + 'with its own structured `filter` (`{ name: \'done_count\', aggregate: \'count\', filter: ' + + '{ status: \'done\' } }`), and a ratio, sum, difference or product of measures is ' + + '`derived: { op, of: [...] }` over measures named in the same dataset (`{ name: ' + + '\'done_rate\', derived: { op: \'ratio\', of: [\'done_count\', \'task_count\'] }, format: ' + + '\'0.0%\' }`). A dimension that bucketed a column with a CASE expression has no expression ' + + 'form in either layer: group by the column itself, or keep the bucket as a field of the ' + + 'object and name that field', + reason: + 'Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose ' + + '`sql` is an expression names no single field, so no platform check can judge which fields ' + + 'it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it ' + + 'verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the ' + + 'author surface ("zero raw SQL / zero raw expressions"); it governed the dataset layer and ' + + 'left the cube members it compiles to open, which is the gap this closes. The dataset form ' + + 'is the declared home of a derived value because every field it reads is named: a measure ' + + 'filter names its fields, and a derived measure references other measures by name only. ' + + 'There is no D2 conversion: the rewrite moves a member to a different metadata type and ' + + 'cannot be derived from the expression text in general, so only the author can say which ' + + 'dataset measures express what the expression meant. A ratio also changes SCALE on the way: ' + + 'a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned ' + + 'percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio ' + + 'column\'s percent scale as a fraction) and re-check any consumer that read the old number ' + + 'raw. ADR-0021 / ADR-0049 / ADR-0087', + acceptanceCriteria: + 'Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack ' + + 'refuse an expression member at its `sql` with a prescription that names the dataset form, ' + + 'so the sweep is mechanical — parse each cube, and each refusal is one member to move. For ' + + 'each moved measure, a dataset over the same object declares it, and a query over a fixture ' + + 'where the condition excludes rows returns the same figure the expression returned (a ' + + 'ratio: the same value divided by 100 when the expression returned percentage points). ' + + 'Every dashboard, report or saved query that named the cube member now names the dataset ' + + 'measure. A cube member that aggregates a column parses byte-identically to before.', + }, // #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the // `metric-filters-removed` family (ruling B on #17152: one D3 entry per // retirement family, even when D2 is lossless). The unknown-keys entry @@ -8239,22 +8309,23 @@ const step18: MigrationStep = { { id: 'cube-metric-filters-retired', surface: 'analyticsCubes[].measures..filters — the per-metric raw-SQL filter list', - replacement: 'One of three filters that ARE applied: a `where` condition at query time, the ' - + 'condition folded into the metric\'s own `sql` expression (a conditional aggregate), or an ' - + 'ADR-0021 dataset measure with a structured `filter`.', + replacement: 'One of the two filters that ARE applied: a `where` condition at query time, or an ' + + 'ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition ' + + 'into the metric\'s own `sql` expression — left with `cube-member-sql-expression-retired`: a ' + + 'member\'s `sql` is a column reference.)', reason: 'The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and ' + 'the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a ' + 'metric authored with `filters: [{ sql: "stage = \'closed_won\'" }]` already returned the ' + 'UNFILTERED aggregate under the author\'s metric name, and still does. That is exactly why the ' + 'strip does not finish the job. The author wrote a condition because they wanted a filtered ' + 'number; every dashboard, report and export reading that metric has been showing a larger ' - + 'one. Only the author can say which of the three live mechanisms expresses the condition ' - + 'they meant — a query-time `where` changes every query, a conditional aggregate changes the ' - + 'metric, a dataset measure moves it to the governed layer — and whether numbers already ' - + 'published from the unfiltered metric need to be revisited.', + + 'one. Only the author can say which of the two live mechanisms expresses the condition ' + + 'they meant — a query-time `where` changes every query, a dataset measure moves the metric ' + + 'to the governed layer — and whether numbers already published from the unfiltered metric ' + + 'need to be revisited.', acceptanceCriteria: 'No cube metric carries `filters`; the parse refuses the key by name. For ' + 'each metric that carried one, the author has either re-expressed the condition through one ' - + 'of the three live mechanisms or decided the unfiltered aggregate is what they want — and ' + + 'of the two live mechanisms or decided the unfiltered aggregate is what they want — and ' + 'renamed the metric if its name promised the filter. With the condition re-expressed, a query ' + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', From 6d7b92f7ad9eed997091d5ff2c559f7cda021316 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:17:52 +0000 Subject: [PATCH 4/6] docs(spec,service-analytics): the changeset for the cube member sql narrowing, and the README stops prescribing a sql expression Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20943-cube-member-sql-column-reference.md | 108 ++++++++++++++++++ packages/services/service-analytics/README.md | 7 +- 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 .changeset/20943-cube-member-sql-column-reference.md diff --git a/.changeset/20943-cube-member-sql-column-reference.md b/.changeset/20943-cube-member-sql-column-reference.md new file mode 100644 index 00000000000..322fb78a783 --- /dev/null +++ b/.changeset/20943-cube-member-sql-column-reference.md @@ -0,0 +1,108 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': patch +--- + +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) + +Clause-②: yes (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, +never by the level). + +`MetricSchema.sql` and `DimensionSchema.sql` — the `sql` of every member in an +analytics cube's `measures` and `dimensions` — admit a column reference only: a +field of the cube's object (`amount`), a relationship path of bare identifiers +ending in one (`account.amount`, `account.owner.region`), or `'*'` for a count. +Any other value — a `CASE WHEN …`, an aggregate or a ratio of aggregates, a quoted +or `$`-prefixed spelling, an empty string — is refused at parse with a +prescription. This is ADR-0021's "zero raw SQL / zero raw expressions" carried +from the dataset layer to the cube members it compiles to (maintainer ruling D on +the card): an expression names no single field, so no platform check can judge +which fields it reads, and the two analytics strategies never agreed on it — the +raw-SQL path ran it verbatim and the ObjectQL path refused it. The rule is a +`pattern` in the published JSON Schema too, so a document validated against +`json-schema/**` is judged as the parse judges it. + +## FROM → TO + +A derived value moves to an ADR-0021 dataset over the same object. A conditional +count or sum is a dataset measure with its own structured `filter`; a ratio, sum, +difference or product of measures is `derived: { op, of: [...] }` over measures +named in the same dataset. + +``` +FROM defineCube({ name: 'delivery', sql: 'task', measures: { + done_rate: { label: 'Done Rate (%)', type: 'number', + sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)" }, + } }) + -> parsed; the expression ran verbatim on one strategy and was refused on the other +TO -> ZodError at measures.done_rate.sql (invalid_format): `measures..sql` is a + column reference: a field of the cube's object (`amount`), a relationship path ending + in one (`account.amount`), or `'*'` for a count. A SQL expression there was retired … + + defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task', + dimensions: [/* … */], + measures: [ + { name: 'task_count', aggregate: 'count' }, + { name: 'done_count', aggregate: 'count', filter: { status: 'done' } }, + { name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }, + ] }) +``` + +**Mind the scale.** A `derived` ratio is a 0–1 fraction. An expression that +multiplied by 100 returned percentage points; pair the ratio with a `%` numeral +pattern (the server marks a ratio column's percent scale as a fraction) and +re-check any consumer that read the old number raw. + +**A dimension that bucketed a column with a CASE expression** has no expression +form in the cube layer or the dataset layer: group by the column itself, or keep +the bucket as a field of the object and name that field. + +**The one-line fix:** parse each cube; every refusal at `…sql` is one member to +move — replace it with the column it aggregates, or move the derived value to a +dataset measure as above, and point the dashboards, reports and queries that named +`.` at the dataset measure. + +**What an author who still writes it sees.** `CubeSchema`, `defineCube()`, +`defineStack({ analyticsCubes })` (`STACK_SCHEMA_INVALID` / 422) and the +`analytics_cube` write door refuse the member at its `sql` path with the +prescription. `tsc` does not: the key's type is still `string`. + +## The retirement kit + +- **Schema.** `MetricSchema.sql` / `DimensionSchema.sql` carry the pattern and + their prescriptions (`data/analytics.zod.ts`). A column reference parses + byte-identically to before. The retired metric `filters` guidance and the + analytics query's `filters` guidance no longer offer "fold the condition into + the metric's own `sql` expression" as a live channel; the `metric-filters-removed` + conversion summary and its D3 entry and step-18 rationale fragment say the same. +- **ADR-0087.** The D3 entry `cube-member-sql-expression-retired`, with its + step-18 rationale fragment. No D2 conversion — an expression has no mechanical + rewrite into a dataset — and no `RETIRED_KEYS_BY_MAJOR` row: no key left the + shape, so the authorable-surface, api-surface and JSON-schema manifest + ratchets are unchanged. +- **Liveness.** The `analytics_cube` ledger rows `measures.sql` and + `dimensions.sql` stay `live`, re-verified, with the narrowing recorded. +- **Docs.** The `data/analytics` reference page is regenerated. +- **Example.** The showcase cube's `done_rate` expression member moves to the + `showcase_task_metrics` dataset as `done_count` (a count filtered on + `status: 'done'`) and `done_rate` (`ratio` over `done_count` and `task_count`, + format `0.0%`). +- **`@objectstack/service-analytics`** (README only): its query-body section no + longer tells a reader to fold a per-metric condition into the metric's own + `sql` expression. The runtime is unchanged: its expression branches remain for + a cube that reaches the service without meeting the parse, and their deletion + is a separate change. + +## Reach, measured + +- This repository: one authored expression member (the showcase `done_rate`), + moved here. Test fixtures in `@objectstack/service-analytics` that build + expression members WITHOUT the parse keep exercising the runtime's expression + branches, unchanged. +- Out-of-repo authored cubes: NOT MEASURED. + + diff --git a/packages/services/service-analytics/README.md b/packages/services/service-analytics/README.md index cee8c9eecef..ddbbb4dc762 100644 --- a/packages/services/service-analytics/README.md +++ b/packages/services/service-analytics/README.md @@ -104,9 +104,10 @@ is rejected rather than dropped. There is no `filters` key and no `aggregations` key. `filters` is rejected at the REST door with a 400 naming `where`. There is no per-metric filter key either — the cube -metric's `filters` was removed (#10414: no strategy ever read it); fold a per-metric -condition into the metric's own `sql` expression, or use an ADR-0021 dataset measure's -structured `filter`. +metric's `filters` was removed (#10414: no strategy ever read it). A measure that counts +or sums only some rows is an ADR-0021 dataset measure with its own structured `filter`; +a cube member's `sql` is a column reference (a field, a relationship path ending in one, +or `'*'`), never a SQL expression (#20943). ```typescript const revenueByStatus = await analytics.query({ From e6b66ecde531197df2027f40a0799b86e4c7460b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:24:23 +0000 Subject: [PATCH 5/6] fix(example-showcase): translate the task dataset's done_count and done_rate labels at birth The two dataset measures the done rate moved into are declared surface under the i18n coverage ratchet, so they carry en and zh-CN bundle entries; the showcase's untranslated count is back at its baseline. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/system/translations/index.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b7270b2d709..6debc096cb2 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -348,6 +348,18 @@ export const ShowcaseTranslationBundle = { }, }, }, + // Translated at birth, same rule as the widgets above: the done rate moved + // here from the delivery cube (#20943) as two new dataset measures, born + // under the ratchet. The task dataset's older labels predate it and stay in + // the frozen baseline. + datasets: { + showcase_task_metrics: { + measures: { + done_count: { label: 'Done Tasks' }, + done_rate: { label: 'Done Rate' }, + }, + }, + }, }, 'zh-CN': { objects: { @@ -1018,6 +1030,16 @@ export const ShowcaseTranslationBundle = { }, }, }, + // The done rate's two measures, translated at birth (#20943) — see the `en` + // block. The other task-dataset labels stay in the frozen baseline. + datasets: { + showcase_task_metrics: { + measures: { + done_count: { label: '已完成任务' }, + done_rate: { label: '完成率' }, + }, + }, + }, // Page component copy became declared surface with `pages..components` // (#6080), so these keys are born under the ratchet: leaving any of them // untranslated widens the frozen baseline and fails `check-i18n-coverage`. From ac70996345fd12a48696af01fbbb319bd593bf13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:01:12 +0000 Subject: [PATCH 6/6] fix(spec): the analytics_cube sql ledger notes say what the schema admits, and the schema comment names the ObjectQL refusal's real partition The dimensions.sql note said '*' is refused at parse; the schema admits it on a dimension, as on any measure, and the measures.sql note no longer limits it to a count. The count-only boundary is #21000's. The schema comment now says the ObjectQL path refuses only the number / string / boolean partition. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/liveness/analytics_cube.json | 4 ++-- packages/spec/src/data/analytics.zod.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index d132a0685e3..10a2c0e7fc5 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -63,7 +63,7 @@ "verifiedAt": "2026-09-30", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column the aggregate is applied to (`'*'` the COUNT(*) form), and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'` on a `count` measure — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. The runtime's expression branches (verbatim emit on the raw-SQL path, the gate's stand-down) remain for a cube that reaches the service without meeting the parse; their deletion is the services-lane follow-up. The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset." + "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'`, which the schema admits on any measure (the count-only boundary is #21000's) — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value other than `'*'` (which reads no field value) resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. The runtime's expression branches (verbatim emit on the raw-SQL path, the gate's stand-down) remain for a cube that reaches the service without meeting the parse; their deletion is the services-lane follow-up. The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset." }, "format": { "status": "live", @@ -107,7 +107,7 @@ "verifiedAt": "2026-09-30", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path; a SQL expression — a CASE bucket included — and `'*'` are refused at parse. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." + "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path, and `'*'`, which the schema admits on a dimension too (the accept set ruling D's execution parameters name for both members; it never made a working query, and narrowing it is #21000's); a SQL expression — a CASE bucket included — is refused at parse. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." }, "granularities": { "status": "live", diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 1e1206255d5..6f03ec88387 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -219,7 +219,10 @@ const CUBE_DIMENSION_NAME_REMOVED = cubeMemberNameRemoved('dimensions.