Skip to content

Commit 0b82391

Browse files
fix(service-analytics)!: a caller-named measure whose source names no field is refused at the mint, INVALID_FIELD / 400 (#21437) (#21474)
Fixes #21437 Clause-②: no (narrowing) Dispatched by the PM claim `5961286080` (PM loop round 2, `domain:services` seat 2), on triage's grade `5957198328` as amended by `5958146715`. Session `session_01DiCSbmJrkzNhuEAier4VoJ`. `inferMeasure` minted the row wildcard `'*'` for an empty prefix (`_sum` became `{ type: 'sum', sql: '*' }`), and it passed `*`, `*_sum` and the empty spelling through verbatim. The member-shape gate admits `'*'` as a column reference, so `SUM(*)` / `AVG(*)` / `COUNT(DISTINCT *)` / `SUM()` reached the database and `POST /api/v1/analytics/query` answered `500 DATABASE_ERROR` on both strategies. The mint now admits `'*'` only for the bare `count`. It refuses a source that names no field with `INVALID_FIELD` / 400, naming the spelling the caller sent, before any statement is built. ## What changes (`@objectstack/service-analytics`, `src/analytics-service.ts` only) - **`INFERRED_MEASURE_SUFFIXES`.** The suffix list `inferMeasure` iterates is hoisted to one module-level constant. It is exported from the module only, not from the package index. The enumeration pin iterates it, so a suffix added later is pinned when it lands. - **`inferMeasure(key, spelling)`.** For any key but `count`, the source is the part before the matched suffix, or the whole key when no suffix matches. A source that is empty or `'*'` is refused. No other path changes: a non-empty source is minted exactly as before. - **The envelope.** The refusal is built by `invalidMemberError`, the constructor the mint's dotted-measure refusal already uses. It carries `code: INVALID_FIELD`, `status: 400`, `member` (the spelling as sent, with any `CUBE.` qualifier), `param: 'measures'` and `cube`. That is the code and status of the missing-field refusal (`assertMeasureFields`). There is no `field`, because there is no field to name. - **Both mint sites** pass the request's own entry. They are the ad-hoc mint in `inferCubeFromQuery` and the augmentation loop in `ensureCube`. Both run inside `ensureCube`, ahead of `assertCallerMembersResolvable` and of strategy selection, on `query()` and on the dry run `generateSql()`. - **`inferredCallerMeasureSql`**, the gate's input, is exported from the module only, for the by-construction pin. - **Unchanged, per the amended ruling:** the gate's `'*'` pass-through. There is no local copy of `rowWildcardOutsideCount`, no spec export, no strategy edit and no `filter-normalizer.ts` edit (the file #21448 holds). ## Measured at the door: `POST /api/v1/analytics/query` The probe used the runtime dispatcher's composition from `packages/runtime/src/analytics-json-dimension-door.test.ts`: `AnalyticsServicePlugin` over a real `ObjectQL` engine and `SqlDriver` (SQLite), with the dispatcher-plugin route. The ObjectQL cells narrow `queryCapabilities` to the engine-aggregate path. There are three rows, amounts 100 / 300 / 1000. The authored cube declares `count` and `amount_total` only. The probe file was throwaway and is not committed. | spelling | `origin/main` `713b0fa76`: native SQL | `713b0fa76`: ObjectQL | this branch: both strategies | |---|---|---|---| | `_sum`, `_avg`, `_average`, `_min`, `_max` (ad hoc and authored, bare and `CUBE.`-qualified) | 500 `DATABASE_ERROR`, rawSql+1 | 500 `DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read | | `_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400 `INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read | | `*`, `*_sum`, `*_avg`, `CUBE.*` | 500 `DATABASE_ERROR`, rawSql+1 | 500 `DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read | | `*_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400 `INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read | | `''` (empty spelling) | 500 `DATABASE_ERROR`, rawSql+1 | 500 `DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read | | `CUBE.` (qualifier alone) | 403 `PERMISSION_DENIED`, no read | 403 `PERMISSION_DENIED`, no read | 400 `INVALID_FIELD`, no read | | control `count` | 200, 3 | 200, 3 | 200, 3 | | control `amount_sum` | 200, 1400 | 200, 1400 | 200, 1400 | | control `amount_total` (authored) | 200, 1400 | 200, 1400 | 200, 1400 | | `_`, `__sum` (prefix `_` is a field-shaped name) | 400 `INVALID_FIELD` (missing field `_`) | same | unchanged | Every refused cell's message names the spelling sent, for example "Measure '_sum' on cube 'X' names no field to aggregate: nothing precedes the suffix '_sum'". ## Scope: one rule, wider than the card's title (declared) - **The row wildcard source (`*`, `*_SUFFIX`, `CUBE.*`).** The card names the empty prefix. The added pin ("for every caller-named spelling, a `'*'` reaches the gate only together with `count`") cannot hold unless the mint also refuses a `'*'` source: `*` and `*_sum` mint `'*'` under `sum`, verbatim. That was measured as 500 above. One condition, source `'*'`, covers it. This is the card's own family (the row wildcard under a non-`count` aggregate) and needs no second rule. - **The empty source with no suffix (`''`, and `CUBE.` after the qualifier strip).** These are bounded in-place fixes, and all four conditions hold. 1. It is the same defect class: a caller spelling minted into an aggregate over a source that names no field. `''` answered 500 on `main`. 2. The fix is mechanical: the same `source === ''` condition. 3. The file is held by no other claim (the claim's file surface). 4. It is the same pin file and gate family, with no new verification surface. Moved answer, declared in the changeset: `CUBE.` was a 403 from the member-shape gate and is now the mint's 400, since it names no field either. - **Authored members are not this card's.** `CubeSchema` admits any measure key (`z.record(z.string(), MetricSchema)`). Measured at the spec `dist` built from `713b0fa76`: a cube declaring measures `_sum` and `*` parses. `DatasetSchema` refuses a dataset measure named `_sum` (`invalid_format`, snake_case starting with a letter). A cube that DECLARES `_sum` hits its own member and never reaches the mint. A pin serves it (1400). ## Pins: `src/__tests__/caller-measure-no-field-door.test.ts` (new) The pins use the plugin's own composition over a real `ObjectQL` engine and `SqlDriver` (SQLite), both auto-bridges live, on the native and ObjectQL strategies. They follow the precedent of `cube-measure-field-type-door.test.ts`. - **Enumeration.** Every suffix in `INFERRED_MEASURE_SUFFIXES` is tested with an empty prefix, bare and `CUBE.`-qualified, on both strategies, on an ad-hoc cube and on an authored cube that does not declare it. Each is refused with `INVALID_FIELD` / 400, `member` equal to the spelling, `param: 'measures'`, `cube`, no `field`, and the message naming the spelling. Each run has zero raw statements and zero engine aggregates. - **Other no-field sources.** `*`, `*` with every suffix, `''`, `CUBE.*` and `CUBE.` get the same refusal, with no read. - **Controls.** The bare `count` returns 3, and its dry-run statement is `COUNT(*)`. `amount_sum` returns 1400 (ad hoc and authored). The authored `amount_total` returns 1400. A declared `_sum` member is served, 1400. - **Dry-run door.** `generateSql` refuses every empty-prefix suffix the same way. - **By construction (the added pin).** Over a generated corpus (qualifier `''` / `CUBE.` / `other.` × prefix `''`, `*`, `**`, a space, `_`, `amount`, `count` × tail `''` or each suffix), `inferredCallerMeasureSql` either refuses with the envelope or returns null or a string. A `'*'` comes back only for `count` and `CUBE.count`, which `inferMeasure` types `count`. The pin also asserts that the corpus exercised both arms. The pins stay in `service-analytics`. `AnalyticsService.query()` / `generateSql()` are what the dispatcher's `/analytics/query` and `/analytics/sql` routes call one-to-one. The dispatcher carries a thrown `code` / `status` to the wire, and the same `INVALID_FIELD` / 400 crossing is already pinned at the route by `packages/runtime/src/analytics-json-dimension-door.test.ts`. The throwaway probe above measured the wire answers on this branch. No runtime file is added, so there is no cross-lane addition. ## Ablations (from the committed state, `node scripts/ablation-replace.mjs` in wrap mode, restore proven: blob equals HEAD and `git diff HEAD` is empty) The subject is imported from `src` (`../analytics-service.js`), so no `dist` sits on the path. - **A1: the predecessor mint restored.** The throw is replaced with `return { label: key, type, sql: source || '*' };`. Predicted: the 8 refusal tests, the 2 dry-run tests and the by-construction pin go red, and the 9 controls stay green. Observed: **11 failed, 9 passed**. The enumeration cells answer `DATABASE_ERROR` again (and `INVALID_QUERY` for `_count_distinct` on ObjectQL), the dry run resolves a statement, and the by-construction pin reports that `''` reaches the gate as `'*'` but is not `count`. - **A2: only the wildcard arm dropped.** The condition `source === '' || source === '*'` becomes `source === ''`. Predicted: the empty-prefix enumeration and the dry run stay green, and the 4 other-no-field tests and the by-construction pin go red. Observed: **5 failed, 15 passed**, with "`*` reaches the gate as `'*'` but is not count". So the added pin catches what the empty-prefix enumeration alone does not. ## Verification (at `b49aba455`, after merging `origin/main` `49524f690`) - `pnpm --filter @objectstack/service-analytics typecheck`: clean. `tsc --listFiles` includes the new test file. - `pnpm --filter @objectstack/service-analytics test` (the full script): **172 files passed, 3931 tests passed, 183 skipped**. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 64 commands, and all 64 were run. 63 exited 0 on their first run. `pnpm check:dual-build-cjs-loads` first exited 3 with `PREREQUISITE NOT MET` (some packages had no `dist/`). After a full `turbo run build` (72 tasks, 71 cache hits) it exited 0. `--ran` with every exit code recorded: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". - `check:adr-0087-registration` judged the changeset `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. `check:changeset-no-major` reports no `major`, and `check:empty-changeset` reports no empty-frontmatter changeset and no modified one. - Lint, narrowed and declared as such. eslint `--no-inline-config --format json` over the two touched `.ts` files reports 2 files, 0 errors and 0 warnings. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - CI's own jobs (Test Core shards, Dogfood, Build Core, the type-check lanes) were not measured locally. ## Changeset `.changeset/21437-analytics-measure-names-no-field.md` declares `@objectstack/service-analytics`: `minor` with the BREAKING banner, the `Clause-②: no (narrowing)` line, before and after cells, the one-line fix, and one ADR-0087 disposition (`not-required (no-migration-prescription)`, every other category ruled out on facts). Its twin precedent is #21431's `.changeset/21409-analytics-row-wildcard-count-only.md`, the authored-position half of the same rule. It is the same `minor`-under-launch-window shape with the BREAKING banner and the `(narrowing)` arm. That one registers a D3 entry because stored documents need a prescription. This one has no stored shape, so it takes the `no-migration-prescription` disposition, as the sibling analytics-door narrowings do (`21267-analytics-order-key-selected.md`, `21426-native-number-comparand.md`). ## Docs `content/docs/api/data-api.mdx` ("How to spell a measure") already states the contract this enforces: the bare `count`, or one of the object's own field names plus a suffix. The change makes no sentence there false, so it is untouched. A `skills/**` grep for measure spellings finds only field-prefixed ones (`amount_sum`, `total_sum`, `revenue_sum`). None is an empty prefix or `'*'`. ## Acceptance notes - **Docs drift, not caused here.** `inferMeasure` also strips `_average`, and with no suffix it sums the whole key. `data-api.mdx` lists neither. Nothing was made false, so this is noted only (carrier: none). - **The console adapter.** objectui's analytics adapter, at the pinned `.objectui-sha` `89cad75d5`, composes a measure as the value field, an underscore and the function. A widget with an empty value field would post `_sum`. That adapter classified the old 500 as `unknown` and answered with its client-side `aggregateViaFind`. It classifies the new 400 as `rejected` and throws `AnalyticsQueryRejectedError`. This is the intended direction (loud over plausible numbers), and the changeset states it. Whether any shipped widget reaches an empty value field was not measured. - **The gate's empty-measure skip.** The `measure === ''` skip in `assertCallerMembersResolvable` no longer sees `''`, because the mint refuses it first. It is left as is, and the gate is untouched per the ruling. - **No dialect cell.** The pin file has no PostgreSQL cell. The refusal happens before any statement, so it is dialect-free. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e33b67 commit 0b82391

3 files changed

Lines changed: 423 additions & 19 deletions

File tree

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 caller-named analytics measure whose inferred source names no field (`_sum`, `*`, `*_sum`, an empty spelling) is refused with `INVALID_FIELD` / 400 at the analytics door, naming the spelling sent, on both strategies, before any statement is built (#21437)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the analytics door of a caller-named measure spelling whose inferred source names no field. No authorable key, spelling, export type or stored shape moves: AnalyticsQuerySchema still parses every measures list it parsed (the refusal is a runtime rule on the request, not a schema change), CubeSchema and DatasetSchema are untouched, a member a cube declares is never minted and is served as before, the package index exports the same names with the same types, and no stored row is read or rewritten. A refused spelling had no answer to preserve: both strategies answered 500 for it, and which column a caller meant by an empty prefix 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 caller-named measure spelling, and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept in `measures`, on both strategies and every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, published type or error code changes.
12+
13+
**The rule.** A `measures` entry the cube does not declare is inferred: the bare `count` counts rows (`COUNT(*)`), and any other spelling aggregates one of the object's own fields, named before an aggregation suffix (`_sum`, `_avg`, `_average`, `_min`, `_max`, `_count_distinct`) or, with no suffix, by the whole spelling. The bare `count` is now the only spelling that reads the row wildcard `'*'`. A spelling whose source is empty or is `'*'` names no field, and it is refused with `400 INVALID_FIELD` before anything is executed. The error names the spelling as it was sent (`member`, with `param: 'measures'` and `cube`); a `<cube>.` qualifier is kept in the name.
14+
15+
**Before**, measured through `POST /api/v1/analytics/query` on SQLite, on the native-SQL and the ObjectQL strategy, on an ad-hoc cube and on an authored cube that does not declare the member:
16+
17+
- `_sum`, `_avg`, `_average`, `_min`, `_max`, their `<cube>.`-qualified forms, `*`, `*_sum`, `*_avg` and the empty spelling `''` answered `500 DATABASE_ERROR`, after a statement reached the database (`SUM(*)`, `AVG(*)`, `SUM()`).
18+
- `_count_distinct` and `*_count_distinct` answered `500 DATABASE_ERROR` on the native-SQL strategy (`COUNT(DISTINCT *)`). On the ObjectQL strategy the engine answered `400 INVALID_QUERY` after the aggregate was called.
19+
- The qualifier alone (`<cube>.`) answered `403 PERMISSION_DENIED` from the member-shape gate. It now answers the same `400 INVALID_FIELD`, because it names no field either.
20+
21+
**Now** each of those answers `400 INVALID_FIELD`, and no statement and no engine aggregate runs. `POST /api/v1/analytics/sql` refuses the same spellings instead of returning a statement that cannot run.
22+
23+
**What to write instead.** Ask for `count` to count rows, or put the field's name before the suffix: the sum of `amount` is `amount_sum`.
24+
25+
**Who is affected.** A caller that sent a measure spelling with nothing before the suffix, or the row wildcard itself. Every such request was already a 500. No example app, shipped dashboard, report, dataset, cube, doc or skill in this repository sends one. The console's analytics adapter composes a measure as the value field, an underscore and the aggregate function, so a widget whose value field is empty posts `_sum`. At the pinned `.objectui-sha` that adapter reads a 500 as an unknown failure and answers with its own client-side aggregation; it reads the 400 as a rejected request and surfaces it as an error.
26+
27+
**Unchanged.** The bare `count`; a field-prefixed spelling such as `amount_sum`; the no-suffix spelling of a field (`amount`); a measure a cube declares, including one declared under a key such as `_sum`, which is the cube's own vocabulary and is never inferred; and the authored-position twin of this rule, the `@objectstack/spec` parse refusal of `'*'` outside a `count` on a cube or dataset measure (#21409).
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21437] A caller-named measure whose inferred source names no field is
5+
* refused at the analytics door, `INVALID_FIELD` / 400 naming the spelling the
6+
* caller sent, before either strategy reads anything.
7+
*
8+
* ## The shape this closes
9+
*
10+
* `inferMeasure` strips an aggregation suffix and aggregates what precedes it.
11+
* For an EMPTY prefix it minted the row wildcard (`key.slice(…) || '*'`), so
12+
* `_sum` became `{ type: 'sum', sql: '*' }`; the member-shape gate admits `'*'`
13+
* as a column reference, and the strategies emit the operand verbatim.
14+
* Measured through `POST /api/v1/analytics/query` on `origin/main`
15+
* `713b0fa76` (the runtime dispatcher's door composition, SQLite, both
16+
* strategies, an ad-hoc cube and an authored cube that does not declare the
17+
* member): every suffix with an empty prefix answered `500 DATABASE_ERROR`
18+
* after a statement reached the engine, except `_count_distinct` on the
19+
* ObjectQL strategy, which the engine refused `400 INVALID_QUERY` after the
20+
* aggregate was called. The same mint also passed the row wildcard and the
21+
* empty string through VERBATIM — `*`, `*_sum`, `''` — with the same 500.
22+
*
23+
* ## What these pins hold
24+
*
25+
* - **The enumeration.** Every suffix `inferMeasure` strips — read from
26+
* `INFERRED_MEASURE_SUFFIXES`, the list the mint itself iterates, never
27+
* restated here — with an empty prefix, bare and `<cube>.`-qualified, on
28+
* both strategies, on an ad-hoc cube and on an authored cube that does not
29+
* declare the member, is refused `INVALID_FIELD` / 400 naming the spelling
30+
* sent, with zero raw statements and zero engine aggregates. So are the
31+
* other spellings whose source names no field (`*`, `*<suffix>`, `''`).
32+
* - **The controls.** The bare `count` is still `COUNT(*)`; `amount_sum` and
33+
* an authored `amount_total` answer the true sum; a member an authored cube
34+
* DECLARES under an empty-prefix name (`CubeSchema` admits any measure key)
35+
* is the cube's own vocabulary, never minted, and is served.
36+
* - **By construction.** For every caller spelling the corpus generates, the
37+
* source the member-shape gate reads (`inferredCallerMeasureSql`) is `'*'`
38+
* only for a spelling that reduces to `count`, so the gate's `'*'`
39+
* pass-through decides nothing about aggregates.
40+
*
41+
* `AnalyticsService.query()` / `generateSql()` are what the dispatcher's
42+
* `POST /api/v1/analytics/query` and `/sql` routes call one-to-one, and the
43+
* dispatcher carries a thrown `code` / `status` to the wire unchanged — the
44+
* same `INVALID_FIELD` / 400 crossing is pinned at the route by
45+
* `packages/runtime/src/analytics-json-dimension-door.test.ts`.
46+
*/
47+
48+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
49+
import { ObjectQL } from '@objectstack/objectql';
50+
import { SqlDriver } from '@objectstack/driver-sql';
51+
import type { Cube } from '@objectstack/spec/data';
52+
import {
53+
AnalyticsService,
54+
INFERRED_MEASURE_SUFFIXES,
55+
inferMeasure,
56+
inferredCallerMeasureSql,
57+
} from '../analytics-service.js';
58+
import { AnalyticsServicePlugin } from '../plugin.js';
59+
60+
const OBJECT = 'os21437_deal';
61+
62+
const DEAL = {
63+
name: OBJECT,
64+
label: 'Caller measure ledger',
65+
fields: {
66+
title: { name: 'title', type: 'text' as const },
67+
amount: { name: 'amount', type: 'number' as const },
68+
},
69+
};
70+
71+
const ROWS = [
72+
{ id: 'd1', title: 'x', amount: 100 },
73+
{ id: 'd2', title: 'x', amount: 300 },
74+
{ id: 'd3', title: 'y', amount: 1000 },
75+
] as const;
76+
77+
/** Sum of `amount` over ROWS. */
78+
const TRUE_SUM = 1400;
79+
80+
/** An authored cube that declares NO empty-prefix member: every one is minted. */
81+
const AUTHORED: Cube = {
82+
name: 'os21437_cube',
83+
title: 'Caller measure cube',
84+
sql: OBJECT,
85+
public: true,
86+
measures: {
87+
count: { type: 'count', sql: '*', label: 'Rows' },
88+
amount_total: { type: 'sum', sql: 'amount', label: 'Total amount' },
89+
},
90+
dimensions: { title: { type: 'string', sql: 'title', label: 'Title' } },
91+
} as Cube;
92+
93+
/** An authored cube that DECLARES a member under an empty-prefix name. */
94+
const DECLARES_EMPTY_PREFIX_NAME: Cube = {
95+
name: 'os21437_declared_cube',
96+
title: 'Declared empty-prefix name',
97+
sql: OBJECT,
98+
public: true,
99+
measures: {
100+
_sum: { type: 'sum', sql: 'amount', label: 'Total amount, under an empty-prefix key' },
101+
},
102+
dimensions: { title: { type: 'string', sql: 'title', label: 'Title' } },
103+
} as Cube;
104+
105+
const SUFFIXES = INFERRED_MEASURE_SUFFIXES.map(([suffix]) => suffix);
106+
107+
/** The other spellings whose source names no field: the wildcard, wildcard + suffix, empty. */
108+
const OTHER_NO_FIELD = ['*', ...SUFFIXES.map((s) => `*${s}`), ''];
109+
110+
const SHAPES = [
111+
{ label: 'an ad-hoc cube', cube: OBJECT },
112+
{ label: 'an authored cube that does not declare the member', cube: AUTHORED.name },
113+
] as const;
114+
115+
const FACES = ['native', 'objectql'] as const;
116+
type Face = (typeof FACES)[number];
117+
118+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
119+
120+
type Refusal = Error & { code?: string; status?: number; member?: string; param?: string; cube?: string; field?: string };
121+
122+
const refusalOf = (pending: Promise<unknown>): Promise<Refusal> =>
123+
pending.then<never, Refusal>(
124+
() => {
125+
throw new Error('expected the request to be refused, but it resolved');
126+
},
127+
(e) => e as Refusal,
128+
);
129+
130+
describe('[#21437] a caller-named measure whose source names no field — at the analytics door (sqlite)', () => {
131+
let engine: ObjectQL;
132+
/** Raw-SQL statements and engine aggregates that read THIS object. */
133+
const reads = { rawSql: 0, aggregate: 0 };
134+
const services: Partial<Record<Face, AnalyticsService>> = {};
135+
136+
const read = async (face: Face, cube: string, measures: readonly string[]) => {
137+
const before = { ...reads };
138+
const outcome = await services[face]!.query({ cube, measures: [...measures] } as any).then(
139+
(res) => ({ res, err: undefined as Refusal | undefined }),
140+
(err) => ({ res: undefined, err: err as Refusal }),
141+
);
142+
return { ...outcome, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate };
143+
};
144+
145+
beforeAll(async () => {
146+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
147+
engine = new ObjectQL({ logger: quiet } as any);
148+
engine.registerDriver(driver, true);
149+
await engine.init();
150+
engine.registry.registerObject(DEAL as any);
151+
await engine.syncSchemas();
152+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
153+
154+
const realExecute = (engine as any).execute.bind(engine);
155+
(engine as any).execute = (sql: unknown, opts?: { object?: string }) => {
156+
if (opts?.object === OBJECT) reads.rawSql += 1;
157+
return realExecute(sql, opts);
158+
};
159+
const realAggregate = engine.aggregate.bind(engine);
160+
(engine as any).aggregate = (object: string, ...rest: unknown[]) => {
161+
if (object === OBJECT) reads.aggregate += 1;
162+
return (realAggregate as any)(object, ...rest);
163+
};
164+
165+
// The plugin's own composition over the real engine, both auto-bridges
166+
// live; `objectql` narrows the capabilities to the engine-aggregate path.
167+
for (const [face, caps] of [
168+
['native', undefined],
169+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
170+
] as const) {
171+
const registered: Record<string, unknown> = {};
172+
await new AnalyticsServicePlugin({
173+
cubes: [AUTHORED, DECLARES_EMPTY_PREFIX_NAME],
174+
...(caps ? { queryCapabilities: caps } : {}),
175+
} as any).init({
176+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
177+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
178+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
179+
hook: () => {},
180+
logger: quiet,
181+
} as never);
182+
services[face] = registered.analytics as AnalyticsService;
183+
}
184+
});
185+
186+
afterAll(async () => {
187+
try { await engine?.destroy(); } catch { /* noop */ }
188+
});
189+
190+
it('the enumeration reads the mint\'s own suffix list, and it is not empty', () => {
191+
expect(SUFFIXES.length).toBeGreaterThan(0);
192+
});
193+
194+
for (const face of FACES) {
195+
describe(`${face} strategy`, () => {
196+
for (const { label, cube } of SHAPES) {
197+
it(`every suffix the mint strips, with an empty prefix, is refused INVALID_FIELD / 400 naming the spelling — ${label}`, async () => {
198+
for (const spelling of SUFFIXES.flatMap((s) => [s, `${cube}.${s}`])) {
199+
const { res, err, rawSql, aggregate } = await read(face, cube, [spelling]);
200+
expect(res, `${spelling} must not be served`).toBeUndefined();
201+
expect(err?.code, `${spelling}: ${err?.message}`).toBe('INVALID_FIELD');
202+
expect(err?.status, spelling).toBe(400);
203+
expect(err?.member, spelling).toBe(spelling);
204+
expect(err?.param, spelling).toBe('measures');
205+
expect(err?.cube, spelling).toBe(cube);
206+
expect(err?.field, `${spelling}: there is no field to name`).toBeUndefined();
207+
expect(err?.message, spelling).toContain(`Measure '${spelling}' on cube '${cube}'`);
208+
expect(rawSql, `${spelling}: no raw statement ran`).toBe(0);
209+
expect(aggregate, `${spelling}: no engine aggregate ran`).toBe(0);
210+
}
211+
});
212+
213+
it(`the other spellings whose source names no field (*, *<suffix>, '') are refused the same way — ${label}`, async () => {
214+
for (const spelling of [...OTHER_NO_FIELD, `${cube}.*`, `${cube}.`]) {
215+
const { res, err, rawSql, aggregate } = await read(face, cube, [spelling]);
216+
expect(res, `${JSON.stringify(spelling)} must not be served`).toBeUndefined();
217+
expect(err?.code, `${JSON.stringify(spelling)}: ${err?.message}`).toBe('INVALID_FIELD');
218+
expect(err?.status, spelling).toBe(400);
219+
expect(err?.member, spelling).toBe(spelling);
220+
expect(err?.param, spelling).toBe('measures');
221+
expect(rawSql + aggregate, `${JSON.stringify(spelling)}: nothing was read`).toBe(0);
222+
}
223+
});
224+
225+
it(`CONTROL the bare count is COUNT(*), and amount_sum is the true sum — ${label}`, async () => {
226+
const count = await read(face, cube, ['count']);
227+
expect(count.err, count.err?.message).toBeUndefined();
228+
expect(Number(count.res!.rows[0]!.count)).toBe(ROWS.length);
229+
const statement = await services[face]!.generateSql({ cube, measures: ['count'] } as any);
230+
expect(statement.sql, 'the count reads the row wildcard').toMatch(/COUNT\(\*\)/i);
231+
232+
const sum = await read(face, cube, ['amount_sum']);
233+
expect(sum.err, sum.err?.message).toBeUndefined();
234+
expect(Number(sum.res!.rows[0]!.amount_sum)).toBe(TRUE_SUM);
235+
});
236+
}
237+
238+
it('CONTROL the authored cube\'s own amount_total is the true sum', async () => {
239+
const { res, err } = await read(face, AUTHORED.name, ['amount_total']);
240+
expect(err, err?.message).toBeUndefined();
241+
expect(Number(res!.rows[0]!.amount_total)).toBe(TRUE_SUM);
242+
});
243+
244+
it('CONTROL a member an authored cube DECLARES under an empty-prefix name is its own vocabulary — served, never minted', async () => {
245+
const { res, err } = await read(face, DECLARES_EMPTY_PREFIX_NAME.name, ['_sum']);
246+
expect(err, err?.message).toBeUndefined();
247+
expect(Number(res!.rows[0]!._sum)).toBe(TRUE_SUM);
248+
});
249+
250+
it('the dry-run door refuses what the query door refuses, and shows COUNT(*) for the control', async () => {
251+
for (const spelling of SUFFIXES) {
252+
const err = await refusalOf(services[face]!.generateSql({ cube: AUTHORED.name, measures: [spelling] } as any));
253+
expect(err.code, `${spelling}: ${err.message}`).toBe('INVALID_FIELD');
254+
expect(err.status, spelling).toBe(400);
255+
expect(err.member, spelling).toBe(spelling);
256+
}
257+
const control = await services[face]!.generateSql({ cube: AUTHORED.name, measures: ['count'] } as any);
258+
expect(control.sql).toMatch(/COUNT\(\*\)/i);
259+
});
260+
});
261+
}
262+
});
263+
264+
describe('[#21437] by construction: a \'*\' reaches the member-shape gate only together with count', () => {
265+
const CUBE = AUTHORED.name;
266+
const PREFIXES = ['', '*', '**', ' ', '_', 'amount', 'count'];
267+
const TAILS = ['', ...SUFFIXES];
268+
const QUALIFIERS = ['', `${CUBE}.`, 'other.'];
269+
const CORPUS = [...new Set(QUALIFIERS.flatMap((q) => PREFIXES.flatMap((p) => TAILS.map((t) => `${q}${p}${t}`))))];
270+
/** The spellings that reduce to the bare `count`, once the cube qualifier is stripped. */
271+
const COUNT_SPELLINGS = new Set(['count', `${CUBE}.count`]);
272+
273+
it('for every caller spelling the corpus generates, the gate input is \'*\' only for count, and the mint pairs it with count', () => {
274+
let wildcards = 0;
275+
let refusals = 0;
276+
for (const spelling of CORPUS) {
277+
let source: string | null;
278+
try {
279+
source = inferredCallerMeasureSql(spelling, CUBE);
280+
} catch (e) {
281+
const err = e as Refusal;
282+
expect(err.code, `${JSON.stringify(spelling)}: ${err.message}`).toBe('INVALID_FIELD');
283+
expect(err.status, spelling).toBe(400);
284+
expect(err.member, spelling).toBe(spelling);
285+
refusals += 1;
286+
continue;
287+
}
288+
if (source !== '*') continue;
289+
wildcards += 1;
290+
expect(COUNT_SPELLINGS.has(spelling), `${JSON.stringify(spelling)} reaches the gate as '*' but is not count`).toBe(true);
291+
const key = spelling.startsWith(`${CUBE}.`) ? spelling.slice(CUBE.length + 1) : spelling;
292+
expect(inferMeasure(key, { member: spelling, cube: CUBE }).type, spelling).toBe('count');
293+
}
294+
// The corpus must exercise both arms, or this pin could pass over nothing.
295+
expect(wildcards, 'the count spellings reached the gate as the row wildcard').toBe(COUNT_SPELLINGS.size);
296+
expect(refusals, 'the no-field spellings were refused').toBeGreaterThanOrEqual(2 * SUFFIXES.length);
297+
});
298+
});

0 commit comments

Comments
 (0)