Skip to content

Commit 2bc1876

Browse files
hotlongclaude
andauthored
fix(service-analytics): refuse a dotted measures entry loudly, naming the caller's spelling (#5918) (#6292)
`inferCubeFromQuery`'s measure mint dropped the first segment of ANY dotted entry, so `owner.region_count_distinct` minted a Metric over the BASE table's own `region` column: `SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct" FROM "crm_account"` — no JOIN, no error, and a response column labelled with a relation attribute whose number came from the base table. Where the object had no same-named column it degraded to #4437's `400 INVALID_FIELD` naming the STRIPPED tail, about a string the caller had never written. `measures` is the fourth mint site of the punctuation #5739 sorted out on the three dimension-shaped ones, and it is ruled the other way: `lookupMember`'s relation-traversal tier is dimension-only, so a dotted measure has no correct traversal answer to converge on. Maintainer ruling 2026-08-07 — refuse it, with the caller's own spelling in the envelope (`INVALID_FIELD` / 400 / `member` / `param: 'measures'`, the #4437 family's shape, via `invalidMemberError`). The rule lives in one place (`mintableMeasureKey`) and both measure mints call it: the ad-hoc one and `ensureCube`'s suffix-augmentation loop. Measured: the ad-hoc path REGISTERS what it infers, so from the second request onwards the same query reaches the augmentation loop instead — refusing at one site only would have left every warm service exactly as it was. A measure the cube DECLARES is authored, not minted, and is checked verbatim first so a cube's own dotted key still resolves and still compiles its JOIN. Unchanged: the `<cube>.` qualifier, bare measures, and the dimension traversal #5739 ruled the other way. Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7330c1d commit 2bc1876

5 files changed

Lines changed: 673 additions & 32 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): refuse a dotted `measures` entry loudly instead of aggregating the base column (#5918)
6+
7+
**Observable behaviour change.** An analytics query whose `measures` entry
8+
carries a dot that is not the cube-name qualifier — `owner.region_count_distinct`,
9+
`total.sum` — now answers `400 INVALID_FIELD` naming the entry **as the request
10+
spelled it**. Some of these queries used to succeed.
11+
12+
That is the point: succeeding is what was wrong with them. The auto-inference
13+
path minted a measure by dropping the first segment of any dotted entry, so on
14+
an object that happened to carry a same-named column the query ran
15+
16+
```
17+
SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct" FROM "crm_account"
18+
```
19+
20+
— no JOIN, no error, a response column labelled with a relation attribute and a
21+
number that came from the base table. The caller could not tell from the result
22+
that it was wrong. Where the object had no same-named column it degraded to the
23+
#4437 gate's `400 INVALID_FIELD`, which was honest about what reached SQL
24+
(`aggregates field 'score'`) but named a string nobody had written; the caller
25+
had sent `owner.score_sum`.
26+
27+
`measures` was the fourth and last mint site of the punctuation #5739 sorted
28+
out on `dimensions` / `where` / `timeDimensions`. It is ruled the other way, and
29+
deliberately so: `lookupMember`'s relation-traversal tier is dimension-only, so a
30+
dotted measure has no correct traversal answer to converge on. A refusal is the
31+
honest answer, and it costs nothing that was working. Maintainer ruling,
32+
2026-08-07.
33+
34+
Both a genuine traversal intent (`owner.amount_sum`) and a plain typo
35+
(`total.sum`) get this refusal. They are lexically indistinguishable on this
36+
path, and separating them would need field metadata the ad-hoc path does not
37+
have. A real relation-traversal measure (`SUM("owner"."amount")` + LEFT JOIN)
38+
would be a capability with its own justification, not a side effect of a strip.
39+
40+
The refusal is applied at both places a Metric is minted from a request
41+
spelling — the ad-hoc mint and the suffix-augmentation mint for a cube that is
42+
already registered — because the ad-hoc path registers what it infers, so the
43+
very same query reaches the second one from the second request onwards.
44+
45+
Unchanged: the `<cube>.` qualifier (`crm_account.region_count_distinct`) is
46+
still stripped and still runs; bare measures (`region_count_distinct`, `count`,
47+
`created_at_max`) are untouched; a cube's own declared measure is authored, not
48+
minted, so a Cube whose measure names a related column in its `sql` still
49+
compiles the JOIN — which is the supported way to aggregate across a
50+
relationship; and dotted **dimensions** still traverse, per #5739.
51+
52+
**Migration.** Aggregate one of the object's own fields
53+
(`<field>_sum` / `_avg` / `_min` / `_max` / `_count_distinct`), or declare a Cube
54+
whose measure names the related column. The refusal message says both, and names
55+
the entry you sent.

0 commit comments

Comments
 (0)