Skip to content

Commit 3a7b6eb

Browse files
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
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: a cube measure whose `sql` is a relationship path (`account.name`) is judged by the aggregate × field-type table, described in `fields[]` and presented on the native-SQL strategy by the declaration on the object the path's last hop reaches, as a measure over the cube's own column already was
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused sum / avg pair by the second, each with its routes. This change makes the cube door read a relationship-path column's declaration on the object the path reaches, where it already read a base-object column's; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves. -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept on the native-SQL strategy, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
The column a relationship path names is located by the one hop resolver both strategies join and read it through: the cube's declared join at that path, else the relationship field's declared `reference`.
14+
15+
FROM → TO, for a `measures` entry that resolves to a cube measure over a relationship path (an authored cube measure, or a compiled dataset's measure over an `include`d relationship):
16+
17+
- `min` / `max` / `sum` / `avg` over a related field of a type the table refuses for that aggregate (the string family such as `text`, `select`, `lookup`; the JSON-stored, file and `formula` types; and the rest the table lists): FROM, on the native-SQL strategy, `200` with the related column's own value (a string such as `"zeta"`) under `fields[] { type: 'number' }` on SQLite and PostgreSQL, and for `sum` a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL; the ObjectQL strategy refused it as a cross-object measure. TO `400 INVALID_FIELD` on both strategies, before either reads anything — the refusal a base-object column of the same type already got.
18+
- `min` / `max` over a related numeric field on PostgreSQL: FROM the exact-decimal string (`"250.000000000000000000000000000000"`) under `fields[] number`. TO the number `250`.
19+
- `min` / `max` over a related `date`, `datetime` or `time` field: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`.
20+
21+
**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the path, the related object and the type it declares, saying the query was not run, and naming the types the aggregate accepts. The thrown error carries `member`, `param` (`measures`), `cube`, `field` (the path, `account.name`) and `object` (the related object that declares the column).
22+
23+
**What to write instead.** Aggregate a related field of a type the aggregate accepts, or `count` the rows. A first or last related record by a text value is a sort on a list, not an aggregate.
24+
25+
**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a related column through the native-SQL strategy and read the answer as a real one. No example app and no shipped cube or dataset authors such a pair. A dataset whose measure aggregates such a related field is now refused when its query runs (`INVALID_FIELD`), where its compile check, which reads the base object's declaration, still lets it through.
26+
27+
**Unchanged.** Every pair the table accepts; a measure over the cube's own column; `count`, and `count_distinct`, which keeps its own door; a related column the host's field metadata cannot describe; an expression `sql` or `*`; a host that wires no `sourceFieldMeta`; and the ObjectQL strategy's refusal of a related-field measure the table accepts (`max` over a related `number`), a capability limit of the engine aggregate — run that query on a native-SQL driver.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ for equality alike. The check reads the field's declaration as well as its
214214
type: a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared
215215
`multiple: true` holds a list stored as JSON, so `count_distinct` over it is
216216
refused too, although the table accepts the type. The analytics service
217-
refuses the same pair with `400 DATASET_INVALID` when a query is built; this
217+
refuses the same pair with `400 DATASET_INVALID` when a query is built — or,
218+
for a field reached through a relationship path, with `400 INVALID_FIELD` when
219+
the query runs, judged on the object the path reaches; this
218220
is the identical verdict,
219221
from the identical table, one door earlier. The rule stays silent wherever the
220222
field's type cannot be resolved (an object this stack does not define, a

‎packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -470,21 +470,52 @@ describe('#16737 — the gate stands down rather than guessing', () => {
470470
expect(result.rows.length).toBe(1);
471471
});
472472

473+
const rel = () => DatasetSchema.parse({
474+
name: 'contract_cycle_rel',
475+
label: 'Contract cycle',
476+
object: 'clm_contract',
477+
include: ['account'],
478+
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
479+
measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }],
480+
});
481+
473482
it('a RELATIONSHIP-PATH field → not judged, because the hook answers about the base object', async () => {
474483
// `account.submitted_at` is a column on `account`, not on `clm_contract`.
475484
// Judging it from `sourceFieldMeta('clm_contract', …)` would be answering
476-
// about a different column that happens to share a name.
477-
const { svc } = makeService([{ status: 'open', avg_acct: 1 }]);
478-
const rel = DatasetSchema.parse({
479-
name: 'contract_cycle_rel',
480-
label: 'Contract cycle',
481-
object: 'clm_contract',
482-
include: ['account'],
483-
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
484-
measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }],
485+
// about a different column that happens to share a name. [#21129] The hook
486+
// here answers about the base object ALONE, as this case says: the cube
487+
// door locates the column on `account`, which it cannot describe, so no
488+
// door judges the pair and its statement runs. `makeService`'s hook
489+
// answers for every object, which is the next case.
490+
const sqls: string[] = [];
491+
const svc = new AnalyticsService({
492+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
493+
executeRawSql: async (_object: string, sql: string) => {
494+
sqls.push(sql);
495+
return [{ status: 'open', avg_acct: 1 }];
496+
},
497+
sourceFieldMeta: (o: string, f: string) => (o === 'clm_contract' && FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined),
485498
});
486-
const result: any = await svc.queryDataset(rel, { dimensions: ['status'], measures: ['avg_acct'] });
499+
const result: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] });
487500
expect(result.rows.length).toBe(1);
501+
expect(sqls.length).toBe(1);
502+
});
503+
504+
it('[#21129] …and where the hook describes the joined object, the cube door judges the column there: INVALID_FIELD / 400, before any statement', async () => {
505+
// The compile check above still stands down on the dotted field; the
506+
// query the dataset runs passes the cube door, which reads the declaration
507+
// on the object the path's hop reaches (`hop-object.ts`) — here the
508+
// compiled join's `account`.
509+
const { svc, sqls } = makeService([{ status: 'open', avg_acct: 1 }]);
510+
const err: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] }).then(
511+
() => undefined,
512+
(e) => e,
513+
);
514+
expect(err?.code, err?.message).toBe('INVALID_FIELD');
515+
expect(err?.status).toBe(400);
516+
expect(err?.field).toBe('account.submitted_at');
517+
expect(err?.object).toBe('account');
518+
expect(sqls.length, 'no statement ran').toBe(0);
488519
});
489520
});
490521

‎packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,14 @@ describe('#16099 — the controls: every pair the table accepts still compiles',
369369
expect(unknown.sqls.length).toBe(1);
370370

371371
// 2. a dotted relationship path — the hook answers for the BASE object
372+
// [#21129] …and for it ALONE: the cube door reads a relationship-path
373+
// column on the object its hop reaches (`account` here), so a hook that
374+
// answered for every object would describe the column and refuse the pair.
372375
const sqls: string[] = [];
373376
const svc = new AnalyticsService({
374377
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
375378
executeRawSql: async (_o: string, sql: string) => { sqls.push(sql); return [{ status: 'open', probe_measure: 1 }]; },
376-
sourceFieldMeta: (_o: string, f: string) => (FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined),
379+
sourceFieldMeta: (o: string, f: string) => (o === 'duly_task' && FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined),
377380
} as never);
378381
const rel = DatasetSchema.parse({
379382
name: 'task_metrics_rel', label: 'Task metrics', object: 'duly_task', include: ['account'],
@@ -382,6 +385,7 @@ describe('#16099 — the controls: every pair the table accepts still compiles',
382385
});
383386
const r2: any = await svc.queryDataset(rel as never, { dimensions: ['status'], measures: ['probe_measure'] } as never);
384387
expect(r2.rows.length).toBe(1);
388+
expect(sqls.length).toBe(1);
385389

386390
// 3. no `declaredFieldType` hook at all (no data engine wired)
387391
const bare: string[] = [];

0 commit comments

Comments
 (0)