Skip to content

Commit bb2eccf

Browse files
fix(service-analytics)!: the analytics door refuses a multi-value grouping and a JSON-stored count_distinct, judged on the declaration, as the engine does (#21019)
Fixes #20912 Clause-②: no (narrowing) ## What changes The analytics door refuses what the engine's aggregate door refuses, ahead of both strategies, naming the member the caller wrote: - a **grouped dimension on a multi-value field** (`multiselect`, `checkboxes`, `tags`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` declared `multiple: true`), `INVALID_FIELD` / 400; - a **`count_distinct` measure over a JSON-stored field** (the compatibility table's `count_distinct` row refuses the type, or the declaration is a list), `INVALID_FIELD` / 400. An authored cube measure, a suffix-inferred `FIELD_count_distinct` and a compiled dataset's are judged alike; - and the dataset compiler's measure door **reads the declaration**: a dataset `count_distinct` over a base-object field declared `multiple: true` is refused `DATASET_INVALID` / 400 at compile time (registration and the request door), beside the type row that already refused `tags`. The predicates are `@objectstack/spec/data`'s, the ones the engine's two doors read: `isMultiValueField`, `STRUCTURED_JSON_TYPES`, and `isAggregateCompatibleWithFieldType('count_distinct', …)`. No copy of the rule. Both doors receive the declaration `{ type, multiple }` (`ValueShapeFieldDef`, the shape `declaredValueShape` already builds), never the type alone: a type-only door would refuse `tags` and serve a `select` with `multiple: true`, which is the same JSON column. Files: - `structured-json-dimension-door.ts` (the door #20807 landed): the multi-value class beside the structured-JSON one for grouped members, and a second walk over `query.measures` for `count_distinct`. The #20807 structured-JSON words are byte-identical. - `dataset-compiler.ts`: `assertAggregateFieldTypeCompatible` asks `isMultiValueField` beside the table's row, for `count_distinct` only (the `sum` / `avg` / `min` / `max` rows accept no multi-capable type, and `count` reads no value). `DatasetCompileOptions.declaredFieldType` becomes `declaredValueShape`, answering `{ type, multiple }`. - `analytics-service.ts`: exactly the two call sites the seat's surface decision names. The lambda `assertDimensionsGroupScalarColumns` hands the door, and `compile()`'s probe handed to `compileDataset`, each pass `{ type, multiple }` instead of `?.type`. Nothing else in that file changes. ## The three rows, measured before and after Through `AnalyticsService` as `AnalyticsServicePlugin` composes it over a real ObjectQL engine and `SqlDriver`, on SQLite (better-sqlite3) and a live PostgreSQL 16.13 started for this run. Four rows: `status` a, a, b, b (single-value select, the control); `tags_f` (tags) [a,b], [a,b], [b], [ab]; `ms` (multiselect) and `multi_sel` (select, `multiple: true`) [a,b], [a,b], [b], [b]; `meta` (json) two equal documents of four. Before is `origin/main` `8d329f02`; after is this branch at `d53f230cdb`. | input | strategy | before, SQLite | before, PostgreSQL 16.13 | after, both dialects | |:--|:--|:--|:--|:--| | row 1: cube dimension `tags_f` / `ms` / `multi_sel` | NativeSQL | 200, one group per serialized array | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD`, member named | | row 1: same | ObjectQL | 400 `INVALID_FIELD`, engine's `groupBy[0]` | same | 400 `INVALID_FIELD`, member named | | row 1: dataset dimension over `tags_f` / `multi_sel` | NativeSQL | 200, one group per serialized array | 500 | 400 `INVALID_FIELD`, dataset dimension named | | row 2: `meta_count_distinct` / `tags_f_count_distinct` / `multi_sel_count_distinct`, ad-hoc and authored cube | NativeSQL | 200: 2 / 3 / 2 | 500 | 400 `INVALID_FIELD`, measure named | | row 2: same | ObjectQL | 400 `INVALID_FIELD`, engine's `aggregations[0].field` | same | 400 `INVALID_FIELD`, measure named | | row 3: dataset `count_distinct` over `multi_sel` | NativeSQL | registers, then 200, 2 | registers, then 500 | 400 `DATASET_INVALID` at registration | | row 3: same | ObjectQL | registers, then 400 `INVALID_FIELD` at query time | same | 400 `DATASET_INVALID` at registration | | controls: dimension `status`, `status_count_distinct`, dataset `count_distinct` over `status` | both | 200 | 200 | 200, unchanged | On the ObjectQL face the code and status were already right; what changes there is that the refusal names the member the caller wrote, which is the fold-in the card offers. ### The words They follow the engine's doors (verdict, "The query was NOT run.", route, reason) in the member-named form #20807 used. One deliberate difference in the multi-value **route**: it names a **record query** on the declaring object with `where { "FIELD": { "$contains": VALUE } }`, not this service's own `where`. Measured on the same base, the analytics native strategy compiles `$contains` over a JSON-stored column to `LIKE`: `where { tags_f: { $contains: 'a' } }` answered 3 on SQLite (`[ab]` counted as holding `a`; membership is 2) and 500 on PostgreSQL (`json ~~ text`), while the ObjectQL face and the engine answered 2 on both. A route through the analytics `where` would trade one wrong answer for another. That native-path `$contains` defect is outside this card and is reported for filing. ## Pins Committed red first (`f88977bf4e`), then the fix (`2c1b093a66`): - `service-analytics/src/__tests__/multi-value-json-stored-door.test.ts`: rows 1 and 2 on both strategy faces (every multi-value declaration, the member as spelled, a bucketed time dimension, a dataset dimension and a dataset measure over a declared join, the dry run), row 3 through `compileDataset` and through the service, single-value controls, and two GUARDs over every `FieldType` with and without `multiple: true` whose expected verdict is computed from the spec predicates. Red before the fix: 16 of 21 cases; the 5 controls green. - `service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts`: the card's three rows and a scalar control for each, over a real `SqlDriver`, SQLite always and PostgreSQL where `OS_TEST_POSTGRES_URL` is set. Red before: all three refusal cases on both dialects; controls green. - `rest/src/analytics-dataset-multi-value-door.test.ts`: `POST /api/v1/analytics/dataset/query` with an inline dataset, rows 1 and 3 and their controls, SQLite and PostgreSQL. Red before: both refusal cases on both dialects. That first run also caught a wrong control expectation of mine (the dataset route answers the option labels, `A` 2 and `B` 2, which it did on the base too); it was corrected before the pins commit. - `dimension-structured-json-door.test.ts`: its GUARD now expects `STRUCTURED_JSON_TYPES` or `isMultiValueField` (an inherently multi option type is judged by the door now). The cube face over HTTP (`/api/v1/analytics/query`) is served by `@objectstack/runtime`'s dispatcher, outside this card's surface; its rows are pinned at the service on both dialects. ## Ablation Run from the committed fix, each leg through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed), with a whole-script `trap` restoring by absolute path; each restore proven by blob equality with `HEAD` and an empty `git diff HEAD`. Suites: the three service pin files plus `aggregate-nontemporal-measure-refusal.test.ts` (92 cases, PostgreSQL cell on). | leg | mutation | result | |:--|:--|:--| | M1a | door call site back to `fieldMeta(o, field)?.type` | 27 red | | M1b | door call site drops `multiple` (type-only shape) | 16 red: every `multiple: true` row; `tags` still refused | | M2a | compile call site back to `?.type` | 33 red, the existing compile-door pins included | | M2b | compile call site drops `multiple` | 3 red: exactly row 3 (SQLite, PostgreSQL, compile unit) | | M3 | door's measure walk removed | 9 red: row 2 | | M4 | door's multi-value grouped class removed | 10 red: row 1 | | M5 | compiler's declaration half removed | 4 red: row 3 | | control | restored tree | 92 / 92 green | The REST pin resolves `@objectstack/service-analytics` through `dist/`, so one leg ran there: M1a, rebuilt, `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs`, the REST pin went 2 red (the dimension rows, SQLite and PostgreSQL), then restored, rebuilt, preflight `--absent` and a clean tree, 8 / 8 green. The mutated build's DTS step failed on the `?.type` revert (TS2345), so the type system also refuses that call site. ## Tests and gates, at `d53f230cdb` - `@objectstack/service-analytics`: `vitest run` 152 files, 3489 tests passed (PostgreSQL cell on); `typecheck` clean. - `@objectstack/rest`: the new pin 8 / 8 (SQLite and PostgreSQL); `typecheck` clean (`check:test-typecheck` OK). - `node scripts/pm/dispatch-gates.mjs --commands` derived 62 commands; all 62 run under the shared lock, plus the 4 roster families it marks (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). 64 exit 0. `--ran` reconciliation: 62 derived, 60 run, 2 NOT MEASURED. - `check-adr-0087-registration`: passes with `not-required (runtime-interface-only …#DatasetCompileOptions)`. `check-changeset-no-major`: no `major`. ## Acceptance notes - **NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`**, reason: both exit 3 PREREQUISITE NOT MET, needing a whole-workspace build; this run built only the tested closure (`@objectstack/rest^...`). CI builds the full tree. - **NOT MEASURED: MySQL.** The refusals happen before any SQL is built, so the dialect does not enter the verdict. - **PostgreSQL cells are un-run in CI** (no step provisions `OS_TEST_POSTGRES_URL` for these packages); the PostgreSQL 16.13 readings above are from a local server. - **Downstream suites declared to CI:** `@objectstack/runtime`, the dogfood suites and other consumers of the analytics doors were not run locally. A `git grep` of `count_distinct` in `examples/`, `packages/runtime/src`, `packages/qa` and `packages/rest/src` found one analytics use, `region_count_distinct` over a `text` field, which is not judged. - **Published interface:** `DatasetCompileOptions.declaredFieldType` is renamed `declaredValueShape` and returns `{ type, multiple }`. The changeset says this under **BREAKING** with a "Your fix" paragraph for a host calling `compileDataset` directly. Its ADR-0087 disposition is `runtime-interface-only` naming that interface, which the gate verified (no metadata surface). The fix paragraph is prose, in the same shape as the landed `20648` changeset's. - **Naming:** `assertNoStructuredJsonDimension` and `structured-json-dimension-door.ts` now also judge multi-value grouping and `count_distinct` measures. They are not renamed, because a rename would change the call in `analytics-service.ts` beyond the two granted lambdas. The module header says what it judges. - **Comments beside the two call sites** in `analytics-service.ts` still say "declared type". They are left as they are for the same reason. - **Reported for filing, not fixed here:** (1) the analytics native strategy compiles `$contains` over a JSON-stored column to `LIKE`, against the membership reading `filter.zod.ts` declares (substring on SQLite, 500 on PostgreSQL); (2) the dataset-measure lint rule `measure-aggregate-field-type-refused` judges the type only, so it passes `count_distinct` over a `multiple: true` select, which the compile leg now refuses. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78e3e3a commit bb2eccf

8 files changed

Lines changed: 1175 additions & 53 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 grouped dimension on a multi-value field and a `count_distinct` measure over a JSON-stored field are refused with `INVALID_FIELD` / 400 at the analytics door, before any SQL is built; a dataset `count_distinct` measure over a field declared `multiple: true` is refused at compile time
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (runtime-interface-only packages/services/service-analytics/src/dataset-compiler.ts#DatasetCompileOptions) a published options interface of `compileDataset`, whose one probe member is renamed and now answers the field's declaration instead of its type. It is not a Zod schema, a `packages/spec` declaration or an object definition, it is not a projection of a schema, and no metadata surface references it, so `objectstack migrate meta` has nothing to rewrite; the TypeScript host that passed the old member is told by its compiler at its own call site. The other halves refuse a QUERY shape at the analytics door (a grouping or distinct-count target) and one aggregate x declaration pair at the dataset compile door: no authorable key, spelling or stored row moves (`CubeSchema`, `DatasetSchema` and the analytics query body keep parsing every member), and which scalar a caller meant to group on or count 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 an analytics grouping or distinct-count target or this interface, and this diff adds none (not `registered` / `already-registered`); and the interface was concretely typed at the merge base, not erased (not `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what the analytics query doors accept, in two positions, and what the dataset compiler accepts, in one. 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 and on both strategies. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
- A cube or dataset dimension whose column is a **multi-value** field, when it groups the result (a `dimensions` entry, or a `timeDimensions` entry with a `granularity`): an inherently multi option type (`multiselect`, `checkboxes`, `tags`), or a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared `multiple: true`. The same types without the flag are served.
14+
- A `measures` entry that resolves to a `count_distinct` measure whose column is **JSON-stored**: a structured-JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), an inherently multi option type, or a multi-capable field declared `multiple: true`. An authored cube measure, a suffix-inferred one (`tags_count_distinct`) and a compiled dataset's are judged alike; a `count` measure is not judged.
15+
- A dataset measure that pairs `count_distinct` with a base-object field declared `multiple: true` is refused `400 DATASET_INVALID` when the dataset compiles, at registration and on the request door, beside the type row that already refused `tags`.
16+
17+
The column is judged where it is declared: on the cube's object, or, for a dotted path, on the object the cube's declared join names.
18+
19+
**What an author sees now.** `400 INVALID_FIELD`, naming the member as the request wrote it, the column, the object, the declaration (`select with multiple: true`), saying the query was not run, and naming the route. For a multi-value field the route is a record query on the declaring object filtered by one member with `$contains`, one query per member. For a structured-JSON field it is to store the scalar part in a field of its own. The thrown error carries `member`, `param` (`dimensions`, `timeDimensions` or `measures`), `cube`, `field` and `object`. The dataset compile refusal names the measure, the field and its declaration with `multiple: true`.
20+
21+
**Why a refusal.** The engine's aggregate door already refuses both shapes, and the native-SQL strategy compiled its own statement and never reached it. Measured through this service as `AnalyticsServicePlugin` composes it over a real engine: a dimension on a `tags`, `multiselect` or `multiple: true` select answered 200 with one group per serialized array on SQLite and 500 `DATABASE_ERROR` on PostgreSQL 16; an inferred `count_distinct` over a `json`, `tags` or `multiple: true` select field answered 2, 3 and 2 on SQLite and 500 on PostgreSQL; a dataset `count_distinct` over the `multiple: true` select registered, then answered 2 on SQLite and 500 on PostgreSQL. The engine-aggregate strategy answered 400 for every one of these, under the engine's position (`groupBy[0]`, `aggregations[0].field`) rather than the member the caller wrote. The predicates are `@objectstack/spec/data`'s, the ones the engine's doors read: `isMultiValueField`, and the aggregate × field-type table's `count_distinct` row.
22+
23+
**Your fix (a host calling `compileDataset` directly).** `DatasetCompileOptions` no longer has `declaredFieldType`. Pass `declaredValueShape` instead: the same read of the field's metadata, answering `{ type, multiple }` (the type, and `multiple === true`) rather than the type alone, or `undefined` when nothing answers. A host that passes neither compiles exactly as before, with no aggregate × field-type refusal at all. `AnalyticsService` and `AnalyticsServicePlugin` wire it themselves from `sourceFieldMeta`.
24+
25+
**Who is affected.** A dashboard, report or caller that grouped by a multi-value field, or counted a JSON-stored field distinct, through the native-SQL strategy on SQLite and read the serialized arrays or the text-compared count as real answers. On PostgreSQL the same queries were already a 500. A dataset that pairs `count_distinct` with a `multiple: true` field no longer registers.
26+
27+
**Unchanged.** A dimension or `count_distinct` on a scalar-stored field, a single-value `select` or `lookup` included; `count` over any field; a member naming a column the object does not have, which keeps its existing `INVALID_FIELD` answer first; a dotted path the cube declares no join for; a measure whose `sql` is an expression; and a host that wires no `sourceFieldMeta`, where the declaration cannot be read.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A dataset dimension on a MULTI-VALUE field, and a dataset `count_distinct`
5+
* measure over a `select` declared `multiple: true`, are refused at the
6+
* dataset door — `POST /api/v1/analytics/dataset/query` answers 400 before
7+
* any SQL is built — over a real `SqlDriver`; the single-value controls are
8+
* served unchanged.
9+
*
10+
* The cube face (`POST /api/v1/analytics/query`, an inferred
11+
* `FIELD_count_distinct` included) is served by `@objectstack/runtime`'s
12+
* dispatcher, not by this package; its rows are pinned at the service, on the
13+
* same two dialects, in
14+
* `packages/services/service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts`.
15+
*
16+
* ## Measured without the refusal, through this door
17+
*
18+
* Four rows: `status` a, a, b, b; `tags_f` [a,b], [a,b], [b], [ab];
19+
* `multi_sel` (a `select` with `multiple: true`) [a,b], [a,b], [b], [b]:
20+
*
21+
* | the inline dataset's selection | SQLite | PostgreSQL 16 |
22+
* |:--|:--|:--|
23+
* | dimension `status_dim` (single-value select, the control) | 200, `A` 2 · `B` 2 (the option labels) | same |
24+
* | dimension `tag_dim` (`tags`) / `picked_dim` (`multi_sel`) | 200, one group per serialized array | 500 |
25+
* | measure `count_distinct` over `multi_sel` | 200, 2 | 500 |
26+
* | measure `count_distinct` over `status` (the control) | 200, 2 | same |
27+
*
28+
* ## The composition, and the dialect axis of THIS file
29+
*
30+
* The analytics service is the one `AnalyticsServicePlugin` composes over a
31+
* real `ObjectQL` engine — both auto-bridges live, so `NativeSQLStrategy`
32+
* answers on a SQL driver. The SQLite cell always runs. The PostgreSQL cell
33+
* runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no
34+
* CI step provisions that variable for this package, so the live cell is
35+
* red-capable and un-run in CI. The live cell owns its table, dropped before
36+
* and after.
37+
*/
38+
39+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
40+
import { ObjectQL } from '@objectstack/objectql';
41+
import { SqlDriver } from '@objectstack/driver-sql';
42+
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';
43+
import { RestServer } from './rest-server';
44+
45+
const OBJECT = 'rest_dataset_multi_value_ledger';
46+
const OPTIONS = [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }];
47+
48+
const LEDGER = {
49+
name: OBJECT,
50+
label: 'Dataset multi-value door ledger',
51+
fields: {
52+
status: { name: 'status', type: 'select' as const, options: OPTIONS },
53+
tags_f: { name: 'tags_f', type: 'tags' as const },
54+
multi_sel: { name: 'multi_sel', type: 'select' as const, multiple: true, options: OPTIONS },
55+
},
56+
};
57+
58+
const ROWS = [
59+
{ id: 'r1', status: 'a', tags_f: ['a', 'b'], multi_sel: ['a', 'b'] },
60+
{ id: 'r2', status: 'a', tags_f: ['a', 'b'], multi_sel: ['a', 'b'] },
61+
{ id: 'r3', status: 'b', tags_f: ['b'], multi_sel: ['b'] },
62+
{ id: 'r4', status: 'b', tags_f: ['ab'], multi_sel: ['b'] },
63+
];
64+
65+
/** The inline dataset the request carries — as a Studio preview or a widget posts it. */
66+
const dataset = (measures: unknown[]) => ({
67+
name: 'multi_value_inline',
68+
label: 'Multi-value inline',
69+
object: OBJECT,
70+
dimensions: [
71+
{ name: 'status_dim', field: 'status', type: 'string' },
72+
{ name: 'tag_dim', field: 'tags_f', type: 'string' },
73+
{ name: 'picked_dim', field: 'multi_sel', type: 'string' },
74+
],
75+
measures,
76+
});
77+
const ROW_COUNT = [{ name: 'row_count', aggregate: 'count' }];
78+
79+
interface Cell {
80+
id: 'sqlite' | 'pg';
81+
label: string;
82+
env: string | null;
83+
config: () => Record<string, unknown> | null;
84+
}
85+
86+
const CELLS: readonly Cell[] = [
87+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
88+
{
89+
id: 'pg',
90+
label: 'live postgres',
91+
env: 'OS_TEST_POSTGRES_URL',
92+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
93+
},
94+
];
95+
96+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
97+
98+
function createMockServer() {
99+
const noop = () => {};
100+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
101+
}
102+
103+
function mockProtocol() {
104+
return {
105+
getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }),
106+
getMetaTypes: async () => [],
107+
getMetaItems: async () => [],
108+
};
109+
}
110+
111+
function makeRes() {
112+
const res: any = {
113+
statusCode: 200,
114+
body: undefined as any,
115+
header: () => res,
116+
status: (code: number) => { res.statusCode = code; return res; },
117+
json: (body: unknown) => { res.body = body; return res; },
118+
end: () => res,
119+
};
120+
return res;
121+
}
122+
123+
for (const cell of CELLS) {
124+
const config = cell.config();
125+
describe.skipIf(!config)(
126+
`a multi-value dataset dimension or distinct count at POST /api/v1/analytics/dataset/query — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
127+
() => {
128+
let driver: any;
129+
let engine: ObjectQL;
130+
/** Raw-SQL statements and engine aggregates that read THIS object. */
131+
const reads = { rawSql: 0, aggregate: 0 };
132+
let query: (measures: unknown[], selection: Record<string, unknown>) => Promise<{ status: number; body: any }>;
133+
134+
const dropTable = async () => {
135+
if (cell.id === 'sqlite') return;
136+
await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
137+
};
138+
139+
beforeAll(async () => {
140+
driver = new SqlDriver(config as any);
141+
await dropTable();
142+
engine = new ObjectQL({ logger: quiet } as any);
143+
engine.registerDriver(driver, true);
144+
await engine.init();
145+
engine.registry.registerObject(LEDGER as any);
146+
await engine.syncSchemas();
147+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
148+
149+
const realExecute = (engine as any).execute.bind(engine);
150+
(engine as any).execute = (sql: unknown, opts?: { object?: string }) => {
151+
if (opts?.object === OBJECT) reads.rawSql += 1;
152+
return realExecute(sql, opts);
153+
};
154+
const realAggregate = engine.aggregate.bind(engine);
155+
(engine as any).aggregate = (object: string, ...rest: unknown[]) => {
156+
if (object === OBJECT) reads.aggregate += 1;
157+
return (realAggregate as any)(object, ...rest);
158+
};
159+
160+
// The plugin's own composition over the real engine: both auto-bridges.
161+
const registered: Record<string, unknown> = {};
162+
await new AnalyticsServicePlugin().init({
163+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
164+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
165+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
166+
hook: () => {},
167+
logger: quiet,
168+
} as never);
169+
const service = registered.analytics as AnalyticsService;
170+
171+
const rest = new RestServer(
172+
createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
173+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
174+
undefined, undefined, undefined, undefined,
175+
async () => service,
176+
);
177+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
178+
rest.registerRoutes();
179+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query');
180+
expect(route).toBeDefined();
181+
query = async (measures, selection) => {
182+
const res = makeRes();
183+
// What the wire carries: JSON.
184+
const body = JSON.parse(JSON.stringify({ dataset: dataset(measures), selection }));
185+
await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res);
186+
return { status: res.statusCode, body: res.body };
187+
};
188+
});
189+
190+
afterAll(async () => {
191+
await dropTable();
192+
try { await engine?.destroy(); } catch { /* noop */ }
193+
});
194+
195+
it('a dataset dimension on a multi-value field answers 400 INVALID_FIELD naming the dataset dimension — no statement reaches the engine', async () => {
196+
const before = { ...reads };
197+
for (const [member, column, declared] of [
198+
['tag_dim', 'tags_f', 'tags'],
199+
['picked_dim', 'multi_sel', 'select with multiple: true'],
200+
] as const) {
201+
const res = await query(ROW_COUNT, { measures: ['row_count'], dimensions: [member] });
202+
expect(res.status, JSON.stringify(res.body)).toBe(400);
203+
expect(res.body.code, member).toBe('INVALID_FIELD');
204+
expect(String(res.body.message), member).toContain(
205+
`Dimension '${member}' on cube 'multi_value_inline' groups by field '${column}', which object '${OBJECT}' declares as ${declared} — a multi-value field`,
206+
);
207+
}
208+
expect(reads, 'no raw SQL and no engine aggregate for the object').toEqual(before);
209+
});
210+
211+
it('CONTROL a single-value select dimension is served unchanged: one group per option, counted under its label', async () => {
212+
const res = await query(ROW_COUNT, { measures: ['row_count'], dimensions: ['status_dim'] });
213+
expect(res.status, JSON.stringify(res.body)).toBe(200);
214+
const groups = (res.body.rows as Array<{ status_dim: string; row_count: number | string }>)
215+
.map((r) => [r.status_dim, Number(r.row_count)] as const)
216+
.sort(([a], [b]) => a.localeCompare(b));
217+
expect(groups).toEqual([['A', 2], ['B', 2]]);
218+
});
219+
220+
it('a dataset count_distinct over a select with multiple: true answers 400 DATASET_INVALID — no statement reaches the engine', async () => {
221+
const before = { ...reads };
222+
const res = await query(
223+
[{ name: 'picked_kinds', aggregate: 'count_distinct', field: 'multi_sel' }],
224+
{ measures: ['picked_kinds'] },
225+
);
226+
expect(res.status, JSON.stringify(res.body)).toBe(400);
227+
expect(res.body.code).toBe('DATASET_INVALID');
228+
expect(String(res.body.message)).toContain('declares as `select` with `multiple: true`');
229+
expect(reads, 'no raw SQL and no engine aggregate for the object').toEqual(before);
230+
});
231+
232+
it('CONTROL a dataset count_distinct over a single-value select is served', async () => {
233+
const res = await query(
234+
[{ name: 'status_kinds', aggregate: 'count_distinct', field: 'status' }],
235+
{ measures: ['status_kinds'] },
236+
);
237+
expect(res.status, JSON.stringify(res.body)).toBe(200);
238+
expect(Number(res.body.rows[0].status_kinds)).toBe(2);
239+
});
240+
},
241+
);
242+
}

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@
2121
* (inferred) cube, and a bucketed time dimension;
2222
* - no statement and no aggregate reaches the host bridges on a refusal;
2323
* - GUARD: the judged types are exactly `@objectstack/spec/data`'s
24-
* `STRUCTURED_JSON_TYPES`, over every `FieldType` — the predicate the
25-
* engine's door reads, never a second list.
24+
* `STRUCTURED_JSON_TYPES` and `isMultiValueField`, over every `FieldType` —
25+
* the predicates the engine's door reads, never a second list.
2626
*/
2727

2828
import { describe, it, expect, vi } from 'vitest';
2929
import type { Cube } from '@objectstack/spec/data';
30-
import { FieldType, STRUCTURED_JSON_TYPES } from '@objectstack/spec/data';
30+
import { FieldType, STRUCTURED_JSON_TYPES, isMultiValueField } from '@objectstack/spec/data';
3131
import type { Dataset } from '@objectstack/spec/ui';
3232
import type { AnalyticsQuery } from '@objectstack/spec/contracts';
3333
import { AnalyticsService } from '../analytics-service.js';
@@ -246,7 +246,11 @@ describe('what the door does not judge', () => {
246246
expect(calls.raw).toHaveLength(1);
247247
});
248248

249-
it('GUARD the judged types are exactly the spec\'s STRUCTURED_JSON_TYPES, over every FieldType', async () => {
249+
it('GUARD the judged types are exactly the spec\'s STRUCTURED_JSON_TYPES and isMultiValueField, over every FieldType', async () => {
250+
// The multi-value class joined the door with the engine's: an inherently
251+
// multi option type (`multiselect`, `checkboxes`, `tags`) is judged with or
252+
// without the flag. The flagged half is pinned in
253+
// `multi-value-json-stored-door.test.ts`.
250254
const { service } = makeService('native');
251255
for (const type of FieldType.options) {
252256
const query: AnalyticsQuery = { cube: OBJECT, measures: ['count'], dimensions: [`f_${type}`] };
@@ -255,7 +259,9 @@ describe('what the door does not judge', () => {
255259
(e: Refusal) => ({ code: e.code, status: e.status, member: e.member }),
256260
);
257261
expect(verdict, type).toEqual(
258-
STRUCTURED_JSON_TYPES.has(type) ? { code: 'INVALID_FIELD', status: 400, member: `f_${type}` } : null,
262+
STRUCTURED_JSON_TYPES.has(type) || isMultiValueField({ type })
263+
? { code: 'INVALID_FIELD', status: 400, member: `f_${type}` }
264+
: null,
259265
);
260266
}
261267
});

0 commit comments

Comments
 (0)