|
| 1 | +--- |
| 2 | +'@objectstack/spec': minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: the analytics row wildcard `'*'` is admitted only where a `count` consumes it — a cube or dataset measure over `'*'` under any other aggregate, and a cube dimension over `'*'`, are refused at parse (#21409) |
| 6 | + |
| 7 | +Clause-②: no (narrowing) |
| 8 | + |
| 9 | +**BREAKING** — shipped as `minor` under the launch-window convention |
| 10 | +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by |
| 11 | +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, |
| 12 | +never by the level). |
| 13 | + |
| 14 | +`'*'` is the row wildcard: what a `count` aggregates (`COUNT(*)`), reading no |
| 15 | +field value. It is now admitted in exactly one place, a measure that counts: |
| 16 | + |
| 17 | +- `MetricSchema.sql` — a cube measure's `sql` — admits `'*'` under |
| 18 | + `type: 'count'` only; under any other `type` it is refused at `sql` |
| 19 | + (code `custom`). |
| 20 | +- `DatasetMeasureSchema.field` — an ADR-0021 dataset measure's `field` — admits |
| 21 | + `'*'` under `aggregate: 'count'` only; under any other aggregate, or on a |
| 22 | + measure with no aggregate (a `derived` one), it is refused at `field` |
| 23 | + (code `custom`). A count may still omit `field`. |
| 24 | +- `DimensionSchema.sql` — a cube dimension's `sql` — never admits `'*'` |
| 25 | + (code `invalid_format`): it takes the column path without the wildcard arm, |
| 26 | + the pattern a dataset dimension's `field` already takes. |
| 27 | + |
| 28 | +Each refusal names the slot and the aggregate the author wrote, and prescribes |
| 29 | +the two ways out: a `count`, or a column. A column or a relationship path parses |
| 30 | +byte-identically to before on every slot, and so does a `count` over `'*'`. |
| 31 | + |
| 32 | +Why: no aggregate but `count` has a column to read over `'*'`, and a dimension |
| 33 | +has no aggregate at all, yet the contract admitted the wildcard on any measure |
| 34 | +and on a cube dimension, and the analytics strategies sent it to the database as |
| 35 | +written. Measured at `POST /api/v1/analytics/dataset/query` over a real SQLite |
| 36 | +driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure |
| 37 | +aggregating `'*'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered |
| 38 | +`500 DATABASE_ERROR`. A dataset measure compiles to the cube measure it names |
| 39 | +verbatim, so the same reading covers an authored cube measure. Such a member |
| 40 | +never produced an answer, so no working document changes meaning: the failure |
| 41 | +moves from the query to the authoring parse. The two measure slots ask ONE |
| 42 | +shared predicate; the rule is cross-field (the slot and its aggregate), so it is |
| 43 | +a refinement, which the published JSON Schema cannot carry — both sites are |
| 44 | +declared in `dropped-refinements.baseline.json`. The dimension half is a |
| 45 | +`pattern`, so `json-schema/**` states it. |
| 46 | + |
| 47 | +## FROM → TO |
| 48 | + |
| 49 | +``` |
| 50 | +FROM { name: 'deal_metrics', label: 'Deal Metrics', object: 'deal', |
| 51 | + dimensions: [{ name: 'stage', field: 'stage' }], |
| 52 | + measures: [{ name: 'deals', aggregate: 'sum', field: '*' }] } |
| 53 | + -> DatasetSchema.parse accepted it; a dataset query selecting `deals` |
| 54 | + answered 500 DATABASE_ERROR |
| 55 | +TO -> DatasetSchema.parse throws a ZodError at measures.0.field (custom): |
| 56 | + `measures[].field` is the row wildcard `'*'` under `aggregate: 'sum'`. … |
| 57 | + defineStack({ datasets }) refuses it at datasets.N.measures.0.field (422 |
| 58 | + STACK_SCHEMA_INVALID), and POST /api/v1/analytics/dataset/query answers |
| 59 | + 400 VALIDATION_FAILED for an inline or a saved copy |
| 60 | +
|
| 61 | + measures: [{ name: 'deals', aggregate: 'count' }] // a row count |
| 62 | + measures: [{ name: 'deal_value', aggregate: 'sum', field: 'amount' }] // an aggregate of a column |
| 63 | +
|
| 64 | +FROM defineCube({ name: 'deals', sql: 'deal', |
| 65 | + measures: { total: { label: 'Total', type: 'sum', sql: '*' } }, |
| 66 | + dimensions: { everything: { label: 'All', type: 'string', sql: '*' } } }) |
| 67 | +TO -> refused at measures.total.sql (custom) and dimensions.everything.sql (invalid_format) |
| 68 | +
|
| 69 | + measures: { total: { label: 'Total', type: 'sum', sql: 'amount' } }, |
| 70 | + dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } } |
| 71 | +``` |
| 72 | + |
| 73 | +**The one-line fix:** parse each cube and dataset; every refusal at `…sql` / |
| 74 | +`…field` naming `'*'` is one member to change — declare a `count` to count rows, |
| 75 | +or name the column the measure aggregates (a dimension names the column it |
| 76 | +groups by). On a `derived` dataset measure, delete `field`: nothing read it. |
| 77 | +There is no mechanical rewrite: `os migrate meta` rewrites nothing for it, and |
| 78 | +lists the entry `analytics-row-wildcard-outside-count-refused` as a manual |
| 79 | +change that requires your judgment. |
| 80 | + |
| 81 | +**What a stored document meets.** A metadata read still serves it as stored, |
| 82 | +with the refusal on its read diagnostics (`_diagnostics`), and a re-save through |
| 83 | +the metadata write door is refused at the slot. `POST |
| 84 | +/api/v1/analytics/dataset/query` parses every dataset it is handed, inline or |
| 85 | +saved, so a stored dataset carrying such a measure answers `400 |
| 86 | +VALIDATION_FAILED` at `measures.N.field` on every query — including a query that |
| 87 | +selects only its other measures, which used to answer — until the member is |
| 88 | +fixed: it fails closed. An authored cube reaches the analytics runtime through |
| 89 | +the stack definition, whose parse refuses it. |
| 90 | + |
| 91 | +## The kit |
| 92 | + |
| 93 | +- **Schema.** `data/analytics-column-reference.ts` (not published API) declares |
| 94 | + the predicate `rowWildcardOutsideCount` and its refusal once; `MetricSchema` |
| 95 | + and `DatasetMeasureSchema` call both from a refinement, and |
| 96 | + `DimensionSchema.sql` takes `ANALYTICS_COLUMN_PATH`. No export, key or enum |
| 97 | + member changes, so the api-surface, authorable-surface and JSON-schema |
| 98 | + manifest ratchets are unchanged. |
| 99 | +- **ADR-0087.** D3 entry `analytics-row-wildcard-outside-count-refused`. No D2 |
| 100 | + conversion: rewriting to `count` would change the figure the author asked for, |
| 101 | + and only the author can name the column. No `RETIRED_KEYS_BY_MAJOR` row. |
| 102 | +- **Dropped refinements.** `data/Metric` and `ui/DatasetMeasure` gain their root |
| 103 | + site, and every published schema embedding them gains the embedded site. |
| 104 | +- **Liveness.** `analytics_cube` `measures.sql` / `dimensions.sql` and `dataset` |
| 105 | + `measures.field` stay `live`, re-verified, their notes re-pointed here. |
| 106 | +- **Docs.** The `ui/dataset` reference page is regenerated. |
| 107 | +- **Runtime.** Unchanged. |
| 108 | + |
| 109 | +## Reach, measured |
| 110 | + |
| 111 | +- This repository: no example, platform object, doc, skill, script or test |
| 112 | + fixture authors `'*'` outside a `count` at the three slots (`git grep` of every |
| 113 | + `field` / `sql` value spelled `'*'`, 173 hits, each read in its enclosing |
| 114 | + object: 154 under a `count`, the rest QueryAST aggregations, comments and |
| 115 | + strategy-level literals). One spec pin admitted `'*'` on a cube dimension; it |
| 116 | + now pins the refusal. |
| 117 | +- objectui at the pinned `.objectui-sha`: zero `field` / `sql` values spelled |
| 118 | + `'*'` (lit controls: 51 `aggregate: 'sum'`, 438 `field: 'amount'`). |
| 119 | +- Out-of-repo authored metadata: NOT MEASURED. |
| 120 | + |
| 121 | +<!-- adr-0087: registered analytics-row-wildcard-outside-count-refused --> |
0 commit comments