Skip to content

Commit 075a463

Browse files
committed
fix(service-analytics): judge a dotted dimension on the object its declared join names; changeset
A dataset dimension over an included relationship (`account.hq`) reached NativeSQLStrategy unjudged: measured, SQLite 200 with one group per document and PostgreSQL 500, like a base-object one. The door now reads the column the way the strategy compiles it: a bare identifier on the cube's object, a dotted identifier path on the object the cube's declared join for that path names. A path with no declared join is a synthetic traversal and stays unjudged. Pins: the service door and the dataset door gain the joined case and its text control. Changeset: service-analytics minor, BREAKING, Clause-② no (narrowing), ADR-0087 not-required (no-migration-prescription). Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1befe2 commit 075a463

5 files changed

Lines changed: 177 additions & 35 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: a cube or dataset dimension on a structured-JSON field is refused with `INVALID_FIELD` / 400 at the analytics door, before any SQL is built
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a grouping TARGET at the analytics door: a `dimensions` entry, or a bucketed `timeDimensions` entry, whose column is a declared json, composite, repeater, record, location, address or vector field. No authorable key, spelling, export or stored shape moves (the door module is internal; `@objectstack/service-analytics` exports nothing new and nothing less, and `CubeSchema`, `DatasetSchema` and the analytics query body keep parsing every member), and no stored row is read or rewritten. The grouping had no shared meaning to preserve (one group per serialized document on SQLite, a 500 on PostgreSQL), and which scalar part of the document a caller meant to group on is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a grouping target (not `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what the analytics query doors accept as a dimension. A cube dimension, or a dataset dimension, whose column is a declared field of the structured-JSON class (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) is refused before either strategy builds a statement, when it groups the result: a `dimensions` entry, or a `timeDimensions` entry with a `granularity`. The column is judged where it is declared: on the cube's object, or, for a dotted path such as a dataset dimension over `account.hq`, on the object the cube's declared join for that path names. It holds on `POST /api/v1/analytics/query`, on its dry run `POST /api/v1/analytics/sql`, and on `POST /api/v1/analytics/dataset/query`, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What an author sees now.** `400 INVALID_FIELD`, naming the member as the request wrote it (the cube dimension, or the dataset dimension), the column it groups by, the object and the column's declared type, saying the query was not run, and naming the route: group by a field that stores one scalar value, storing the part of the document you group on in a field of its own. The thrown error carries `member`, `param` (`dimensions` or `timeDimensions`), `cube`, `field` and `object`.
14+
15+
**Why a refusal.** A JSON document is no group key the SQL dialects share. Measured through `POST /api/v1/analytics/query` over three rows with a different document each, on the service `AnalyticsServicePlugin` composes over a real engine: SQLite answered 200 with one group per serialized document, and PostgreSQL 16 answered 500 `DATABASE_ERROR`. A dataset dimension over a joined object's `json` field answered the same two ways through `POST /api/v1/analytics/dataset/query`. The native-SQL strategy compiled the `GROUP BY` itself, so the engine's own refusal of a structured-JSON `groupBy` never saw the query; the engine-aggregate strategy did reach that refusal, but named the engine's `groupBy[0]` position rather than the member the caller wrote. The class is `@objectstack/spec/data`'s `STRUCTURED_JSON_TYPES`, the one the engine's refusal reads. No producer groups by such a field: no cube or dataset dimension in the example apps names one.
16+
17+
**Who is affected.** A dashboard, report or caller that grouped an analytics query by a structured-JSON field on SQLite and read one group per serialized document as real groups. On PostgreSQL the same query was already a 500.
18+
19+
**Unchanged.** A dimension on any other type; a `timeDimensions` entry with no `granularity`, which bounds a range and groups nothing; measures (this door judges only the members that group); a dotted dimension path the cube declares no join for, whose object is not a declaration; a member naming a column the object does not have, which keeps its existing `INVALID_FIELD` answer first; and a host that wires no `sourceFieldMeta`, where the column's type cannot be read.

‎packages/rest/src/analytics-dataset-json-dimension-door.test.ts‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@
1313
* the route this package serves: an INLINE dataset, compiled per request,
1414
* whose dimensions reach the same cube query through `DatasetExecutor`.
1515
*
16-
* ## Measured on the base, through this door
16+
* ## Measured without the refusal, through this door
1717
*
18-
* Three rows, `title` x, x, y, and a different `meta` document per row; a
19-
* dataset declaring `meta_doc` over the `json` field `meta`:
18+
* On the base (the `meta_doc` row), and with the refusal ablated (the
19+
* `acct_hq` row). Three rows, `title` x, x, y, and a different `meta`
20+
* document per row; a dataset declaring `meta_doc` over the `json` field
21+
* `meta`, and `acct_hq` over the `json` field `hq` of the object the
22+
* `account` lookup references:
2023
*
2124
* | `selection.dimensions` | SQLite | PostgreSQL 16 |
2225
* |:--|:--|:--|
2326
* | `title_dim` (text, the control) | 200, `x` 2 · `y` 1 | same |
2427
* | `meta_doc` (json) | 200, one group per serialized document (3) | 500 |
28+
* | `acct_hq` (`account.hq`, a json field of the `include`d object) | 200, one group per document (2) | 500 |
2529
*
2630
* ## The composition, and the dialect axis of THIS file
2731
*
@@ -42,30 +46,50 @@ import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/serv
4246
import { RestServer } from './rest-server';
4347

4448
const OBJECT = 'rest_dataset_json_dim_ledger';
49+
/** The object the ledger's `account` lookup references — joined through the dataset's `include`. */
50+
const ACCOUNT = 'rest_dataset_json_dim_account';
51+
52+
const ACCOUNT_OBJECT = {
53+
name: ACCOUNT,
54+
label: 'Dataset JSON dimension account',
55+
fields: {
56+
name: { name: 'name', type: 'text' as const },
57+
hq: { name: 'hq', type: 'json' as const },
58+
},
59+
};
4560

4661
const LEDGER = {
4762
name: OBJECT,
4863
label: 'Dataset JSON dimension ledger',
4964
fields: {
5065
title: { name: 'title', type: 'text' as const },
5166
meta: { name: 'meta', type: 'json' as const },
67+
account: { name: 'account', type: 'lookup' as const, reference: ACCOUNT },
5268
},
5369
};
5470

71+
const ACCOUNTS = [
72+
{ id: 'a1', name: 'A', hq: { city: 'Paris' } },
73+
{ id: 'a2', name: 'B', hq: { city: 'Rome' } },
74+
];
75+
5576
const ROWS = [
56-
{ id: 'r1', title: 'x', meta: { a: 1 } },
57-
{ id: 'r2', title: 'x', meta: { a: 2 } },
58-
{ id: 'r3', title: 'y', meta: { b: 1 } },
77+
{ id: 'r1', title: 'x', meta: { a: 1 }, account: 'a1' },
78+
{ id: 'r2', title: 'x', meta: { a: 2 }, account: 'a1' },
79+
{ id: 'r3', title: 'y', meta: { b: 1 }, account: 'a2' },
5980
];
6081

6182
/** The inline dataset the request carries — as a Studio preview or a widget posts it. */
6283
const DATASET = {
6384
name: 'json_dim_inline',
6485
label: 'JSON dimension inline',
6586
object: OBJECT,
87+
include: ['account'],
6688
dimensions: [
6789
{ name: 'title_dim', field: 'title', type: 'string' },
6890
{ name: 'meta_doc', field: 'meta', type: 'string' },
91+
{ name: 'acct_name', field: 'account.name', type: 'string' },
92+
{ name: 'acct_hq', field: 'account.hq', type: 'string' },
6993
],
7094
measures: [{ name: 'row_count', aggregate: 'count' }],
7195
};
@@ -130,7 +154,7 @@ for (const cell of CELLS) {
130154

131155
const dropTables = async () => {
132156
if (cell.id === 'sqlite') return;
133-
await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
157+
for (const table of [OBJECT, ACCOUNT]) await driver?.execute(`drop table if exists ${table}`).catch(() => {});
134158
};
135159

136160
beforeAll(async () => {
@@ -139,8 +163,10 @@ for (const cell of CELLS) {
139163
engine = new ObjectQL({ logger: quiet } as any);
140164
engine.registerDriver(driver, true);
141165
await engine.init();
166+
engine.registry.registerObject(ACCOUNT_OBJECT as any);
142167
engine.registry.registerObject(LEDGER as any);
143168
await engine.syncSchemas();
169+
for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any);
144170
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
145171

146172
const realExecute = (engine as any).execute.bind(engine);
@@ -200,6 +226,18 @@ for (const cell of CELLS) {
200226
expect(reads, 'no raw SQL and no engine aggregate for the object').toEqual(before);
201227
});
202228

229+
it('a dataset dimension over an included relationship\'s json field answers the same 400, naming the joined object — no statement reaches the engine', async () => {
230+
const before = { ...reads };
231+
const res = await query({ measures: ['row_count'], dimensions: ['acct_hq'] });
232+
expect(res.status, JSON.stringify(res.body)).toBe(400);
233+
expect(res.body.code).toBe('INVALID_FIELD');
234+
expect(String(res.body.message)).toContain(
235+
`Dimension 'acct_hq' on cube '${DATASET.name}' groups by field 'account.hq', whose column 'hq' the joined object '${ACCOUNT}' declares as json`,
236+
);
237+
expect(String(res.body.message)).toContain(ROUTE);
238+
expect(reads, 'no raw SQL and no engine aggregate for the object').toEqual(before);
239+
});
240+
203241
it('CONTROL a text dataset dimension is served unchanged: one group per value, counted', async () => {
204242
const before = { ...reads };
205243
const res = await query({ measures: ['row_count'], dimensions: ['title_dim'] });
@@ -209,6 +247,13 @@ for (const cell of CELLS) {
209247
.sort(([a], [b]) => a.localeCompare(b));
210248
expect(groups).toEqual([['x', 2], ['y', 1]]);
211249
expect(reads.rawSql - before.rawSql, 'the native strategy answered').toBeGreaterThanOrEqual(1);
250+
251+
const joined = await query({ measures: ['row_count'], dimensions: ['acct_name'] });
252+
expect(joined.status, JSON.stringify(joined.body)).toBe(200);
253+
const joinedGroups = (joined.body.rows as Array<{ acct_name: string; row_count: number | string }>)
254+
.map((r) => [r.acct_name, Number(r.row_count)] as const)
255+
.sort(([a], [b]) => a.localeCompare(b));
256+
expect(joinedGroups).toEqual([['A', 2], ['B', 1]]);
212257
});
213258
},
214259
);

‎packages/services/service-analytics/src/__tests__/dimension-structured-json-door.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,20 @@ const silentLogger = {
4242

4343
const OBJECT = 'ledger';
4444

45-
/** One field per `FieldType`, named `f_<type>`, plus the text control `title`. */
45+
/** One field per `FieldType`, named `f_<type>`, plus the text control `title` and a lookup to `account`. */
4646
const FIELDS: Record<string, { type: string }> = {
4747
title: { type: 'text' },
48+
account: { type: 'lookup' },
4849
...Object.fromEntries(FieldType.options.map((type) => [`f_${type}`, { type }])),
4950
};
5051

52+
/** The joined object: a text column and a json one. */
53+
const JOINED = 'account';
54+
const JOINED_FIELDS: Record<string, { type: string }> = {
55+
name: { type: 'text' },
56+
hq: { type: 'json' },
57+
};
58+
5159
/** An authored cube: a text dimension, a json one filed under a key that is not its column, and a time one. */
5260
const LEDGER_CUBE: Cube = {
5361
name: 'ledger_cube',
@@ -69,9 +77,12 @@ const LEDGER_DATASET = {
6977
name: 'ledger_ds',
7078
label: 'Ledger dataset',
7179
object: OBJECT,
80+
include: ['account'],
7281
dimensions: [
7382
{ name: 'title_dim', field: 'title', type: 'string' },
7483
{ name: 'meta_doc', field: 'f_json', type: 'string' },
84+
{ name: 'acct_name', field: 'account.name', type: 'string' },
85+
{ name: 'acct_hq', field: 'account.hq', type: 'string' },
7586
],
7687
measures: [{ name: 'row_count', aggregate: 'count' }],
7788
} as unknown as Dataset;
@@ -106,7 +117,7 @@ function makeService(face: Face, opts: { sourceFieldMeta?: boolean } = {}) {
106117
getObjectFieldNames: (n: string) => (n === OBJECT ? Object.keys(FIELDS) : undefined),
107118
...(opts.sourceFieldMeta === false
108119
? {}
109-
: { sourceFieldMeta: (o: string, f: string) => (o === OBJECT ? FIELDS[f] : undefined) }),
120+
: { sourceFieldMeta: (o: string, f: string) => (o === OBJECT ? FIELDS[f] : o === JOINED ? JOINED_FIELDS[f] : undefined) }),
110121
});
111122
return { service, calls };
112123
}
@@ -182,6 +193,17 @@ describe('a dimension on a structured-JSON field is refused at the analytics doo
182193
expect(calls.raw).toEqual([]);
183194
});
184195

196+
it('a dataset dimension over an included relationship is judged on the JOINED object — naming the dataset dimension', async () => {
197+
const { service, calls } = makeService('native');
198+
const err = await rejection(service.queryDataset(LEDGER_DATASET, { measures: ['row_count'], dimensions: ['acct_hq'] }));
199+
expect(envelopeOf(err)).toEqual({ code: 'INVALID_FIELD', status: 400, member: 'acct_hq', param: 'dimensions', field: 'account.hq', object: JOINED });
200+
expect(err.message).toContain(`Dimension 'acct_hq' on cube 'ledger_ds' groups by field 'account.hq', whose column 'hq' the joined object '${JOINED}' declares as json`);
201+
expect(calls.raw).toEqual([]);
202+
// CONTROL the joined text column is served.
203+
await service.queryDataset(LEDGER_DATASET, { measures: ['row_count'], dimensions: ['acct_name'] });
204+
expect(calls.raw).toHaveLength(1);
205+
});
206+
185207
it('an ad-hoc query (no cube registered under the object name) is refused the same way', async () => {
186208
const { service, calls } = makeService('native');
187209
const err = await rejection(service.query({ cube: OBJECT, measures: ['count'], dimensions: ['f_json'] }));
@@ -211,6 +233,13 @@ describe('what the door does not judge', () => {
211233
expect(err.message).toContain("which object 'ledger' does not have");
212234
});
213235

236+
it('a dotted path the cube declares no join for is a synthetic traversal: its object is not a declaration, so it is not judged', async () => {
237+
const { service, calls } = makeService('native');
238+
await service.generateSql({ cube: 'ledger_cube', measures: ['count'], dimensions: ['account.hq'] });
239+
await service.query({ cube: 'ledger_cube', measures: ['count'], dimensions: ['account.hq'] });
240+
expect(calls.raw).toHaveLength(1);
241+
});
242+
214243
it('a host that wires no sourceFieldMeta cannot name the type, so the door stands down', async () => {
215244
const { service, calls } = makeService('native', { sourceFieldMeta: false });
216245
await service.query({ cube: 'ledger_cube', measures: ['count'], dimensions: ['doc'] });

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,10 +2511,11 @@ export class AnalyticsService implements IAnalyticsService {
25112511
* (`structured-json-dimension-door.ts`); this method supplies the two
25122512
* answers only the service has.
25132513
*
2514-
* - The column a member groups by is {@link resolveMemberSource}'s, the
2515-
* resolver the dimension source-field gate above reads, with the same
2516-
* `'dimension'` kind the strategies' `resolveDimensionSql` /
2517-
* `resolveFieldName(…, 'dimension')` resolve by.
2514+
* - The dimension `sql` a member resolves to is {@link declaredMemberEntry}'s
2515+
* (`cube.dimensions` only, the `'dimension'` kind the strategies'
2516+
* `resolveDimensionSql` / `resolveFieldName(…, 'dimension')` resolve by),
2517+
* and the member itself when the cube declares none — the column the
2518+
* strategies group by in that case.
25182519
* - Its declared type is {@link AnalyticsServiceConfig.sourceFieldMeta}'s.
25192520
*
25202521
* Runs after the dimension source-field gate on every `ensureCube` path, so a
@@ -2531,7 +2532,11 @@ export class AnalyticsService implements IAnalyticsService {
25312532
query,
25322533
cube,
25332534
object,
2534-
(member) => resolveMemberSource(cube, member, 'dimension').source,
2535+
(member) => {
2536+
const entry = declaredMemberEntry(cube, member, 'dimension');
2537+
if (!entry) return member;
2538+
return typeof entry.sql === 'string' ? entry.sql : '';
2539+
},
25352540
(o, field) => fieldMeta(o, field)?.type,
25362541
);
25372542
}

0 commit comments

Comments
 (0)