Repository navigation
Commit 3a7b6eb
fix(service-analytics)!: judge and present a relationship-path cube measure by its column's declaration on the object the path reaches (#21230)
Fixes #21129
Clause-②: no (narrowing)
## What changed
A configured cube measure whose `sql` is a relationship path
(`account.name`) is now judged, described and presented by the
declaration on the object the path's last hop reaches, exactly as a
measure over the cube's own column already was (#21044).
- **One column, located once** (`analytics-service.ts`).
`declaredMeasureColumn` locates a relationship-path column on the object
its last hop reaches through `columnObjectOf` (`hop-object.ts`, PR
#21088, consumed unchanged), with the service's `hopReference`: the
cube's declared join, else the relationship field's declared
`reference`, else the alias. That is the answer the field gate admits
the path with and both strategies join it by. ⛔ No second path walk.
- **The door** (`cube-measure-field-type-door.ts`).
`assertCubeMeasureFieldTypesAccepted` now takes the located column
(`MeasureColumn`: object, column, path) and asks
`isAggregateCompatibleWithFieldType` with that object's declaration.
`field` on the error is the path as the measure spells it, and `object`
is the related object that declares the column: the convention the
#20912 door already uses for a joined dimension. The words for a
base-object column are byte-identical to before.
- **The result type** (`withMeasureResultTypes`). It reads the same
`declaredMeasureColumn`, so a relationship-path `min` / `max` over a
temporal column is described `time`, as a base-object one is.
- **The native presenter** (`strategies/native-sql-strategy.ts`). A new
module helper, `measureColumnOf`, is read by the presenter and by the
aggregand operand policy. The policy is the same code, moved onto the
helper with unchanged behaviour. A relationship-path `min` / `max` over
a numeric column now goes through `presentAsNumber`, as a base-object
one does.
## Why "refused" rather than "presented as text" (dispatch Zone 2, item
2)
The base-object door's answer for a base `text` `max` is a refusal on
both faces, and following it needs no new surface. The ObjectQL face
already refused the related pair as a cross-object measure. The native
face now refuses it through the same door, so the two faces give one
envelope. "Presented as text" would need two things. First, the ObjectQL
face would have to serve cross-object measures, a widening and so a
Clause-② question. Second, `fields[]` would need a new word for a text
measure. It would also disagree with the base-object door on the same
declared type.
## Measured: `POST /api/v1/analytics/query` on the real dispatcher route
Setup: `AnalyticsServicePlugin` over a real `ObjectQL` engine and
`SqlDriver`, the cube handed in as `cubes`, and a signed-in caller. The
cube is over `deal` with a declared join `account` (`name` text,
`revenue` number, `opened_at` datetime, `tier` select). Readings were
taken before at `c6b6889193` (base) and after with this branch's
`service-analytics` build. There are 40 cells (2 drivers x 2 faces x 10
measures): 20 changed and 20 are identical. The scratch probe was
deleted.
| measure `sql` | face | SQLite before → after | PostgreSQL 16.14 before
→ after |
|:--|:--|:--|:--|
| `max` / `min` over `account.name` (text) | native | 200 `"zeta"` /
`"alpha"`, `fields[]` number → **400 `INVALID_FIELD`** | the same →
**400** |
| `max` over `account.tier` (select) | native | 200 `"b"`, number →
**400** | the same → **400** |
| `sum` over `account.name` | native | 200 `0` → **400** | 500
`DATABASE_ERROR` → **400** |
| `max` / `min` over `account.revenue` (number) | native | 200 `250` /
`100` → unchanged | 200 `"250.000000000000000000000000000000"` (string)
→ **200 `250` (number)** |
| `max` over `account.opened_at` (datetime) | native | 200 instant,
`fields[]` number → **`time`** | the same → **`time`** |
| the text / select / sum-of-text rows above | ObjectQL | 400
`INVALID_FIELD` (cross-object refusal) → 400 `INVALID_FIELD` (this door,
with `field` / `object`) | the same |
| the number / datetime rows above | ObjectQL | 400 cross-object refusal
→ unchanged | unchanged |
| controls: `max` over `note` (base text), `max` over `amount` (base
number) | both | 400 / 200 `32` → unchanged | unchanged |
## Pins
New file:
`packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts`.
It uses the plugin's own composition over a real engine, on a SQLite
cell and a PostgreSQL cell (a named skip without
`OS_TEST_POSTGRES_URL`), and both faces. It has 13 tests: 6 per cell and
1 more.
- Every refused relationship-path pair is `INVALID_FIELD` / 400 on both
faces, with nothing read. The checked fields are `code`, `status`,
`member`, `param`, `cube`, `field` (the path) and `object` (the related
object), and the raw-SQL and engine-aggregate counters stay at 0. The
two faces' envelopes must be equal. The ObjectQL face's own cross-object
refusal carries no `field` / `object`, so equality proves the door
answered and not two refusals that happen to agree. The pairs include
`max` over `owner.email`, where `owner` is a lookup the cube declares no
join for. Only the field's declared `reference` reaches `os21129_person`
(tier 2).
- Native: a related numeric `min` / `max` is a JS number typed `number`
(`250`, `100`, `41`), and a related temporal `max` is typed `time`.
- The dry-run door refuses what the query door refuses.
- Controls: a base text `max` is refused the same way, and a base number
`max` answers `32` on both faces.
- Cannot answer, do not block: a host whose field metadata does not
describe the related object gets no verdict, and the statement runs.
## Ablations (from committed `37196767`; predictions written before each
run)
The tests import the subject by relative path
(`../analytics-service.js`, `../plugin.js`), so each run reads `src`.
There is no `dist` leg. Every mutation went through
`scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap`
restore on `EXIT INT TERM` against the absolute path.
| ablation | mutation | predicted | observed |
|:--|:--|:--|:--|
| A1 | `declaredMeasureColumn`: a relationship path answers `column:
null` (the old stand-down) | 7 red: refused-pair, temporal `time` and
dry-run on each cell, plus the dataset query-time refusal case | **7
failed / 32 passed**, e.g. `native max_acct_name must not be served`,
`expected 'number' to be 'time'` |
| A2 | presenter: `target = { object: objectName, field: measure.sql }`
(the old base-object lookup) | 1 red: the PostgreSQL numeric case;
SQLite answers numbers anyway | **1 failed / 22 passed**: `expected
'250.000000000000000000000000000000' to be 250` |
| A3 | `columnObjectOf(..., undefined)`: the host's reference answer is
dropped | 2 red: the refused-pair test on each cell, at
`max_owner_email` | **2 failed / 11 passed**: `native max_owner_email
must not be served` |
Each mutation landed (anchor 1 → 0, and the blob changed: A1
`34fb71b6048c` → `c55b95c96e8a`, A2 `d7c20d35c3ba` → `aebd3cb1f1fc`, A3
`34fb71b6048c` → `ee3a18cc777f`). Each was restored and proven: the blob
equals the HEAD blob, `git diff HEAD` is empty, and porcelain shows 0.
## Fixture triage
The full `service-analytics` suite turned up exactly two fixtures that
pinned the removed stand-down. Each drove a dataset measure over
`account.FIELD` through `queryDataset`, with a `sourceFieldMeta` stub
that answered by field name for every object, so the stub described the
joined object too.
- `aggregate-nontemporal-measure-refusal.test.ts`, tier 2 of "the three
cannot-answer tiers": the stub now describes the base object alone, as
the comment says, and the case also asserts that the statement ran.
- `aggregate-datetime-measure-refusal.test.ts`: the stand-down case gets
a base-only hook, the same way. A sibling case keeps the any-object hook
and pins the new behaviour: the dataset's compile check still stands
down on the dotted field, and its query is refused by the cube door with
`INVALID_FIELD` / 400, `field` `account.submitted_at` and `object`
`account`, and no statement runs.
Consumer radius: no fixture outside `service-analytics` feeds the door a
relationship-path `min` / `max` / `sum` / `avg` with field metadata
wired. A `git grep` over `packages/rest`, `packages/runtime`,
`packages/qa`, `examples`, `packages/cli`, `packages/plugins` and `apps`
turned up only dimensions, plus one `rest` dataset measure (`sum` over
`account.balance`) whose service wires no `sourceFieldMeta`.
## Docs
`content/docs/deployment/validating-metadata.mdx:216-217`. Old: "The
analytics service refuses the same pair with `400 DATASET_INVALID` when
a query is built; this is the identical verdict," New: "The analytics
service refuses the same pair with `400 DATASET_INVALID` when a query is
built — or, for a field reached through a relationship path, with `400
INVALID_FIELD` when the query runs, judged on the object the path
reaches; this is the identical verdict,". The lint rule on that page
resolves relationship paths, and the compile check does not, so the pair
is now refused one door later with the member-level code. No other
sentence under `content/docs/**` (outside `releases/` and `references/`)
speaks about the type of a measure over a related field.
## Open question (not decided here, per the dispatch)
The ObjectQL face still refuses a relationship-path pair the table
**accepts** (`max` over `account.revenue`) as a cross-object measure:
the engine aggregate cannot join. The native face serves it. The two
faces now agree on every refused pair and disagree only on this
capability, and the refusal names its remedy (run on a native-SQL
driver). The four-axis options are in the dev report on #21129.
## Acceptance notes
- **Route pin not added.** The dispatcher relays this door's envelope
generically, and
`packages/runtime/src/analytics-cube-measure-field-type-door.test.ts`
already pins that relay for this door. The route-level readings above
were taken through the real route.
- **Dataset compile check.** `assertAggregateFieldTypeCompatible`
(`dataset-compiler.ts`) still returns early on a dotted field, so such a
dataset is refused when its query runs (`INVALID_FIELD`), not at compile
(`DATASET_INVALID`). It is refused loudly before anything is read, so
this is not a defect, and it is not changed here.
- **The changeset's direction.** `Clause-②: no (narrowing)`, as claim
revision `5939237083` sets it, in this body and in the changeset: the
change narrows the native face's accept set, the same class as #21044.
The changeset also carries a `**BREAKING**` banner and a `!` summary;
`check-adr-0087-registration` reads `[BREAKING+bang+clause-②-narrowing]`
with an `already-registered` disposition. The package is graded `minor`.
- **Same-family observation, reported for the seat to file.** The #20807
/ #20912 door (`structured-json-dimension-door.ts`, `columnOf`) resolves
a relationship path through the cube's declared join only. It reads no
relationship field's declared `reference`, so it does not use the one
hop resolver. A cube that reaches a related `json` field only through
the lookup's `reference` gets 500 `DATABASE_ERROR` on PostgreSQL when
grouping by it or taking `count_distinct` over it on the native face. On
SQLite it answers 200 (one group per serialized document). The
declared-join control answers 400 `INVALID_FIELD`. The measurement is in
the dev report. Not touched here: the file is outside this card's
surface.
## Local verification (at `17e58d67`, after merging `origin/main`
`d6d6e872` and refreshing install and build)
- `pnpm --filter @objectstack/service-analytics exec vitest run
--maxWorkers=2` with `OS_TEST_POSTGRES_URL` (private PostgreSQL 16.14):
**163 files, 3799 passed**.
- `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc
--listFiles` lists all three touched test files.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 91 families, all **exit 0**. Two of them
(`check:skill-examples`, `check:dual-build-cjs-loads`) first answered
exit 3 (PREREQUISITE NOT MET, no `dist/`) and were re-run after a full
turbo build. `--ran`: "91 derived famil(ies) accounted for — 91 run, 0
NOT-MEASURED (a DERIVED zero — all 91 recorded an exit code and none of
them is 3)".
- ESLint, a proven narrowing at `17e58d67`: `eslint --no-inline-config
--format json` over the 7 touched `.ts` files reads 7 files, 0 errors, 0
warnings. The population comes from `eslint.config.mjs`
(`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`).
Invariance: the config never enables type-aware linting (no
`parserOptions.project` or `projectService`), so this diff cannot move a
verdict on an untouched file.
- NOT MEASURED: MySQL (no server here), `packages/qa/dogfood` (CI's
Dogfood Regression Gate), and the full `rest` / `runtime` suites (CI).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 95e24b0 commit 3a7b6eb
9 files changed
Lines changed: 589 additions & 68 deletions
File tree
- .changeset
- content/docs/deployment
- packages/services/service-analytics/src
- __tests__
- strategies
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
217 | | - | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
218 | 220 | | |
219 | 221 | | |
220 | 222 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
470 | 470 | | |
471 | 471 | | |
472 | 472 | | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
473 | 482 | | |
474 | 483 | | |
475 | 484 | | |
476 | | - | |
477 | | - | |
478 | | - | |
479 | | - | |
480 | | - | |
481 | | - | |
482 | | - | |
483 | | - | |
484 | | - | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
485 | 498 | | |
486 | | - | |
| 499 | + | |
487 | 500 | | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
488 | 519 | | |
489 | 520 | | |
490 | 521 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
369 | 369 | | |
370 | 370 | | |
371 | 371 | | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
372 | 375 | | |
373 | 376 | | |
374 | 377 | | |
375 | 378 | | |
376 | | - | |
| 379 | + | |
377 | 380 | | |
378 | 381 | | |
379 | 382 | | |
| |||
382 | 385 | | |
383 | 386 | | |
384 | 387 | | |
| 388 | + | |
385 | 389 | | |
386 | 390 | | |
387 | 391 | | |
| |||
0 commit comments