Skip to content

Commit 775dfcd

Browse files
committed
docs(lint): document the dataset-measure aggregate x field-type rule, with its changeset
The rule reference gains the row and the worked example beside the dataset-axis section that already covers measures, so an author reading about chart axes finds the one about the measure itself. The changeset carries the migration the refusal prescribes, per refused class, and the ADR-0087 disposition: the two semantic entries that register this surface already exist. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
1 parent a42e265 commit 775dfcd

2 files changed

Lines changed: 73 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Refuse a dataset measure whose `aggregate` the field's declared type cannot carry, at authoring time
6+
7+
A dataset measure pairs an `aggregate` with a `field`, and
8+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which of those pairs every
9+
backend answers the same way. Nothing in the authoring path read that table, so `avg` over a
10+
`datetime` field validated clean and shipped: one SQL family coerces the column's canonical UTC
11+
text and returns a plausible number (the average *year*), another has no such function and fails at
12+
query time — the answer decided by the deployment rather than by the document. The analytics service
13+
refuses the pair when a query is built (`400 DATASET_INVALID`); this is the same verdict, from the
14+
same table, at the door the author is standing in front of.
15+
16+
New rule `measure-aggregate-field-type-refused`, gating (`error`), on `os validate` / `os build` /
17+
`os lint`. It resolves the field's declared type on the object graph lint already indexes — including
18+
a dotted `relationship.field` path, whose leaf type the compile leg cannot see — and refuses the
19+
pair when `isAggregateCompatibleWithFieldType` says no. The message names the aggregate, the field,
20+
its declared type and the accepted set, and the hint names the aggregates that type *does* accept,
21+
both computed from the table rather than restated. It stays silent wherever the type cannot be
22+
resolved (an object this stack does not define, a field path that resolves to nothing, an untyped
23+
field, an aggregate outside the closed `AggregationFunction` vocabulary) rather than guessing.
24+
25+
**BREAKING**: metadata that passed `os validate` / `os build` / `os lint` before can now fail. Every
26+
pair this refuses is one the analytics service already refuses at query time, so nothing that
27+
*worked* stops working — but a build that did not fail now does.
28+
29+
Migration, per refused pair — FROM the aggregate the field's type cannot carry, TO one it accepts:
30+
31+
- `avg` / `sum` over a `date` / `datetime` / `time` field → `min` / `max`, which return a real
32+
instant of the field's own type, or `count` / `count_distinct`. A DURATION is not recoverable from
33+
an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that.
34+
- `sum` over a `percent` field → `avg`. A rate does not add; the total routinely exceeds 100%.
35+
- `min` / `max` over the string, option, reference, file, structured-JSON or `formula` classes →
36+
`count` / `count_distinct` for "how many distinct values", or a SORT on the record list for "the
37+
first / last record". String order is collation-dependent, so two backends answer two different
38+
"smallest" values for one document.
39+
- Any other refused pair → read the row for your aggregate in
40+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY`; the refusal message prints it.
41+
42+
A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the
43+
`derived` one. A `date` / `datetime` / `text` field used as a DIMENSION — grouping, bucketing,
44+
filtering — is untouched: this is about aggregation only.
45+
46+
Clause-②: yes (narrowing)
47+
48+
<!-- adr-0087: not-required (already-registered dataset-measure-aggregate-field-type-refused, dataset-measure-selecting-aggregate-field-type-refused) The two entries register this exact surface — dataset measure `aggregate` x `field` pairs the table refuses — with the prescription above; #16099 likewise declared not-required against the first id when it widened the same leg. This change adds no surface of its own: it is the second consumer of that one table, at the authoring door. -->

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,30 @@ The react `<ObjectChart>` block is **object-bound** (`objectName` + an inline
189189
gate below against a different rule — its result rows are keyed by the raw field
190190
names, exactly the opposite of the dataset case.
191191

192+
One level down from the axis, the measure itself is checked against the field it
193+
aggregates. A measure pairs an `aggregate` with a `field`, and
194+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which pairs
195+
every backend answers the same way. A pair outside it is refused
196+
(`measure-aggregate-field-type-refused`) naming the aggregate, the field, its
197+
declared type and the accepted set:
198+
199+
```ts
200+
// object declares `closed_at` as `datetime`
201+
measures: [{ name: 'avg_closed', aggregate: 'avg', field: 'closed_at' }]
202+
// ↑ refused: the answer would be the dialect's, not the data's
203+
```
204+
205+
`avg` over a temporal column is where this bites hardest: one SQL family coerces
206+
the stored text and returns a plausible number (the average *year*), another has
207+
no such function and fails at query time. `min`/`max` over the same field are
208+
**accepted** — they return a real instant of the field's own type — and
209+
`count`/`count_distinct` are accepted over every type, because they read no
210+
arithmetic off the value. The analytics service refuses the same pair with
211+
`400 DATASET_INVALID` when a query is built; this is the identical verdict,
212+
from the identical table, one door earlier. The rule stays silent wherever the
213+
field's type cannot be resolved (an object this stack does not define, a
214+
dangling field path, an untyped field) rather than guessing.
215+
192216
### 7. Navigation exposing objects nobody can read
193217

194218
Navigation and permissions are separate metadata, each valid on its own — so an
@@ -396,6 +420,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
396420
| Zod-valid but functionally inert declarations — a `summary` with no operations (ADR-0078), a managed object advertising an API method its affordances refuse (#7521) | ✓ | ✓ | ✓ | ✓ᵒ |
397421
| View container shape | ✓ | ✓ | ✓ | — |
398422
| Widget-binding integrity (ADR-0021) | ✓ | ✓ | ✓ | ✓ᵈ |
423+
| Dataset measure `aggregate` × the field's declared type — a pair the spec's compatibility table refuses, e.g. `avg` over a `datetime` field (#16354) | ✓ | ✓ | ✓ | — |
399424
| Dashboard action/route references (ADR-0049) | ✓ | ✓ | ✓ | — |
400425
| Filter placeholder resolvability (#3574) | ✓ | ✓ | ✓ | — |
401426
| Ordering comparands naming a date-range preset — `last_30_days` in a `>=` position (#8793) | ✓ | ✓ | ✓ | ✓ᵈᵛᵒᵖᶠ |

0 commit comments

Comments
 (0)