Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/21129-relationship-path-measure-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/service-analytics": minor
---

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

Clause-②: no (narrowing)

<!-- 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. -->

**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.

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`.

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):

- `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.
- `min` / `max` over a related numeric field on PostgreSQL: FROM the exact-decimal string (`"250.000000000000000000000000000000"`) under `fields[] number`. TO the number `250`.
- `min` / `max` over a related `date`, `datetime` or `time` field: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`.

**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).

**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.

**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.

**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.
4 changes: 3 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ for equality alike. The check reads the field's declaration as well as its
type: a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared
`multiple: true` holds a list stored as JSON, so `count_distinct` over it is
refused too, although the table accepts the type. The analytics service
refuses the same pair with `400 DATASET_INVALID` when a query is built; this
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,
from the identical table, one door earlier. The rule stays silent wherever the
field's type cannot be resolved (an object this stack does not define, a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,21 +470,52 @@ describe('#16737 — the gate stands down rather than guessing', () => {
expect(result.rows.length).toBe(1);
});

const rel = () => DatasetSchema.parse({
name: 'contract_cycle_rel',
label: 'Contract cycle',
object: 'clm_contract',
include: ['account'],
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }],
});

it('a RELATIONSHIP-PATH field → not judged, because the hook answers about the base object', async () => {
// `account.submitted_at` is a column on `account`, not on `clm_contract`.
// Judging it from `sourceFieldMeta('clm_contract', …)` would be answering
// about a different column that happens to share a name.
const { svc } = makeService([{ status: 'open', avg_acct: 1 }]);
const rel = DatasetSchema.parse({
name: 'contract_cycle_rel',
label: 'Contract cycle',
object: 'clm_contract',
include: ['account'],
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }],
// about a different column that happens to share a name. [#21129] The hook
// here answers about the base object ALONE, as this case says: the cube
// door locates the column on `account`, which it cannot describe, so no
// door judges the pair and its statement runs. `makeService`'s hook
// answers for every object, which is the next case.
const sqls: string[] = [];
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async (_object: string, sql: string) => {
sqls.push(sql);
return [{ status: 'open', avg_acct: 1 }];
},
sourceFieldMeta: (o: string, f: string) => (o === 'clm_contract' && FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined),
});
const result: any = await svc.queryDataset(rel, { dimensions: ['status'], measures: ['avg_acct'] });
const result: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] });
expect(result.rows.length).toBe(1);
expect(sqls.length).toBe(1);
});

it('[#21129] …and where the hook describes the joined object, the cube door judges the column there: INVALID_FIELD / 400, before any statement', async () => {
// The compile check above still stands down on the dotted field; the
// query the dataset runs passes the cube door, which reads the declaration
// on the object the path's hop reaches (`hop-object.ts`) — here the
// compiled join's `account`.
const { svc, sqls } = makeService([{ status: 'open', avg_acct: 1 }]);
const err: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] }).then(
() => undefined,
(e) => e,
);
expect(err?.code, err?.message).toBe('INVALID_FIELD');
expect(err?.status).toBe(400);
expect(err?.field).toBe('account.submitted_at');
expect(err?.object).toBe('account');
expect(sqls.length, 'no statement ran').toBe(0);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,14 @@ describe('#16099 — the controls: every pair the table accepts still compiles',
expect(unknown.sqls.length).toBe(1);

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

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