Skip to content

Commit 46ee85e

Browse files
committed
test(service-analytics,runtime,rest): pin a structured-JSON dimension refused at the analytics door (red)
Pins first, before the fix. On the base they are red: - POST /api/v1/analytics/query with a cube or dataset dimension on a json field answers 200 with one group per serialized document on SQLite and 500 DATABASE_ERROR on PostgreSQL 16; /analytics/sql builds the statement. - POST /api/v1/analytics/dataset/query answers the same two ways. - The service door pins (both strategy faces, every structured-JSON type, the member as written, the one-predicate GUARD) are red; their controls are green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 793fb83 commit 46ee85e

3 files changed

Lines changed: 708 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A dataset dimension on a structured-JSON field is refused at the dataset
5+
* door — `POST /api/v1/analytics/dataset/query` answers `400 INVALID_FIELD`,
6+
* naming the dataset dimension the selection wrote, before any SQL is built —
7+
* over a real `SqlDriver`; and a `text` dimension (the control) is served
8+
* unchanged.
9+
*
10+
* The cube face of the same door (`POST /api/v1/analytics/query`, served by
11+
* `@objectstack/runtime`'s dispatcher, not by this package) is pinned in
12+
* `packages/runtime/src/analytics-json-dimension-door.test.ts`. This file is
13+
* the route this package serves: an INLINE dataset, compiled per request,
14+
* whose dimensions reach the same cube query through `DatasetExecutor`.
15+
*
16+
* ## Measured on the base, through this door
17+
*
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`:
20+
*
21+
* | `selection.dimensions` | SQLite | PostgreSQL 16 |
22+
* |:--|:--|:--|
23+
* | `title_dim` (text, the control) | 200, `x` 2 · `y` 1 | same |
24+
* | `meta_doc` (json) | 200, one group per serialized document (3) | 500 |
25+
*
26+
* ## The composition, and the dialect axis of THIS file
27+
*
28+
* The analytics service is the one `AnalyticsServicePlugin` composes over a
29+
* real `ObjectQL` engine — both auto-bridges live, so `NativeSQLStrategy`
30+
* answers on a SQL driver. The SQLite cell always runs. The PostgreSQL cell
31+
* runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no
32+
* CI step provisions that variable for this package, so the live cell is
33+
* red-capable and un-run in CI, and the PR that landed this file carries its
34+
* local PostgreSQL 16 run. The live cell owns its table, dropped before and
35+
* after.
36+
*/
37+
38+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
39+
import { ObjectQL } from '@objectstack/objectql';
40+
import { SqlDriver } from '@objectstack/driver-sql';
41+
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';
42+
import { RestServer } from './rest-server';
43+
44+
const OBJECT = 'rest_dataset_json_dim_ledger';
45+
46+
const LEDGER = {
47+
name: OBJECT,
48+
label: 'Dataset JSON dimension ledger',
49+
fields: {
50+
title: { name: 'title', type: 'text' as const },
51+
meta: { name: 'meta', type: 'json' as const },
52+
},
53+
};
54+
55+
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 } },
59+
];
60+
61+
/** The inline dataset the request carries — as a Studio preview or a widget posts it. */
62+
const DATASET = {
63+
name: 'json_dim_inline',
64+
label: 'JSON dimension inline',
65+
object: OBJECT,
66+
dimensions: [
67+
{ name: 'title_dim', field: 'title', type: 'string' },
68+
{ name: 'meta_doc', field: 'meta', type: 'string' },
69+
],
70+
measures: [{ name: 'row_count', aggregate: 'count' }],
71+
};
72+
73+
/** The route the refusal prescribes — asserted on the wire body. */
74+
const ROUTE = 'Group by a field that stores one scalar value: store the part you group on in a field of its own and group by that field.';
75+
76+
interface Cell {
77+
id: 'sqlite' | 'pg';
78+
label: string;
79+
env: string | null;
80+
config: () => Record<string, unknown> | null;
81+
}
82+
83+
const CELLS: readonly Cell[] = [
84+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
85+
{
86+
id: 'pg',
87+
label: 'live postgres',
88+
env: 'OS_TEST_POSTGRES_URL',
89+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
90+
},
91+
];
92+
93+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
94+
95+
function createMockServer() {
96+
const noop = () => {};
97+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
98+
}
99+
100+
function mockProtocol() {
101+
return {
102+
getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }),
103+
getMetaTypes: async () => [],
104+
getMetaItems: async () => [],
105+
};
106+
}
107+
108+
function makeRes() {
109+
const res: any = {
110+
statusCode: 200,
111+
body: undefined as any,
112+
header: () => res,
113+
status: (code: number) => { res.statusCode = code; return res; },
114+
json: (body: unknown) => { res.body = body; return res; },
115+
end: () => res,
116+
};
117+
return res;
118+
}
119+
120+
for (const cell of CELLS) {
121+
const config = cell.config();
122+
describe.skipIf(!config)(
123+
`a dataset dimension on a json field at POST /api/v1/analytics/dataset/query — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
124+
() => {
125+
let driver: any;
126+
let engine: ObjectQL;
127+
/** Raw-SQL statements and engine aggregates that read THIS object. */
128+
const reads = { rawSql: 0, aggregate: 0 };
129+
let query: (selection: Record<string, unknown>) => Promise<{ status: number; body: any }>;
130+
131+
const dropTables = async () => {
132+
if (cell.id === 'sqlite') return;
133+
await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
134+
};
135+
136+
beforeAll(async () => {
137+
driver = new SqlDriver(config as any);
138+
await dropTables();
139+
engine = new ObjectQL({ logger: quiet } as any);
140+
engine.registerDriver(driver, true);
141+
await engine.init();
142+
engine.registry.registerObject(LEDGER as any);
143+
await engine.syncSchemas();
144+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
145+
146+
const realExecute = (engine as any).execute.bind(engine);
147+
(engine as any).execute = (sql: unknown, opts?: { object?: string }) => {
148+
if (opts?.object === OBJECT) reads.rawSql += 1;
149+
return realExecute(sql, opts);
150+
};
151+
const realAggregate = engine.aggregate.bind(engine);
152+
(engine as any).aggregate = (object: string, ...rest: unknown[]) => {
153+
if (object === OBJECT) reads.aggregate += 1;
154+
return (realAggregate as any)(object, ...rest);
155+
};
156+
157+
// The plugin's own composition over the real engine: both auto-bridges.
158+
const registered: Record<string, unknown> = {};
159+
await new AnalyticsServicePlugin().init({
160+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
161+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
162+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
163+
hook: () => {},
164+
logger: quiet,
165+
} as never);
166+
const analytics = registered.analytics as AnalyticsService;
167+
168+
const rest = new RestServer(
169+
createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
170+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
171+
undefined, undefined, undefined, undefined,
172+
async () => analytics,
173+
);
174+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
175+
rest.registerRoutes();
176+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query');
177+
expect(route).toBeDefined();
178+
query = async (selection) => {
179+
const res = makeRes();
180+
// What the wire carries: JSON.
181+
const body = JSON.parse(JSON.stringify({ dataset: DATASET, selection }));
182+
await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res);
183+
return { status: res.statusCode, body: res.body };
184+
};
185+
});
186+
187+
afterAll(async () => {
188+
await dropTables();
189+
try { await engine?.destroy(); } catch { /* noop */ }
190+
});
191+
192+
it('a dataset dimension on a json field answers 400 INVALID_FIELD naming the dataset dimension — no statement reaches the engine', async () => {
193+
const before = { ...reads };
194+
const res = await query({ measures: ['row_count'], dimensions: ['meta_doc'] });
195+
expect(res.status, JSON.stringify(res.body)).toBe(400);
196+
expect(res.body.code).toBe('INVALID_FIELD');
197+
expect(String(res.body.message)).toContain(`Dimension 'meta_doc' on cube '${DATASET.name}' groups by field 'meta'`);
198+
expect(String(res.body.message)).toContain(`'${OBJECT}' declares as json`);
199+
expect(String(res.body.message)).toContain(ROUTE);
200+
expect(reads, 'no raw SQL and no engine aggregate for the object').toEqual(before);
201+
});
202+
203+
it('CONTROL a text dataset dimension is served unchanged: one group per value, counted', async () => {
204+
const before = { ...reads };
205+
const res = await query({ measures: ['row_count'], dimensions: ['title_dim'] });
206+
expect(res.status, JSON.stringify(res.body)).toBe(200);
207+
const groups = (res.body.rows as Array<{ title_dim: string; row_count: number | string }>)
208+
.map((r) => [r.title_dim, Number(r.row_count)] as const)
209+
.sort(([a], [b]) => a.localeCompare(b));
210+
expect(groups).toEqual([['x', 2], ['y', 1]]);
211+
expect(reads.rawSql - before.rawSql, 'the native strategy answered').toBeGreaterThanOrEqual(1);
212+
});
213+
},
214+
);
215+
}

0 commit comments

Comments
 (0)