diff --git a/.changeset/21044-cube-measure-field-type-table.md b/.changeset/21044-cube-measure-field-type-table.md new file mode 100644 index 00000000000..40a2495a433 --- /dev/null +++ b/.changeset/21044-cube-measure-field-type-table.md @@ -0,0 +1,27 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure, so a configured or suffix-inferred cube measure whose aggregate the table refuses for its column's declared type answers `INVALID_FIELD` / 400 on every driver and both strategies, and a `min` / `max` over a temporal column is described `time` in `fields[]` + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept, on every driver and on both strategies. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +FROM → TO, for a `measures` entry that resolves to a cube measure over a column of the cube's own object (an authored cube measure, or a suffix-inferred one such as `note_max`): + +- `min` / `max` over a type outside the numeric, temporal and boolean classes (the string family such as `text`, `email` and `url`; `select`, `radio`, `lookup`, `user`; `autonumber`; the JSON-stored, file and `formula` types): FROM, on the native-SQL strategy, `200` with the column's own value (a string such as `"y"`) under `fields[] { type: 'number' }`, on SQLite and PostgreSQL alike; on the ObjectQL strategy the engine's door already answered `400 INVALID_FIELD` after the strategy began. TO `400 INVALID_FIELD` before either strategy reads anything. +- `sum` / `avg` over a type outside the numeric and boolean classes (`sum` also refuses `percent`): FROM `200` with a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL (the ObjectQL strategy refused `avg` at the engine and passed `sum` to the driver, which answered the same `0` / `500`). TO `400 INVALID_FIELD`. +- `min` / `max` over a `date`, `datetime` or `time` column: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`, the `DimensionType` word a temporal dimension column already carries, by the same rule the dataset door applies (`measureResultType`). + +**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the column, the object and its declared type, saying the query was not run, and naming the types the aggregate accepts, read off `AGGREGATE_FIELD_TYPE_COMPATIBILITY`. The thrown error carries `member`, `param` (`measures`), `cube`, `field` and `object`. + +**Why a refusal.** The dataset door (`POST /api/v1/analytics/dataset/query`) refuses every one of these pairs at compile by the same table (`DATASET_INVALID`), and the engine's aggregate door refuses most of them on the ObjectQL strategy; the native-SQL strategy compiled its own statement and asked nothing. Measured through the real dispatcher route on SQLite and PostgreSQL 16: a configured cube's `max` over a `text` column answered `"y"` under a column described `number` on the native strategy and `400` on the ObjectQL strategy, and `sum` over the same column answered `0` on SQLite and `500` on PostgreSQL. One cube, one query, an answer chosen by the driver. + +**What to write instead.** Aggregate a field of a type the aggregate accepts. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text belongs in a numeric field of its own, aggregated there. + +**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a column through `/analytics/query` on the native-SQL strategy and read the answer as a real one. No example app and no shipped cube authors such a pair. A reader that branched on `fields[].type === 'number'` for a temporal `min` / `max` column now sees `time`. + +**Unchanged.** Every pair the table accepts, a `max` over a `boolean` column included (its column keeps `number`: the rule declines the boolean class); `count` over any column; `count_distinct`, which keeps its own door and words; a measure over a relationship path (`account.name`), which this door does not judge; a column the host's field metadata cannot resolve, or a type outside `FieldType`; a measure whose `sql` is `*`; an expression metric type (`number` / `string` / `boolean`); and a host that wires no `sourceFieldMeta`, where the declaration cannot be read. The dataset door keeps its own `DATASET_INVALID` answer at compile. diff --git a/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts b/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts new file mode 100644 index 00000000000..9bbf0f4b665 --- /dev/null +++ b/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21044] Over the wire: `POST /api/v1/analytics/query`, served by the real + * dispatcher route, refuses a configured cube's `max` over a `text` column with + * the cube door's `INVALID_FIELD` / 400 — on the native-SQL face, which served + * the column's text under `fields[] { type: 'number' }`, and on the ObjectQL + * face, which the engine's aggregate door refused only after the strategy had + * begun — and serves the controls. + * + * The service door is pinned beside the implementation (`service-analytics` + * `cube-measure-field-type-door.test.ts`, both faces, with read counters). + * This file asks the question that pin cannot: does the route relay the + * refusal's envelope as a 400 with its code, and does `fields[]` reach the wire + * as the service describes it. + * + * The analytics service is the one `AnalyticsServicePlugin` composes over a + * real `ObjectQL` engine and `SqlDriver`: `sourceFieldMeta` wired from the + * engine's registry, both auto-bridges live, the cube handed in as + * `AnalyticsServiceConfig.cubes` (the config key the CLI threads an app's + * `analyticsCubes` into). The SQLite cell always runs; the PostgreSQL cell runs + * where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. No CI step + * provisions that variable for this package, so the live cell is red-capable + * and un-run in CI; the PR that landed this file carries its local PostgreSQL + * 16 run. The live cell owns its table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const OBJECT = 'os21044_route_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'Cube measure route ledger', + fields: { + note: { name: 'note', type: 'text' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +const ROWS = [ + { id: 'r1', note: 'x', opened_at: '2026-01-02T03:04:05.000Z', amount: 10 }, + { id: 'r2', note: 'y', opened_at: '2026-03-04T05:06:07.000Z', amount: 32 }, +] as const; + +const CUBE = { + name: 'os21044_route_cube', + title: 'Cube measure route cube', + sql: OBJECT, + public: true, + measures: { + max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' }, + max_opened: { type: 'max', sql: 'opened_at', label: 'Latest opening (datetime)' }, + max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' }, + }, + dimensions: {}, +}; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const FACES = ['native', 'objectql'] as const; +type Face = (typeof FACES)[number]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +// ── harness (the shape `analytics-authored-cube-format-granularity.test.ts` uses) ── + +// The analytics domain refuses an anonymous caller first (ADR-0056 D2), so this +// harness signs its caller in the way that file does: an `auth` slot in the +// shape `resolveExecutionContext` reads answers a session for every request. +// Only identity is stubbed; the route, the service and the engine are real. +const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } }; + +type Handler = (req: unknown, res: unknown) => unknown; + +function makeFakeServer() { + const handlers: Record = {}; + const rec = (verb: string) => (path: string, handler: Handler) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: unknown, analytics: unknown) { + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: quiet, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: unknown) { res.body = b; return res; }, + }; + return res; +} + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21044] POST /api/v1/analytics/query — a cube measure the aggregate × field-type table refuses is a 400 — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + const post: Partial Promise<{ status: number; body: any }>>> = {}; + + const dropTable = async () => { + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, registered.analytics)); + const handler = handlers['POST /api/v1/analytics/query']; + expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function'); + post[face] = async (body) => { + const res = makeRes(); + // What the wire carries: JSON, both ways. + await handler({ body: JSON.parse(JSON.stringify(body)), query: {} }, res); + return { status: res.statusCode ?? 200, body: JSON.parse(JSON.stringify(res.body ?? null)) }; + }; + } + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const face of FACES) { + it(`${face}: max over a text column answers 400 INVALID_FIELD, and nothing is served`, async () => { + const res = await post[face]!({ cube: CUBE.name, measures: ['max_note'] }); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INVALID_FIELD'); + expect(res.body.error.httpStatus).toBe(400); + expect(res.body.data).toBeUndefined(); + }); + + it(`${face}: the control — max over a number column is served, a number typed number`, async () => { + const res = await post[face]!({ cube: CUBE.name, measures: ['max_amount'] }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const fields = res.body.data.fields as Array<{ name: string; type: string }>; + expect(res.body.data.rows[0].max_amount).toBe(32); + expect(fields.find((f) => f.name === 'max_amount')?.type).toBe('number'); + }); + + it(`${face}: an accepted temporal pair is served and reaches the wire typed time`, async () => { + const res = await post[face]!({ cube: CUBE.name, measures: ['max_opened'] }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const fields = res.body.data.fields as Array<{ name: string; type: string }>; + expect(res.body.data.rows[0].max_opened).not.toBeNull(); + expect(fields.find((f) => f.name === 'max_opened')?.type).toBe('time'); + }); + } + }, + ); +} diff --git a/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts b/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts new file mode 100644 index 00000000000..5298fb00f00 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21044] The cube door judges a measure by the ONE aggregate × field-type + * table, `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`), + * ahead of strategy selection — as the dataset door judges it at compile. + * + * ## The shape this closes + * + * A configured cube measure `{ type: 'max', sql: 'note' }` over a `text` + * column was SERVED by `NativeSQLStrategy` — `200`, the column's text (`"y"`) — + * while the same response's `fields[]` declared the measure + * `{ type: 'number' }`. The dataset door refuses that pair at compile + * (`DATASET_INVALID` / 400, `dataset-compiler.ts`), and the engine's aggregate + * door refuses it on the ObjectQL face (`INVALID_FIELD` / 400, after the + * strategy has started reading). The cube door consulted nothing. + * + * ## What these pins hold + * + * - A measure whose aggregate the table refuses for its column's declared type + * is refused `INVALID_FIELD` / 400 BEFORE either strategy reads anything — + * no raw statement, no engine aggregate — with the member, the request key, + * the cube, the column and its object on the error. One door, every + * strategy, so both faces give one answer. + * - `sum` / `avg` over the same column are refused by the same door asking the + * same table — the dataset door judges every aggregate, and so does this one + * (`count_distinct` keeps its own door, #20912). + * - The controls are served: `max` over a `number` column answers a number, + * typed `number`. + * - An ACCEPTED non-numeric pair is described by the dataset door's one rule, + * `measureResultType`: `min` / `max` over a temporal column is typed `time`, + * and over a boolean column keeps the producer's `number` (the rule declines + * the boolean class). + * - A suffix-inferred measure and an authored one are one population, and the + * dry-run door refuses what the query door refuses. + * - "Cannot answer, do not block": a host that wires no `sourceFieldMeta` + * gets no verdict. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI step + * provisions that variable for this package, so the live cell is red-capable + * and un-run in CI, and the PR that landed this file carries its local + * PostgreSQL 16 run. The live cell owns its table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const OBJECT = 'os21044_cube_measure_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'Cube measure field-type ledger', + fields: { + category: { name: 'category', type: 'text' as const }, + note: { name: 'note', type: 'text' as const }, + status: { + name: 'status', + type: 'select' as const, + options: [{ label: 'Open', value: 'open' }, { label: 'Won', value: 'won' }], + }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + due_on: { name: 'due_on', type: 'date' as const }, + flag: { name: 'flag', type: 'boolean' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +const ROWS = [ + { id: 'r1', category: 'a', note: 'x', status: 'open', opened_at: '2026-01-02T03:04:05.000Z', due_on: '2026-02-01', flag: true, amount: 10 }, + { id: 'r2', category: 'a', note: 'y', status: 'won', opened_at: '2026-03-04T05:06:07.000Z', due_on: '2026-01-15', flag: false, amount: 32 }, +] as const; + +const CUBE: Cube = { + name: 'os21044_cube', + title: 'Cube measure field-type cube', + sql: OBJECT, + public: true, + measures: { + max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' }, + min_note: { type: 'min', sql: 'note', label: 'Smallest note (text)' }, + max_status: { type: 'max', sql: 'status', label: 'Largest status (select)' }, + max_opened: { type: 'max', sql: 'opened_at', label: 'Latest opening (datetime)' }, + min_due: { type: 'min', sql: 'due_on', label: 'Earliest due day (date)' }, + max_flag: { type: 'max', sql: 'flag', label: 'Any flag (boolean)' }, + max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' }, + sum_note: { type: 'sum', sql: 'note', label: 'Sum of notes (text)' }, + avg_note: { type: 'avg', sql: 'note', label: 'Average note (text)' }, + }, + dimensions: { + category: { type: 'string', sql: 'category', label: 'Category' }, + }, +} as Cube; + +/** + * Pairs the table refuses: the member, and the column its measure reads. The + * `min` / `max` rows are the card's; `sum` / `avg` over the same column are the + * same door asking the same table, measured on the base as `0` on SQLite (both + * faces for `sum`) and a 500 on PostgreSQL. + */ +const REFUSED: ReadonlyArray = [ + ['max_note', 'note'], + ['min_note', 'note'], + ['max_status', 'status'], + ['sum_note', 'note'], + ['avg_note', 'note'], +]; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const FACES = ['native', 'objectql'] as const; +type Face = (typeof FACES)[number]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Refusal = Error & { code?: string; status?: number; member?: string; param?: string; cube?: string; field?: string; object?: string }; + +const refusalOf = (query: Promise): Promise => + query.then( + () => { + throw new Error('expected the query to be refused, but it resolved'); + }, + (e) => e as Refusal, + ); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21044] the cube door judges a measure by the aggregate × field-type table (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read THIS object. */ + const reads = { rawSql: 0, aggregate: 0 }; + /** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */ + const services: Partial> = {}; + + const dropTable = async () => { + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + /** One query on one face, with the reads it caused counted. */ + const read = async (face: Face, cube: string, measures: readonly string[]) => { + const before = { ...reads }; + const outcome = await services[face]!.query({ cube, measures: [...measures] } as any).then( + (res) => ({ res, err: undefined as Refusal | undefined }), + (err) => ({ res: undefined, err: err as Refusal }), + ); + return { ...outcome, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate }; + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + // The plugin's own composition over the real engine — `sourceFieldMeta` + // wired from the engine's registry, both auto-bridges live. + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const face of FACES) { + it(`${face}: a measure whose aggregate the table refuses for its column's type is refused INVALID_FIELD / 400 before anything is read`, async () => { + for (const [member, column] of REFUSED) { + const { res, err, rawSql, aggregate } = await read(face, CUBE.name, [member]); + expect(res, `${member} must not be served`).toBeUndefined(); + expect(err?.code, `${member}: ${err?.message}`).toBe('INVALID_FIELD'); + expect(err?.status, member).toBe(400); + expect(err?.member, member).toBe(member); + expect(err?.param, member).toBe('measures'); + expect(err?.cube, member).toBe(CUBE.name); + expect(err?.field, member).toBe(column); + expect(err?.object, member).toBe(OBJECT); + expect(rawSql, `${member}: no raw statement ran`).toBe(0); + expect(aggregate, `${member}: no engine aggregate ran`).toBe(0); + } + }); + + it(`${face}: the control — max over a number column is served, a number, typed number`, async () => { + const { res, err } = await read(face, CUBE.name, ['max_amount']); + expect(err, err?.message).toBeUndefined(); + expect(res!.rows[0]!.max_amount).toBe(32); + expect(res!.fields.find((f) => f.name === 'max_amount')?.type).toBe('number'); + }); + + it(`${face}: an accepted temporal pair is served and described by measureResultType — typed time`, async () => { + for (const member of ['max_opened', 'min_due']) { + const { res, err } = await read(face, CUBE.name, [member]); + expect(err, `${member}: ${err?.message}`).toBeUndefined(); + expect(res!.rows[0]![member], `${member} answers a value`).not.toBeNull(); + expect(res!.fields.find((f) => f.name === member)?.type, `${member} is described time`).toBe('time'); + } + }); + + // [#21044] The one rule declines the boolean class (three readings + // disagree about what the aggregate returns), so the producer's `number` + // stands — the word SQLite's 0 / 1 answer fits. Not run on PostgreSQL's + // native face, where `max(boolean)` does not exist and the statement is + // a 500 for an ACCEPTED pair: a defect of the native lowering, reported + // on its own and not this door's. + it.skipIf(cell.id === 'pg' && face === 'native')( + `${face}: an accepted boolean pair is served and keeps number — the one rule declines it`, + async () => { + const { res, err } = await read(face, CUBE.name, ['max_flag']); + expect(err, err?.message).toBeUndefined(); + expect(res!.rows[0]!.max_flag).toBe(1); + expect(res!.fields.find((f) => f.name === 'max_flag')?.type).toBe('number'); + }, + ); + + it(`${face}: a suffix-inferred measure is judged as an authored one — on the configured cube and on an inferred cube`, async () => { + for (const cube of [CUBE.name, OBJECT]) { + const { res, err, rawSql, aggregate } = await read(face, cube, ['note_max']); + expect(res, `${cube}: note_max must not be served`).toBeUndefined(); + expect(err?.code, `${cube}: ${err?.message}`).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.member).toBe('note_max'); + expect(err?.field).toBe('note'); + expect(rawSql + aggregate, `${cube}: nothing was read`).toBe(0); + } + const control = await read(face, OBJECT, ['amount_max']); + expect(control.err, control.err?.message).toBeUndefined(); + expect(control.res!.rows[0]!.amount_max).toBe(32); + }); + } + + it('the dry-run door refuses what the query door refuses', async () => { + const err = await refusalOf(services.native!.generateSql({ cube: CUBE.name, measures: ['max_note'] } as any)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('max_note'); + const control = await services.native!.generateSql({ cube: CUBE.name, measures: ['max_amount'] } as any); + expect(control.sql).toMatch(/max\(/i); + }); + }, + ); +} + +describe('[#21044] cannot answer, do not block', () => { + it('a host that wires no sourceFieldMeta gets no verdict: the pair reaches its strategy', async () => { + let statements = 0; + const service = new AnalyticsService({ + logger: quiet as any, + cubes: [CUBE], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => { + statements += 1; + return [{ max_note: 'y' }]; + }, + }); + const res = await service.query({ cube: CUBE.name, measures: ['max_note'] } as any); + expect(statements).toBe(1); + expect(res.rows).toEqual([{ max_note: 'y' }]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts index 2ef3332d745..4d7249dd9d4 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts @@ -37,8 +37,11 @@ * core`), and `min` / `max` over a declared numeric column, through the same * `presentAsNumber` `driver-sql`'s own `aggregate()` applies (#20335). It is * keyed on the measure's declared function, never on whether a value looks - * numeric: the text dimension and the `max` over a text column below hold - * numeric-looking text and stay text. + * numeric: the text dimension below holds numeric-looking text and stays text. + * [#21044] The `max` over a text column this file used to serve as the + * second half of that control no longer reaches the presenter: the cube door + * refuses the pair by the aggregate × field-type table before any statement + * runs, and the case below now pins that refusal instead. * * The fixture's values are dyadic fractions and its groups hold 1, 2 or 4 rows, * so a JS double holds every sum and average EXACTLY and the expected values @@ -143,7 +146,7 @@ type NumberMeasure = (typeof NUMBER_MEASURES)[number]; const byGroup = (g: string) => ROWS.filter((r) => r.category === g); const sumOf = (rows: readonly Row[], f: 'stars' | 'amount' | 'frac') => rows.reduce((a, r) => a + r[f], 0); -function expected(g: string): Record & { max_code: string } { +function expected(g: string): Record { const rows = byGroup(g); return { row_count: rows.length, @@ -157,8 +160,6 @@ function expected(g: string): Record & { max_code: string max_price: Math.max(...rows.map((r) => r.price)), sum_frac: sumOf(rows, 'frac'), avg_frac: sumOf(rows, 'frac') / rows.length, - // Text order, not numeric: '9' sorts after '10'. - max_code: [...rows.map((r) => r.code)].sort().reverse()[0]!, }; } @@ -276,7 +277,7 @@ for (const cell of CELLS) { }); it('the cube read: every measure declared number is a number, equal to the rows, and the native strategy answered it', async () => { - const res = await cubeRead('native', [...NUMBER_MEASURES, 'max_code'], ['category']); + const res = await cubeRead('native', NUMBER_MEASURES, ['category']); expect(res.rawSql, 'one raw statement: NativeSQLStrategy answered').toBe(1); expect(res.aggregate, 'no engine aggregate').toBe(0); for (const m of NUMBER_MEASURES) { @@ -294,11 +295,18 @@ for (const cell of CELLS) { } }); - it('keyed on the declared function, never on the value: max over a text column and a text dimension stay text', async () => { - const res = await cubeRead('native', ['max_code'], ['category']); - for (const [g, a] of byCategory(res.rows)) { - expect(a.max_code, `${g} max(code) is the column's text`).toBe(expected(g).max_code); - } + it('keyed on the declared function, never on the value: a text dimension stays text, and max over a text column is refused before it is read', async () => { + // [#21044] Flipped, not deleted: this half used to read the column's + // text back. The aggregate × field-type table refuses `max` over `text`, + // and the cube door now asks it ahead of the strategy. + const before = { ...reads }; + const refused = await services.native!.query({ cube: CUBE.name, measures: ['max_code'], dimensions: ['category'] } as any).then( + () => undefined, + (e: Error & { code?: string; status?: number }) => e, + ); + expect(refused?.code, refused?.message).toBe('INVALID_FIELD'); + expect(refused?.status).toBe(400); + expect(reads.rawSql - before.rawSql, 'no statement ran').toBe(0); const byCode = await cubeRead('native', ['row_count'], ['code']); expect(byCode.rawSql).toBe(1); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 7d6ae954c41..e4d2570ecbe 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -7,7 +7,7 @@ import type { CubeMeta, DatasetSelection, } from '@objectstack/spec/contracts'; -import { percentScaleOf, type AggregationFunction, type Cube, type FilterCondition } from '@objectstack/spec/data'; +import { percentScaleOf, AggregationFunction, type Cube, type FilterCondition } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { Dataset } from '@objectstack/spec/ui'; // [#6761] The ONE shared `I18nLabel → string` resolver (#6765, maintainer @@ -59,6 +59,11 @@ import { // Owned in its own module so the enumerated verdict per `AggregationFunction` // member has one home rather than being inlined at the enrichment site. import { measureResultType } from './measure-result-type.js'; +// [#21044] …and the table that rule asks first, asked at the cube door too: +// a measure whose aggregate × field-type pair it refuses is refused in +// `ensureCube`, ahead of both strategies, as the dataset door refuses it at +// compile. +import { assertCubeMeasureFieldTypesAccepted } from './cube-measure-field-type-door.js'; import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; @@ -601,6 +606,72 @@ function withDeclaredMeasureFormats( return fields ? { ...result, fields } : result; } +/** + * [#21044] The cube measure a `measures` entry resolves to — through + * {@link declaredMemberEntry}, the resolver {@link withDeclaredMeasureFormats} + * reads — and the base-object COLUMN it aggregates: its `sql` when that is a + * bare identifier, `null` otherwise (`'*'`, or a relationship path whose + * declaration lives on another object). `undefined` when the entry resolves to + * no declared measure. + */ +function declaredMeasureColumn( + cube: Cube, + member: string, +): { type: unknown; column: string | null } | undefined { + const entry = declaredMemberEntry(cube, member, 'measure'); + const measure = entry ? cube.measures[entry.key] : undefined; + if (!measure) return undefined; + const sql = typeof measure.sql === 'string' ? measure.sql.trim() : ''; + return { type: measure.type, column: BARE_IDENTIFIER.test(sql) ? sql : null }; +} + +/** The `AggregationFunction` vocabulary — the aggregates `measureResultType` speaks about. */ +const AGGREGATION_FUNCTIONS: ReadonlySet = new Set(AggregationFunction.options); + +/** + * [#21044] Describe each measure column's `type` by the dataset door's one + * rule, {@link measureResultType} — at the seam every strategy's result leaves + * the cube door through, beside {@link withDeclaredMeasureFormats}. + * + * Every producer of `fields[]` mints `{ type: 'number' }` for a measure + * (`buildFieldMeta`, both strategies). The dataset door corrects that in its + * ADR-0021 enrichment (`enrichResultColumns`); the cube door described a + * `min` / `max` over a temporal column as a number, in the same response that + * carried the instant. ⛔ No copy of the rule: this asks it with the cube + * measure's aggregate and the declared type of the base-object column it reads, + * and writes only what it answers. It answers `undefined` for every pair it has + * nothing to say about — the numeric and boolean classes, the count / sum / avg + * rows, an expression metric type — and for every pair the aggregate × + * field-type table refuses, which the cube door has refused before any strategy + * ran ({@link assertCubeMeasureFieldTypesAccepted}). A relationship-path column + * is not described: the declaration this reads is the base object's. + * + * Copy-on-write, like the format pass: the strategy owns the object it + * returned. + */ +function withMeasureResultTypes( + result: AnalyticsResult, + query: AnalyticsQuery, + cube: Cube | undefined, + declaredTypeOf: ((object: string, field: string) => string | undefined) | undefined, +): AnalyticsResult { + if (!declaredTypeOf || !cube || !result?.fields?.length || !query.measures?.length) return result; + const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!object || !BARE_IDENTIFIER.test(object)) return result; + const requested = new Set(query.measures); + let fields: AnalyticsResult['fields'] | undefined; + result.fields.forEach((f, i) => { + if (!requested.has(f.name)) return; + const measure = declaredMeasureColumn(cube, f.name); + if (!measure?.column || typeof measure.type !== 'string' || !AGGREGATION_FUNCTIONS.has(measure.type)) return; + const type = measureResultType(measure.type as AggregationFunction, declaredTypeOf(object, measure.column)); + if (type === undefined || f.type === type) return; + fields ??= [...result.fields]; + fields[i] = { ...f, type }; + }); + return fields ? { ...result, fields } : result; +} + /** * Configuration for AnalyticsService. */ @@ -1862,8 +1933,20 @@ export class AnalyticsService implements IAnalyticsService { // `analytics_cube.measures.format` is described at the same seam, for // the same reason: whichever strategy answered, the measure columns // leave with the format the cube declares. + // + // [#21044] …and with the `type` the dataset door's one rule gives a + // `min` / `max` over a temporal column (`time`), read off the cube + // measure and the column's declaration, never off the rows. const result = await strategy.execute(query, ctx); - return this.applySqlEchoPolicy(withDeclaredMeasureFormats(result, query, scope.getCube(query.cube!))); + const cube = scope.getCube(query.cube!); + return this.applySqlEchoPolicy( + withMeasureResultTypes( + withDeclaredMeasureFormats(result, query, cube), + query, + cube, + this.sourceFieldMeta && ((object, field) => this.sourceFieldMeta?.(object, field)?.type), + ), + ); } catch (e) { if ((e as { code?: string })?.code === 'RAW_SQL_UNSUPPORTED') { this.logger.warn( @@ -2705,6 +2788,9 @@ export class AnalyticsService implements IAnalyticsService { this.assertDimensionFields(query, cube, Object.keys(cube.dimensions)); // [#20807] …and a grouped dimension's column must not be structured JSON. this.assertDimensionsGroupScalarColumns(query, cube); + // [#21044] …and a measure's aggregate must be one the aggregate × + // field-type table accepts for its column's declared type. + this.assertMeasureFieldTypes(query, cube); // [#5669] …and the `where`'s, third and last of the three request keys that // carry a field name. Its members are read from the filter TREE, not from // `cube.dimensions` — which on this path was minted from this very query, @@ -2777,6 +2863,9 @@ export class AnalyticsService implements IAnalyticsService { // the rejection suggests. this.assertDimensionFields(query, augmented, Object.keys(cube.dimensions)); this.assertDimensionsGroupScalarColumns(query, augmented); + // [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an + // authored one are one population for the aggregate × field-type table. + this.assertMeasureFieldTypes(query, augmented); // [#5669] The `where` gate resolves a filter member through dimensions AND // measures (that is what the strategies do for a filter member), so it is // handed the AUGMENTED cube — a caller filtering on a suffix-inferred @@ -2792,10 +2881,47 @@ export class AnalyticsService implements IAnalyticsService { this.assertMeasureFields(query, cube, Object.keys(cube.measures)); this.assertDimensionFields(query, cube, Object.keys(cube.dimensions)); this.assertDimensionsGroupScalarColumns(query, cube); + this.assertMeasureFieldTypes(query, cube); this.assertWhereFields(query, cube, Object.keys(cube.dimensions)); } } + /** + * [#21044] Refuse a `measures` entry whose aggregate the aggregate × + * field-type table (`AGGREGATE_FIELD_TYPE_COMPATIBILITY`) refuses for its + * column's declared type — `INVALID_FIELD` / 400, naming the member the + * caller wrote — before either strategy reads anything. The rule, the tiers, + * the measurements and the envelope are + * {@link assertCubeMeasureFieldTypesAccepted}'s + * (`cube-measure-field-type-door.ts`); this method supplies the two answers + * only the service has: + * + * - The measure and column a member resolves to are + * {@link declaredMeasureColumn}'s — {@link declaredMemberEntry}, the + * resolver {@link withDeclaredMeasureFormats} reads — and nothing else + * resolves a member to a field here. + * - The column's declared type is {@link AnalyticsServiceConfig.sourceFieldMeta}'s, + * the declaration the #20807 / #20912 door reads beside it. + * + * Runs after that door on every `ensureCube` path, so a `count_distinct` + * measure keeps its own verdict and a member naming a column the object does + * not have is answered as that first. Same stand-downs as that door: no + * `sourceFieldMeta`, or a cube whose `sql` is not a bare object name. + */ + private assertMeasureFieldTypes(query: AnalyticsQuery, cube: Cube): void { + const fieldMeta = this.sourceFieldMeta; + if (!fieldMeta) return; + const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!object || !BARE_IDENTIFIER.test(object)) return; + assertCubeMeasureFieldTypesAccepted( + query, + cube.name, + object, + (member) => declaredMeasureColumn(cube, member), + (o, field) => fieldMeta(o, field)?.type, + ); + } + /** * [#20807] Refuse a GROUPED dimension whose column is a declared * structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller diff --git a/packages/services/service-analytics/src/cube-measure-field-type-door.ts b/packages/services/service-analytics/src/cube-measure-field-type-door.ts new file mode 100644 index 00000000000..6c7bdce48e4 --- /dev/null +++ b/packages/services/service-analytics/src/cube-measure-field-type-door.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21044] The cube door's measure × field-type judgment: a `measures` entry + * whose aggregate `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/ + * data`) refuses for its column's declared type is refused `INVALID_FIELD` / + * 400, ahead of strategy selection — the table the dataset door executes at + * compile, asked at the door every cube query passes. + * + * ## The shape this closes + * + * Measured through `POST /api/v1/analytics/query` on the real dispatcher route, + * at `2821e9f15b`, a configured cube over two rows (`note` `x` / `y`): + * + * | measure | face | SQLite | PostgreSQL 16.13 | + * |:--|:--|:--|:--| + * | `max` / `min` over `text`, `max` over `select` | `NativeSQLStrategy` | 200, the text (`"y"`), `fields[]` `number` | the same | + * | the same | `ObjectQLStrategy` | 400 `INVALID_FIELD`, the engine's door | the same | + * | `sum` over `text` | both | 200, `0`, `fields[]` `number` | 500 `DATABASE_ERROR` | + * | `avg` over `text` | `NativeSQLStrategy` | 200, `0` | 500 `DATABASE_ERROR` | + * | `avg` over `text` | `ObjectQLStrategy` | 400 `INVALID_FIELD`, the engine's door | the same | + * + * The dataset door refuses every one of these pairs at compile + * (`DATASET_INVALID` / 400, `dataset-compiler.ts`); the engine's aggregate door + * refuses some of them on the ObjectQL face, after the strategy has begun (it + * holds its `sum` row); the native face asked nothing. So one cube answered the + * same query by which strategy the driver selected and which dialect it spoke — + * a text value under a column described `number`, a plausible `0`, a 400 or a + * 500. + * + * ## Where it stands + * + * In `AnalyticsService.ensureCube`, on every path out of it, right after the + * #20807 / #20912 door (`structured-json-dimension-door.ts`): ahead of + * `callCtx` and strategy selection, for `query()` (the `/analytics/query` + * door, and every query a dataset selection runs through `DatasetExecutor`) + * and for `generateSql()` (the `/analytics/sql` dry run) alike. One door ahead + * of both strategies gives one answer on every driver, and nothing is read + * before it answers. + * + * ## What it judges + * + * - Every `measures` entry that resolves to a cube measure whose `type` is an + * `AggregationFunction` row of the table — every row, as the dataset door + * judges every row (decision batch #127), with no scope condition on top of + * the table (`count_distinct` through its own door, below) — and an authored measure, a suffix-inferred one (`note_max`) + * and a compiled dataset's are one population. The member is resolved by the + * CALLER's resolver (the same one `withDeclaredMeasureFormats` reads), never + * a second one here. + * - The measure's column is its `sql` when that is a bare identifier: a column + * of the cube's own object, whose declaration the door already reads + * (`sourceFieldMeta`). + * - The verdict is `isAggregateCompatibleWithFieldType`'s. ⛔ No row is + * restated here: the accepted set the words name is read off the exported + * table, so a row changed in the spec changes this refusal in the same + * commit. + * + * `count_distinct` is judged by its own door (#20912, the module named above), + * which asks the same row AND the declaration half the per-type row cannot see + * (`isMultiValueField`): one verdict per pair, never two doors with two + * wordings. The `sum` / `avg` / `min` / `max` rows accept no multi-capable + * type, so the declaration half adds nothing to them, and `count` accepts every + * type. + * + * ## Not judged — "cannot answer, do not block", the dataset door's tiers + * + * - A host that wires no `sourceFieldMeta`, or a cube whose `sql` is not a + * bare object name (the caller stands down for both). + * - A member that resolves to no declared measure (the source-field gate's). + * - A measure type outside the table's vocabulary: the expression metric types + * (`number` / `string` / `boolean`). + * - A RELATIONSHIP-PATH column (`account.name`): the declaration the door + * reads is the base object's, so it would answer about a different column of + * the same name, or about nothing. The spec module says exactly this ("a + * consumer that cannot resolve a field's type must NOT call the predicate + * with a guess"). + * - A column the declaration hook cannot resolve, or a type outside + * `FieldType` (a driver-internal alias): the table is fail-closed on + * vocabulary, and refusing on it would refuse a pair nobody declared. + * + * ## The envelope, and why it is not the dataset door's code + * + * `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the + * column and its object attached, as the #20912 door attaches them. The + * dataset door answers the same pair `DATASET_INVALID`, which is a verdict + * about a dataset DOCUMENT (`dataset-refusal.ts`'s header): this door's caller + * sent no dataset, and a verdict about ONE MEMBER the request named is the + * `INVALID_FIELD` family — the code the cube door's three source-field gates, + * its #20912 `count_distinct` door and the engine's aggregate door already + * answer, the last one for this very pair on the ObjectQL face. The dataset + * door never reaches this one for a pair it refuses: it refuses at compile. + */ + +import { + AGGREGATE_FIELD_TYPE_COMPATIBILITY, + FieldType, + isAggregateCompatibleWithFieldType, +} from '@objectstack/spec/data'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { invalidMemberError } from './dataset-refusal.js'; + +/** The declared `FieldType` vocabulary — the only types the table can answer for. */ +const DECLARED_FIELD_TYPES: ReadonlySet = new Set(FieldType.options); + +/** The aggregates this door judges: every row of the table but `count_distinct`, whose own door judges it. */ +const isJudgedAggregate = (type: unknown): type is keyof typeof AGGREGATE_FIELD_TYPE_COMPATIBILITY => + typeof type === 'string' + && type !== 'count_distinct' + && Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, type); + +/** + * WHY the pair has no backend-independent answer, chosen by what the aggregate + * does with the values — an explanation, never a verdict, and the LAST sentence + * of the words, so a door that bounds a 4xx message cuts it first. + */ +const reasonFor = (aggregate: string): string => + aggregate === 'min' || aggregate === 'max' + ? 'String order is collation-dependent and some stored forms have no order at all, so each SQL ' + + 'dialect would pick its own value.' + : 'Each SQL dialect would coerce the stored form to a number or fail, so the answer would depend on ' + + 'the backend.'; + +/** + * Refuse the first `measures` entry of `query` whose aggregate the table + * refuses for its column's declared type — `INVALID_FIELD` / 400. See the + * module header. + * + * @param baseObject - The object `cube.sql` names (the caller has checked it is + * a bare object name). + * @param measureOf - The cube measure a `measures` entry resolves to, by the + * caller's resolver: its `type`, and the base-object `column` it aggregates + * (`null` when its `sql` is not a bare identifier) — or `undefined` when the + * entry resolves to no declared measure. + * @param declaredTypeOf - The declared `FieldType` of a column on an object, or + * `undefined` when nothing authoritative answers. + * + * The words put the verdict first, then that the query did not run, then the + * accepted set read off the table, then the reason: a door that bounds a 4xx + * message keeps the front of it. + */ +export function assertCubeMeasureFieldTypesAccepted( + query: AnalyticsQuery, + cubeName: string, + baseObject: string, + measureOf: (member: string) => { type: unknown; column: string | null } | undefined, + declaredTypeOf: (object: string, field: string) => string | undefined, +): void { + for (const member of query.measures ?? []) { + const measure = measureOf(member); + if (!measure?.column) continue; + const aggregate = measure.type; + if (!isJudgedAggregate(aggregate)) continue; + const column = measure.column; + const declared = declaredTypeOf(baseObject, column); + if (typeof declared !== 'string' || !DECLARED_FIELD_TYPES.has(declared)) continue; + if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue; + + const err = invalidMemberError( + `Measure '${member}' on cube '${cubeName}' takes the ${aggregate} of field '${column}', which object ` + + `'${baseObject}' declares as ${declared}: ${aggregate} does not accept that type, so the query was NOT ` + + `run. ${aggregate} accepts ${AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate].join(', ')}; aggregate a ` + + `field of one of those types, or count the rows with count. ${reasonFor(aggregate)}`, + { member, param: 'measures', cube: cubeName }, + ) as Error & { field?: string; object?: string }; + err.field = column; + err.object = baseObject; + throw err; + } +} diff --git a/packages/services/service-analytics/src/measure-result-type.ts b/packages/services/service-analytics/src/measure-result-type.ts index 1d18c7768ea..258793275be 100644 --- a/packages/services/service-analytics/src/measure-result-type.ts +++ b/packages/services/service-analytics/src/measure-result-type.ts @@ -37,6 +37,14 @@ import { isAggregateCompatibleWithFieldType } from '@objectstack/spec/data'; * ⛔ There is therefore no second account of which pairs are supported in this * package. A row changed in the spec changes this rule in the same commit. * + * [#21044] The cube door reads this rule too. `POST /analytics/query` asks the + * same table ahead of both strategies (`cube-measure-field-type-door.ts`, a + * refused pair answers `INVALID_FIELD` / 400 before anything is read), and + * describes a served measure column by this function at the seam every + * strategy's result leaves through (`withMeasureResultTypes` in + * `analytics-service.ts`), with the cube measure's aggregate and the declared + * type of the base-object column it reads. Two doors, one rule, one table. + * * ## The aggregate axis, enumerated rather than sampled * * `AggregationFunction` (`spec/data/query.zod.ts`) is a CLOSED vocabulary, so