From eea34c7d2a83c14a6b01af0b61c31116c759e7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 01:17:54 +0000 Subject: [PATCH 1/2] fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cube query carrying a `timeDimensions[].granularity` answered 500 DATABASE_ERROR. The bucket expression was never the fault: an analytics measure is addressed on the wire as `.` and `ObjectQLStrategy` uses that dotted name verbatim as the driver-level aggregation `alias`, and this face bound the alias through knex's `??` placeholder — which parses an identifier rather than quoting one, splitting on `.` into `table.column`. The statement reached the database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it ran. The granularity was the router, not the fault: `NativeSQLStrategy.canHandle` declines exactly on a granularity and that face already hand-wrote `AS ""`, so an un-bucketed cube query never reached this door while a bucketed one always did — which is why the reporter's controls were 200. `aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same function knex calls on each segment it split out, so only the segmentation goes away. Applied at all five alias positions on the aggregate/window builders. Column references still bind through `??` and may still be qualified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- ...alytics-timedimension-granularity-alias.md | 23 ++ ...-aggregate-alias-single-identifier.test.ts | 307 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 70 +++- ...-timedimension-granularity.dogfood.test.ts | 223 +++++++++++++ .../analytics-timedimension-fixture.ts | 65 ++++ 5 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 .changeset/analytics-timedimension-granularity-alias.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts create mode 100644 packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts diff --git a/.changeset/analytics-timedimension-granularity-alias.md b/.changeset/analytics-timedimension-granularity-alias.md new file mode 100644 index 0000000000..4ae886e574 --- /dev/null +++ b/.changeset/analytics-timedimension-granularity-alias.md @@ -0,0 +1,23 @@ +--- +"@objectstack/driver-sql": patch +--- + +Fix a `500 DATABASE_ERROR` on any analytics cube query that buckets a measure by +a time-dimension granularity (`"count by month"` and every other +`timeDimensions[].granularity` shape). + +An analytics measure is addressed on the wire as `.`, and that +dotted name is used verbatim as the driver-level aggregation `alias` — it is the +key the caller reads its own number back under. `driver-sql` bound the alias +through knex's `??` placeholder, which does not quote an identifier so much as +parse one: it splits the value on `.` into `table.column` and re-quotes each +segment. The statement therefore reached the database as +``count(*) as `showcase_delivery`.`count` `` — not valid SQL on any dialect — and +was refused before it ran. + +The granularity was the router rather than the fault: `NativeSQLStrategy` +declines exactly on a granularity, so an un-bucketed cube query was served by the +native face (which already emitted the alias correctly) while a bucketed one fell +through to this door. Aliases are now emitted as a single dialect-quoted +identifier at every alias position on the aggregate and window-function builders; +column *references* still bind through `??` and may still be qualified. diff --git a/packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts b/packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts new file mode 100644 index 0000000000..96ff0e50f0 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#13714 — an aggregate ALIAS is one identifier, never a qualified + * reference. + * + * ## The field report, and what it actually measured + * + * A running showcase deployment (better-sqlite3) reported: + * + * ``` + * POST /api/v1/analytics/query + * { "cube": "showcase_delivery", + * "measures": ["showcase_delivery.count"], + * "timeDimensions": [{ "dimension": "showcase_delivery.due_date", + * "granularity": "month" }] } + * → 500 DATABASE_ERROR + * ``` + * + * with controls from the same session that SUCCEED: the same measure by + * `dimensions: ["showcase_delivery.status"]` → 200 with buckets reconciling + * against `/data`, a second measure by status → 200, and malformed bodies → 400 + * at the entry validator. The filer's hypothesis was a date-truncation SQL + * emission problem on the granularity clause. + * + * ⚠️ That hypothesis is FALSIFIED, and the disproof is the whole point of this + * suite. Measured on `origin/main` `62a137bae`, better-sqlite3, one driver, two + * axes crossed: + * + * ``` + * alias 'n', granularity day/month/quarter/year → OK + * alias 'showcase_delivery.count', NO granularity at all → 500 + * alias 'showcase_delivery.count', granularity month → 500 + * + * select strftime('%Y-%m', `due_date`) as `due_date`, + * count(*) as `showcase_delivery`.`count` + * from `zz_repro_task` group by strftime('%Y-%m', `due_date`) + * - near ".": syntax error + * ``` + * + * The bucket expression is FINE on every row of that table. What the backend + * refuses is the ALIAS: knex's `??` binding does not quote an identifier, it + * PARSES one — `wrapString` splits on `.` into `table.column` and re-quotes each + * segment — so a dotted alias compiled into a qualified reference in the alias + * position. See {@link SqlDriver.aliasIdentifierSql} for the fix and why + * `client.wrapIdentifier` is the same function knex itself calls per segment. + * + * ## Why the granularity is the ROUTER, not the fault + * + * Every analytics measure is named `.` on the wire and + * `ObjectQLStrategy` uses that name verbatim as the aggregation `alias` — it is + * the key the caller reads its own number back under. So every cube query + * reaching THIS face carries a dotted alias. It only shows under a granularity + * because `NativeSQLStrategy.canHandle` declines exactly on + * `timeDimensions[].granularity` (native-sql-strategy.ts) and that face + * hand-writes `AS ""` — one quoted identifier, already correct. So the + * reported controls are 200 because they never reach this door, and the bucketed + * query is a 500 because it does. That fork is pinned end to end, over HTTP, + * by `packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts`. + * + * ## Why the existing date-bucket pins are green while production 500s + * + * `#3773 date-bucket-parity` and `#3839 empty-group-parity` compare the pushed + * down bucket against `applyInMemoryAggregation`. Their reference side keys rows + * by the alias as a plain JS object key, where a dot is inert, and their probe + * aliases are bare names — so the one input that breaks the SQL face is the one + * input they never supply. They are a DECLARED CONTROL for this card (they must + * stay green), not evidence about it. + * + * ## The dialect question, answered structurally + * + * `wrapString`'s split lives in knex's shared formatter, not in a dialect, so + * the defect is dialect-independent by construction — and this suite runs the + * whole matrix through {@link declareDialectCell} rather than asserting that + * from the source: SQLite runs embedded, Postgres and MySQL run live when + * provisioned and are reported as a NAMED UNPROVISIONED CELL otherwise (never a + * silent skip). What each dialect quotes with differs (`"` on Postgres, + * backticks on MySQL/SQLite) and that is exactly what a per-cell run measures. + * + * ## Every granularity, not just `month` + * + * The report names `month`; the impact statement names month/week/day together. + * A pin on `month` alone leaves the rest open, so the sweep below is driven by + * `DateGranularity.options` — the spec's own list — and a granularity the spec + * grows joins it without an edit here. + * + * ⚠️ A dialect that does not bucket a granularity natively (SQLite + `week`, + * which is capped in `dateGranularityCapabilities` because `%V` needs SQLite + * 3.46) is NOT a failure and is asserted as its own declared answer: the #6212 + * `NOT_IMPLEMENTED`/501 capability refusal, which `engine.aggregate` reads off + * `supports.queryDateGranularity` and serves in memory instead. The invariant + * that spans both answers is the one this card is about: no shape answers + * `DATABASE_ERROR`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { DateGranularity } from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'alias_ident_task'; + +/** + * The wire spelling of an analytics measure — `.` — used verbatim + * as the aggregation alias by `ObjectQLStrategy`. The dot is the whole defect. + */ +const CUBE_QUALIFIED_MEASURE = 'showcase_delivery.count'; +const CUBE_QUALIFIED_DIMENSION = 'showcase_delivery.due_date'; + +/** Two months, so a `month` bucket that works has something to separate. */ +const ROWS = [ + { id: 'a1', status: 'open', due_date: '2026-01-15', closed_at: '2026-01-15T10:00:00.000Z', hours: 2 }, + { id: 'a2', status: 'open', due_date: '2026-01-20', closed_at: '2026-01-20T10:00:00.000Z', hours: 3 }, + { id: 'a3', status: 'done', due_date: '2026-02-05', closed_at: '2026-02-05T10:00:00.000Z', hours: 4 }, +]; + +async function caught(run: () => Promise): Promise { + try { + await run(); + } catch (err) { + return err; + } + return null; +} + +function declareSweep(cell: DialectCell): void { +describe(`[#13714] driver-sql — an aggregate alias is ONE identifier (${cell.label})`, () => { + let driver: SqlDriver; + /** What this dialect PUBLISHES, which is what the engine dispatches on. */ + let caps: Record; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { + name: TABLE, + fields: { + status: { type: 'string' }, + due_date: { type: 'date' }, + closed_at: { type: 'datetime' }, + hours: { type: 'number' }, + }, + }, + ] as never); + for (const row of ROWS) await driver.create(TABLE, { ...row }, { bypassTenantAudit: true } as never); + caps = ((driver as unknown as { supports: { queryDateGranularity?: Record } }) + .supports.queryDateGranularity ?? {}); + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + // ─────────────────────────────────────────────────────────────── + // THE CARD — the reported shape, for EVERY granularity the spec declares + // ─────────────────────────────────────────────────────────────── + + for (const granularity of DateGranularity.options) { + it(`granularity '${granularity}' with a cube-qualified measure alias is SERVED, not a DATABASE_ERROR`, async () => { + const query = { + groupBy: [{ field: 'due_date', dateGranularity: granularity }], + aggregations: [{ function: 'count', alias: CUBE_QUALIFIED_MEASURE }], + } as unknown as DriverQuery; + + const err = await caught(() => driver.aggregate(TABLE, query)); + + // ⛔ The one answer this card forbids on every cell, whether or not this + // dialect buckets this granularity: a backend fault for a query that is + // spelled correctly. + expect(err?.code, `${granularity}: must not be a backend fault`).not.toBe('DATABASE_ERROR'); + + if (caps[granularity] !== true) { + // The declared capability gap (#6212). `engine.aggregate` never sends + // this granularity here — it reads the same record and buckets in + // memory — so this is the DIRECT-caller answer, and it is a refusal + // about the BACKEND, never about the request. + expect(err?.code, `${granularity}: declined natively → the #6212 refusal`).toBe('NOT_IMPLEMENTED'); + expect(err?.status).toBe(501); + return; + } + + expect(err, `${granularity}: served natively, so nothing may throw`).toBeNull(); + const rows = (await driver.aggregate(TABLE, query)) as Array>; + + // The alias arrives as ONE column, spelled exactly as the caller wrote + // it. Before the fix the statement never ran at all; a compiled + // `as "showcase_delivery"."count"` would also have keyed the row under + // `count`, which is the silent half of the same defect. + for (const row of rows) { + expect(Object.keys(row), `${granularity}: the caller's own key`).toContain(CUBE_QUALIFIED_MEASURE); + } + const total = rows.reduce((sum, r) => sum + Number(r[CUBE_QUALIFIED_MEASURE] ?? 0), 0); + expect(total, `${granularity}: every row counted exactly once`).toBe(ROWS.length); + }); + } + + it("the buckets are the RIGHT buckets — 'month' separates the two months", async () => { + if (caps.month !== true) return; // asserted as the 501 above on such a cell + const rows = (await driver.aggregate(TABLE, { + groupBy: [{ field: 'due_date', dateGranularity: 'month' }], + aggregations: [{ function: 'count', alias: CUBE_QUALIFIED_MEASURE }], + } as unknown as DriverQuery)) as Array>; + + const byBucket = new Map(rows.map((r) => [String(r.due_date), Number(r[CUBE_QUALIFIED_MEASURE])])); + expect(byBucket.get('2026-01')).toBe(2); + expect(byBucket.get('2026-02')).toBe(1); + }); + + // ─────────────────────────────────────────────────────────────── + // THE FAULT IS THE ALIAS, NOT THE BUCKET — the axis the card turns on + // ─────────────────────────────────────────────────────────────── + + it('a cube-qualified alias with NO granularity at all is served too', async () => { + // This is the shape that proves the granularity is a ROUTER: it carries no + // date bucket whatsoever and it failed identically before the fix. A repair + // aimed at `buildDateBucketExpr` would leave this red. + const rows = (await driver.aggregate(TABLE, { + groupBy: ['status'], + aggregations: [{ function: 'count', alias: CUBE_QUALIFIED_MEASURE }], + } as unknown as DriverQuery)) as Array>; + + expect(rows.map((r) => Number(r[CUBE_QUALIFIED_MEASURE])).sort()).toEqual([1, 2]); + }); + + it('a cube-qualified groupBy ALIAS projects one column as well', async () => { + // `GroupByNodeSchema.alias` (#6401) is the twin alias position on this door, + // and it broke the same way. Bucketed, because that is where a dashboard + // actually writes one. + if (caps.month !== true) return; + const rows = (await driver.aggregate(TABLE, { + groupBy: [{ field: 'due_date', alias: CUBE_QUALIFIED_DIMENSION, dateGranularity: 'month' }], + aggregations: [{ function: 'count', alias: 'n' }], + } as unknown as DriverQuery)) as Array>; + + expect(rows.map((r) => String(r[CUBE_QUALIFIED_DIMENSION])).sort()).toEqual(['2026-01', '2026-02']); + }); + + it('an UNBUCKETED cube-qualified groupBy alias projects one column too', async () => { + const rows = (await driver.aggregate(TABLE, { + groupBy: [{ field: 'status', alias: 'showcase_delivery.status' }], + aggregations: [{ function: 'count', alias: 'n' }], + } as unknown as DriverQuery)) as Array>; + + expect(rows.map((r) => String(r['showcase_delivery.status'])).sort()).toEqual(['done', 'open']); + }); + + it('an alias containing " as " is one column, not a value plus a second alias', async () => { + // knex's `wrapString` splits on a literal `" as "` before it splits on `.`, + // so this is the second half of "the binding PARSES rather than quotes". + const alias = 'hours as billed'; + const rows = (await driver.aggregate(TABLE, { + aggregations: [{ function: 'sum', field: 'hours', alias }], + } as unknown as DriverQuery)) as Array>; + + expect(Object.keys(rows[0])).toEqual([alias]); + expect(Number(rows[0][alias])).toBe(9); + }); + + // ─────────────────────────────────────────────────────────────── + // THE CONTROLS — what must NOT move + // ─────────────────────────────────────────────────────────────── + + it('a bare alias is unchanged — same column name, same numbers', async () => { + const rows = (await driver.aggregate(TABLE, { + groupBy: ['status'], + aggregations: [{ function: 'count', alias: 'n' }, { function: 'sum', field: 'hours', alias: 'total' }], + } as unknown as DriverQuery)) as Array>; + + const byStatus = new Map(rows.map((r) => [String(r.status), r])); + expect(Number(byStatus.get('open')!.n)).toBe(2); + expect(Number(byStatus.get('open')!.total)).toBe(5); + expect(Number(byStatus.get('done')!.n)).toBe(1); + expect(Number(byStatus.get('done')!.total)).toBe(4); + }); + + it('an alias EQUAL to the field still emits no self-rename', async () => { + // The `outKey === g.field` shortcut is deliberately untouched by the fix; + // rewriting `select "status"` into `select "status" as "status"` would be a + // gratuitous statement change on every dialect. + const rows = (await driver.aggregate(TABLE, { + groupBy: [{ field: 'status', alias: 'status' }], + aggregations: [{ function: 'count', alias: 'n' }], + } as unknown as DriverQuery)) as Array>; + + expect(rows.map((r) => String(r.status)).sort()).toEqual(['done', 'open']); + }); + + it('a column REFERENCE may still be qualified — only the alias is one name', async () => { + // ⛔ The fence. `field` goes on being a `??` binding, so a qualified + // reference still resolves; "quote the whole string" applied to references + // would break every legitimate `table.column` this driver emits. + const rows = (await driver.aggregate(TABLE, { + groupBy: [`${TABLE}.status`], + aggregations: [{ function: 'count', alias: CUBE_QUALIFIED_MEASURE }], + } as unknown as DriverQuery)) as Array>; + + expect(rows.map((r) => Number(r[CUBE_QUALIFIED_MEASURE])).sort()).toEqual([1, 2]); + }); +}); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, '#13714 aggregate alias identity', declareSweep); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 9e8a58e60c..32687ef263 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -4569,6 +4569,61 @@ export class SqlDriver implements IDataDriver { * Granularities not listed (or set to false) fall back to in-memory bucketing * via engine.findData → applyInMemoryAggregation. */ + /** + * [#13714] The caller's OUTPUT COLUMN NAME, quoted as exactly ONE identifier. + * + * ## The defect this exists for + * + * Knex's `??` binding does not quote an identifier — it PARSES one. Its + * `wrapString` splits the value on `.` into `table.column` segments (and on a + * literal `" as "` into value + alias) and re-quotes each piece separately. + * That is right for a column REFERENCE, which may legitimately be qualified, + * and wrong for an ALIAS, which is a single name the caller reads its own + * result back under. Every alias site here used `?? `, so an alias carrying a + * dot compiled to a qualified reference in the alias position: + * + * ``` + * select strftime('%Y-%m', `due_date`) as `due_date`, + * count(*) as `showcase_delivery`.`count` ← not valid SQL anywhere + * from `showcase_task` group by strftime('%Y-%m', `due_date`) + * -- near ".": syntax error (better-sqlite3, measured) + * ``` + * + * The statement never runs, so the caller gets `DATABASE_ERROR`/500 from + * {@link SqlDriver.aggregate}'s terminal envelope (#11455) — a backend fault + * for a query that is spelled correctly. + * + * ## Why analytics hits this and almost nothing else does + * + * An analytics measure is named `.` on the wire, and + * `ObjectQLStrategy` uses that name verbatim as the aggregation `alias` — + * because it is the key the caller reads the number back under. So EVERY cube + * query that reaches this face carries a dotted alias. It only shows when a + * `timeDimensions[].granularity` is present, and the granularity is the ROUTER + * rather than the fault: `NativeSQLStrategy.canHandle` declines exactly on a + * granularity, and that native face hand-writes `AS ""` — one quoted + * identifier, correct — so every un-bucketed cube query is served by the face + * that already got this right. That fork is why the field report's controls + * are 200 while `granularity: 'month'` is a 500, and why the date-bucket + * parity pins are green: their reference side keys rows by the alias as a + * plain JS object key, where a dot is inert. + * + * ## Why this is not "quote it ourselves" + * + * `client.wrapIdentifier` is the SAME function knex calls on each segment it + * split out, so this changes nothing but the segmentation: the dialect's own + * quoting and quote-doubling still apply (`"` on Postgres, backticks on MySQL + * and SQLite), and a host-supplied `wrapIdentifier` config hook is still + * honoured. An alias is therefore never grammar — it stays a quoted + * identifier, exactly as it was before, minus the split. + * + * ⛔ NOT for column references. `field` may be qualified and MUST keep going + * through `??`; only the name after `as` is one identifier by definition. + */ + protected aliasIdentifierSql(alias: string): string { + return (this.knex.client as { wrapIdentifier(value: string): string }).wrapIdentifier(String(alias)); + } + protected get dateGranularityCapabilities(): Record { if (this.isPostgres) { return { day: true, month: true, quarter: true, year: true, week: true }; @@ -8160,13 +8215,16 @@ export class SqlDriver implements IDataDriver { ); } builder.groupByRaw(bucket.sql, bucket.bindings); - builder.select(this.knex.raw(`${bucket.sql} as ??`, [...bucket.bindings, outKey])); + builder.select(this.knex.raw(`${bucket.sql} as ${this.aliasIdentifierSql(outKey)}`, [...bucket.bindings])); } else { builder.groupBy(g.field); - // `?? as ??` only when the name actually moves: an alias equal to + // Aliased only when the name actually moves: an alias equal to // the field would otherwise rewrite `select "region"` into // `select "region" as "region"` on every dialect for no gain. - builder.select(outKey === g.field ? g.field : this.knex.raw('?? as ??', [g.field, outKey])); + // [#13714] The alias half is {@link aliasIdentifierSql}, never a + // `??` binding — the FIELD stays a `??` reference (it may be + // qualified), the alias is one identifier by definition. + builder.select(outKey === g.field ? g.field : this.knex.raw(`?? as ${this.aliasIdentifierSql(outKey)}`, [g.field])); // Keyed by the OUTPUT column, like the aggregation branch below — // `presentReadColumns` matches on the name the row actually // carries, so an aliased group value went unpresented before. @@ -8239,9 +8297,9 @@ export class SqlDriver implements IDataDriver { : `${lowering.sql}(${argExpr})`; if (agg.alias) { if (fieldExpr === '*') { - builder.select(this.knex.raw(`${lowering.sql}(*) as ??`, [agg.alias])); + builder.select(this.knex.raw(`${lowering.sql}(*) as ${this.aliasIdentifierSql(agg.alias)}`)); } else { - builder.select(this.knex.raw(`${rawFunc} as ??`, [fieldExpr, agg.alias])); + builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [fieldExpr])); } // `min`/`max` are the only supported functions that hand back a value // OF the column rather than a count/total derived from it, so they are @@ -8600,7 +8658,7 @@ export class SqlDriver implements IDataDriver { if (query.windowFunctions && Array.isArray(query.windowFunctions)) { for (const wf of query.windowFunctions) { const windowFunc = this.buildWindowFunction(wf); - builder.select(this.knex.raw(`${windowFunc} as ??`, [wf.alias])); + builder.select(this.knex.raw(`${windowFunc} as ${this.aliasIdentifierSql(wf.alias)}`)); } } diff --git a/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts b/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts new file mode 100644 index 0000000000..b272879ac2 --- /dev/null +++ b/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#13714 — a cube query with a `timeDimensions[].granularity` is + * SERVED, over real HTTP, on a real SQL driver. + * + * ## Why this suite exists at THIS layer + * + * The card is a field report from a running deployment, and it names the trap + * itself: the date-bucket parity pins (#3773 date-bucket-parity, #3839 + * empty-group-parity) were GREEN the whole time the endpoint answered 500, + * because they exercise bucketing at a layer that never carries what actually + * breaks. So a repair verified only at that layer cannot reproduce the defect, + * and this file is the half that can: `POST /api/v1/analytics/query`, through + * the real Hono app, the real analytics strategy fork, the real + * `engine.aggregate` dispatch and a real SQL driver. + * + * ## The reported request, and the fork underneath it + * + * ``` + * POST /api/v1/analytics/query + * { "cube": "showcase_delivery", + * "measures": ["showcase_delivery.count"], + * "timeDimensions": [{ "dimension": "showcase_delivery.due_date", + * "granularity": "month" }] } + * → 500 DATABASE_ERROR + * ``` + * + * A measure is addressed on the wire as `.`, and + * `ObjectQLStrategy` uses that dotted name verbatim as the driver-level + * aggregation `alias` — it is the key the caller reads its own number back + * under. `driver-sql` then bound that alias through knex's `??`, which SPLITS a + * dotted identifier into `table.column`, so the statement reached the database + * as `count(*) as \`showcase_delivery\`.\`count\`` and was refused before it ran. + * + * ⚠️ The granularity is the ROUTER, not the fault. `NativeSQLStrategy.canHandle` + * declines exactly on `timeDimensions[].granularity`, and that face hand-writes + * `AS ""` — one quoted identifier, already correct. That is precisely + * why the report's controls (same cube, same measure, `dimensions` instead of a + * granularity) answered 200 while the bucketed query answered 500, and both + * halves are asserted here so the fork itself stays pinned. + * + * ## Coverage boundary, stated rather than implied + * + * `bootStack` runs `driver-sqlite-wasm`, which inherits `aggregate()` from + * `SqlDriver`; the report was filed against `better-sqlite3`, which inherits the + * same method. The per-dialect measurement — better-sqlite3 embedded, Postgres + * and MySQL live — is + * `packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts`, + * which runs the live-dialect matrix. What THIS file adds that no driver suite + * can is the road: that a granularity really does route off the native face onto + * this door, over HTTP, in a booted app. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { DateGranularity } from '@objectstack/spec/data'; +import { tdFixtureStack, TdDeliveryCube } from './fixtures/analytics-timedimension-fixture.js'; + +const CUBE = 'td_delivery_cube'; +const COUNT = `${CUBE}.count`; +const HOURS = `${CUBE}.total_estimate_hours`; +const DUE_DATE = `${CUBE}.due_date`; +const STATUS = `${CUBE}.status`; + +/** Two months, three rows — enough for a `month` bucket to have work to do. */ +const SEED = [ + { name: 'jan-a', status: 'open', due_date: '2026-01-15', estimate_hours: 2 }, + { name: 'jan-b', status: 'open', due_date: '2026-01-20', estimate_hours: 3 }, + { name: 'feb-a', status: 'done', due_date: '2026-02-05', estimate_hours: 4 }, +]; + +type AnalyticsBody = { rows?: Array>; error?: unknown; code?: string }; + +describe('dogfood: a cube time-dimension granularity is served, not a 500 (#13714)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(tdFixtureStack as never, { + analytics: new AnalyticsServicePlugin({ cubes: [TdDeliveryCube] }), + }); + token = await stack.signIn(); + + for (const row of SEED) { + const res = await stack.apiAs(token, 'POST', '/data/td_delivery', row); + expect(res.status, `seeding ${row.name}`).toBeLessThan(300); + } + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + const query = async (body: Record): Promise<{ status: number; body: AnalyticsBody }> => { + const res = await stack.apiAs(token, 'POST', '/analytics/query', { cube: CUBE, ...body }); + return { status: res.status, body: (await res.json()) as AnalyticsBody }; + }; + + // ─────────────────────────────────────────────────────────────── + // THE CARD — every granularity the spec declares, not just `month` + // ─────────────────────────────────────────────────────────────── + + for (const granularity of DateGranularity.options) { + it(`granularity '${granularity}' answers 200 and counts every row exactly once`, async () => { + const { status, body } = await query({ + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity }], + }); + + expect(status, `${granularity}: ${JSON.stringify(body).slice(0, 400)}`).toBe(200); + + const rows = body.rows ?? []; + expect(rows.length, `${granularity}: at least one bucket`).toBeGreaterThan(0); + // The caller reads the number back under the name it asked for. A + // response keyed `count` instead would be the silent half of the same + // defect — the alias split leaves the last segment behind. + for (const row of rows) { + expect(Object.keys(row), `${granularity}: the caller's own measure key`).toContain(COUNT); + } + const total = rows.reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); + expect(total, `${granularity}: no row dropped, none double-counted`).toBe(SEED.length); + }); + } + + it("'month' puts the two January rows in one bucket and February in another", async () => { + // The report's exact shape, and the assertion that the buckets are the RIGHT + // buckets rather than merely present — a single collapsed bucket is the + // #3773 failure mode and would satisfy the count total above. + const { status, body } = await query({ + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + expect(status).toBe(200); + const byBucket = new Map( + (body.rows ?? []).map((r) => [String(r[DUE_DATE]).slice(0, 7), Number(r[COUNT])]), + ); + expect(byBucket.get('2026-01')).toBe(2); + expect(byBucket.get('2026-02')).toBe(1); + }); + + it('a bucketed SUM measure answers too — the count is not a special case', async () => { + const { status, body } = await query({ + measures: [HOURS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + expect(status).toBe(200); + const total = (body.rows ?? []).reduce((sum, r) => sum + Number(r[HOURS] ?? 0), 0); + expect(total).toBe(9); + }); + + it('two measures bucketed together keep their own two columns', async () => { + const { status, body } = await query({ + measures: [COUNT, HOURS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + expect(status).toBe(200); + for (const row of body.rows ?? []) { + expect(Object.keys(row)).toEqual(expect.arrayContaining([COUNT, HOURS])); + } + }); + + it('a granularity ALONGSIDE a plain dimension is served', async () => { + const { status, body } = await query({ + measures: [COUNT], + dimensions: [STATUS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + expect(status).toBe(200); + const total = (body.rows ?? []).reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); + expect(total).toBe(SEED.length); + }); + + // ─────────────────────────────────────────────────────────────── + // THE REPORT'S OWN CONTROLS — these were 200 before the fix and must stay 200 + // ─────────────────────────────────────────────────────────────── + + it('CONTROL: measure by a plain dimension still answers 200 and reconciles', async () => { + const { status, body } = await query({ measures: [COUNT], dimensions: [STATUS] }); + + expect(status).toBe(200); + const byStatus = new Map((body.rows ?? []).map((r) => [String(r[STATUS]), Number(r[COUNT])])); + expect(byStatus.get('open')).toBe(2); + expect(byStatus.get('done')).toBe(1); + }); + + it('CONTROL: a second measure by status still answers 200', async () => { + const { status, body } = await query({ measures: [HOURS], dimensions: [STATUS] }); + + expect(status).toBe(200); + const byStatus = new Map((body.rows ?? []).map((r) => [String(r[STATUS]), Number(r[HOURS])])); + expect(byStatus.get('open')).toBe(5); + expect(byStatus.get('done')).toBe(4); + }); + + it('CONTROL: the entry validator still refuses a malformed body at 400', async () => { + // ⛔ The boundary the card draws around the fix: this defect must NOT be + // "solved" by refusing the legitimate query at entry. The entry layer was + // correct all along, and it stays exactly as loud as it was. + const res = await stack.apiAs(token, 'POST', '/analytics/query', { + cube: CUBE, + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'fortnight' }], + }); + + expect(res.status).toBe(400); + }); + + it('CONTROL: the buckets reconcile against /data — the same rows, counted', async () => { + // The report reconciled its own controls against `/data`; so does this, so a + // green bucket total cannot come from an empty or a differently-scoped read. + const res = await stack.apiAs(token, 'GET', '/data/td_delivery'); + expect(res.status).toBe(200); + const data = (await res.json()) as { data?: unknown[]; rows?: unknown[] }; + const records = (data.data ?? data.rows ?? []) as unknown[]; + expect(records).toHaveLength(SEED.length); + }); +}); diff --git a/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts b/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts new file mode 100644 index 0000000000..6a79d692c5 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13714 fixture — the smallest app that reproduces the field report's request. +// +// Shaped after `examples/app-showcase`'s `showcase_delivery` cube, because that +// is what the report was filed against: a cube over one object, with a `date` +// dimension and both a `count` and a `sum` measure. What matters for the defect +// is only that the cube is a CUBE — its measures are addressed on the wire as +// `.`, and `ObjectQLStrategy` uses that dotted name verbatim as +// the driver-level aggregation `alias`. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field, defineCube } from '@objectstack/spec/data'; + +export const TdDelivery = ObjectSchema.create({ + name: 'td_delivery', + // [ADR-0090 D1] grandfather stamp: this fixture's gate is analytics SQL + // emission, not owner-sharing. + sharingModel: 'public_read_write', + label: 'TD Delivery', + pluralLabel: 'TD Deliveries', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + // `Field.date`, like showcase's `due_date` — the dimension the report + // buckets. Kept a DATE (not a datetime) so the fixture matches the report. + due_date: Field.date({ label: 'Due Date' }), + estimate_hours: Field.number({ label: 'Estimate Hours' }), + }, +}); + +/** The cube. `showcase_delivery` with the names changed and the joins dropped. */ +export const TdDeliveryCube = defineCube({ + name: 'td_delivery_cube', + title: 'TD Delivery Analytics', + sql: 'td_delivery', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + total_estimate_hours: { + name: 'total_estimate_hours', + label: 'Total Estimated Hours', + type: 'sum', + sql: 'estimate_hours', + }, + }, + dimensions: { + status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + due_date: { name: 'due_date', label: 'Due Date', type: 'time', sql: 'due_date' }, + }, + public: false, +}); + +export const tdFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.td_fixture', + namespace: 'td', + version: '0.0.0', + type: 'app', + name: 'Time-Dimension Fixture', + // The tracker id lives in the suite's header, not in a runtime string an + // author or operator would read with no way to resolve it. + description: 'One object plus one cube, for the analytics granularity gate.', + }, + objects: [TdDelivery], +}); From 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 05:20:29 +0000 Subject: [PATCH 2/2] test(service-analytics): pin the granularity route onto a real better-sqlite3 driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dogfood HTTP pin with one at the analytics layer, driving AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the field report was filed against. It closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's cube-qualified measure name, which is why nothing but analytics ever put a dot in an alias. The un-bucketed cases are named for what they measure — the same door, without a granularity — rather than as a reproduction of the reporter's controls: this harness declares `nativeSql: false` so they take the failing door too, and the ablation reddens them alongside the bucketed cases. The reporter's controls travel the native face, whose fork is already pinned in both directions by native-sql-granularity-decline.test.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- ...-timedimension-granularity.dogfood.test.ts | 223 ------------ .../analytics-timedimension-fixture.ts | 65 ---- ...dimension-granularity-driver-alias.test.ts | 327 ++++++++++++++++++ 3 files changed, 327 insertions(+), 288 deletions(-) delete mode 100644 packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts create mode 100644 packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts diff --git a/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts b/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts deleted file mode 100644 index b272879ac2..0000000000 --- a/packages/qa/dogfood/test/analytics-cube-timedimension-granularity.dogfood.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * objectstack#13714 — a cube query with a `timeDimensions[].granularity` is - * SERVED, over real HTTP, on a real SQL driver. - * - * ## Why this suite exists at THIS layer - * - * The card is a field report from a running deployment, and it names the trap - * itself: the date-bucket parity pins (#3773 date-bucket-parity, #3839 - * empty-group-parity) were GREEN the whole time the endpoint answered 500, - * because they exercise bucketing at a layer that never carries what actually - * breaks. So a repair verified only at that layer cannot reproduce the defect, - * and this file is the half that can: `POST /api/v1/analytics/query`, through - * the real Hono app, the real analytics strategy fork, the real - * `engine.aggregate` dispatch and a real SQL driver. - * - * ## The reported request, and the fork underneath it - * - * ``` - * POST /api/v1/analytics/query - * { "cube": "showcase_delivery", - * "measures": ["showcase_delivery.count"], - * "timeDimensions": [{ "dimension": "showcase_delivery.due_date", - * "granularity": "month" }] } - * → 500 DATABASE_ERROR - * ``` - * - * A measure is addressed on the wire as `.`, and - * `ObjectQLStrategy` uses that dotted name verbatim as the driver-level - * aggregation `alias` — it is the key the caller reads its own number back - * under. `driver-sql` then bound that alias through knex's `??`, which SPLITS a - * dotted identifier into `table.column`, so the statement reached the database - * as `count(*) as \`showcase_delivery\`.\`count\`` and was refused before it ran. - * - * ⚠️ The granularity is the ROUTER, not the fault. `NativeSQLStrategy.canHandle` - * declines exactly on `timeDimensions[].granularity`, and that face hand-writes - * `AS ""` — one quoted identifier, already correct. That is precisely - * why the report's controls (same cube, same measure, `dimensions` instead of a - * granularity) answered 200 while the bucketed query answered 500, and both - * halves are asserted here so the fork itself stays pinned. - * - * ## Coverage boundary, stated rather than implied - * - * `bootStack` runs `driver-sqlite-wasm`, which inherits `aggregate()` from - * `SqlDriver`; the report was filed against `better-sqlite3`, which inherits the - * same method. The per-dialect measurement — better-sqlite3 embedded, Postgres - * and MySQL live — is - * `packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts`, - * which runs the live-dialect matrix. What THIS file adds that no driver suite - * can is the road: that a granularity really does route off the native face onto - * this door, over HTTP, in a booted app. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; -import { DateGranularity } from '@objectstack/spec/data'; -import { tdFixtureStack, TdDeliveryCube } from './fixtures/analytics-timedimension-fixture.js'; - -const CUBE = 'td_delivery_cube'; -const COUNT = `${CUBE}.count`; -const HOURS = `${CUBE}.total_estimate_hours`; -const DUE_DATE = `${CUBE}.due_date`; -const STATUS = `${CUBE}.status`; - -/** Two months, three rows — enough for a `month` bucket to have work to do. */ -const SEED = [ - { name: 'jan-a', status: 'open', due_date: '2026-01-15', estimate_hours: 2 }, - { name: 'jan-b', status: 'open', due_date: '2026-01-20', estimate_hours: 3 }, - { name: 'feb-a', status: 'done', due_date: '2026-02-05', estimate_hours: 4 }, -]; - -type AnalyticsBody = { rows?: Array>; error?: unknown; code?: string }; - -describe('dogfood: a cube time-dimension granularity is served, not a 500 (#13714)', () => { - let stack: VerifyStack; - let token: string; - - beforeAll(async () => { - stack = await bootStack(tdFixtureStack as never, { - analytics: new AnalyticsServicePlugin({ cubes: [TdDeliveryCube] }), - }); - token = await stack.signIn(); - - for (const row of SEED) { - const res = await stack.apiAs(token, 'POST', '/data/td_delivery', row); - expect(res.status, `seeding ${row.name}`).toBeLessThan(300); - } - }, 120_000); - - afterAll(async () => { - await stack?.stop(); - }); - - const query = async (body: Record): Promise<{ status: number; body: AnalyticsBody }> => { - const res = await stack.apiAs(token, 'POST', '/analytics/query', { cube: CUBE, ...body }); - return { status: res.status, body: (await res.json()) as AnalyticsBody }; - }; - - // ─────────────────────────────────────────────────────────────── - // THE CARD — every granularity the spec declares, not just `month` - // ─────────────────────────────────────────────────────────────── - - for (const granularity of DateGranularity.options) { - it(`granularity '${granularity}' answers 200 and counts every row exactly once`, async () => { - const { status, body } = await query({ - measures: [COUNT], - timeDimensions: [{ dimension: DUE_DATE, granularity }], - }); - - expect(status, `${granularity}: ${JSON.stringify(body).slice(0, 400)}`).toBe(200); - - const rows = body.rows ?? []; - expect(rows.length, `${granularity}: at least one bucket`).toBeGreaterThan(0); - // The caller reads the number back under the name it asked for. A - // response keyed `count` instead would be the silent half of the same - // defect — the alias split leaves the last segment behind. - for (const row of rows) { - expect(Object.keys(row), `${granularity}: the caller's own measure key`).toContain(COUNT); - } - const total = rows.reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); - expect(total, `${granularity}: no row dropped, none double-counted`).toBe(SEED.length); - }); - } - - it("'month' puts the two January rows in one bucket and February in another", async () => { - // The report's exact shape, and the assertion that the buckets are the RIGHT - // buckets rather than merely present — a single collapsed bucket is the - // #3773 failure mode and would satisfy the count total above. - const { status, body } = await query({ - measures: [COUNT], - timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], - }); - - expect(status).toBe(200); - const byBucket = new Map( - (body.rows ?? []).map((r) => [String(r[DUE_DATE]).slice(0, 7), Number(r[COUNT])]), - ); - expect(byBucket.get('2026-01')).toBe(2); - expect(byBucket.get('2026-02')).toBe(1); - }); - - it('a bucketed SUM measure answers too — the count is not a special case', async () => { - const { status, body } = await query({ - measures: [HOURS], - timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], - }); - - expect(status).toBe(200); - const total = (body.rows ?? []).reduce((sum, r) => sum + Number(r[HOURS] ?? 0), 0); - expect(total).toBe(9); - }); - - it('two measures bucketed together keep their own two columns', async () => { - const { status, body } = await query({ - measures: [COUNT, HOURS], - timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], - }); - - expect(status).toBe(200); - for (const row of body.rows ?? []) { - expect(Object.keys(row)).toEqual(expect.arrayContaining([COUNT, HOURS])); - } - }); - - it('a granularity ALONGSIDE a plain dimension is served', async () => { - const { status, body } = await query({ - measures: [COUNT], - dimensions: [STATUS], - timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], - }); - - expect(status).toBe(200); - const total = (body.rows ?? []).reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); - expect(total).toBe(SEED.length); - }); - - // ─────────────────────────────────────────────────────────────── - // THE REPORT'S OWN CONTROLS — these were 200 before the fix and must stay 200 - // ─────────────────────────────────────────────────────────────── - - it('CONTROL: measure by a plain dimension still answers 200 and reconciles', async () => { - const { status, body } = await query({ measures: [COUNT], dimensions: [STATUS] }); - - expect(status).toBe(200); - const byStatus = new Map((body.rows ?? []).map((r) => [String(r[STATUS]), Number(r[COUNT])])); - expect(byStatus.get('open')).toBe(2); - expect(byStatus.get('done')).toBe(1); - }); - - it('CONTROL: a second measure by status still answers 200', async () => { - const { status, body } = await query({ measures: [HOURS], dimensions: [STATUS] }); - - expect(status).toBe(200); - const byStatus = new Map((body.rows ?? []).map((r) => [String(r[STATUS]), Number(r[HOURS])])); - expect(byStatus.get('open')).toBe(5); - expect(byStatus.get('done')).toBe(4); - }); - - it('CONTROL: the entry validator still refuses a malformed body at 400', async () => { - // ⛔ The boundary the card draws around the fix: this defect must NOT be - // "solved" by refusing the legitimate query at entry. The entry layer was - // correct all along, and it stays exactly as loud as it was. - const res = await stack.apiAs(token, 'POST', '/analytics/query', { - cube: CUBE, - measures: [COUNT], - timeDimensions: [{ dimension: DUE_DATE, granularity: 'fortnight' }], - }); - - expect(res.status).toBe(400); - }); - - it('CONTROL: the buckets reconcile against /data — the same rows, counted', async () => { - // The report reconciled its own controls against `/data`; so does this, so a - // green bucket total cannot come from an empty or a differently-scoped read. - const res = await stack.apiAs(token, 'GET', '/data/td_delivery'); - expect(res.status).toBe(200); - const data = (await res.json()) as { data?: unknown[]; rows?: unknown[] }; - const records = (data.data ?? data.rows ?? []) as unknown[]; - expect(records).toHaveLength(SEED.length); - }); -}); diff --git a/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts b/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts deleted file mode 100644 index 6a79d692c5..0000000000 --- a/packages/qa/dogfood/test/fixtures/analytics-timedimension-fixture.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #13714 fixture — the smallest app that reproduces the field report's request. -// -// Shaped after `examples/app-showcase`'s `showcase_delivery` cube, because that -// is what the report was filed against: a cube over one object, with a `date` -// dimension and both a `count` and a `sum` measure. What matters for the defect -// is only that the cube is a CUBE — its measures are addressed on the wire as -// `.`, and `ObjectQLStrategy` uses that dotted name verbatim as -// the driver-level aggregation `alias`. - -import { defineStack } from '@objectstack/spec'; -import { ObjectSchema, Field, defineCube } from '@objectstack/spec/data'; - -export const TdDelivery = ObjectSchema.create({ - name: 'td_delivery', - // [ADR-0090 D1] grandfather stamp: this fixture's gate is analytics SQL - // emission, not owner-sharing. - sharingModel: 'public_read_write', - label: 'TD Delivery', - pluralLabel: 'TD Deliveries', - fields: { - name: Field.text({ label: 'Name', required: true }), - status: Field.text({ label: 'Status' }), - // `Field.date`, like showcase's `due_date` — the dimension the report - // buckets. Kept a DATE (not a datetime) so the fixture matches the report. - due_date: Field.date({ label: 'Due Date' }), - estimate_hours: Field.number({ label: 'Estimate Hours' }), - }, -}); - -/** The cube. `showcase_delivery` with the names changed and the joins dropped. */ -export const TdDeliveryCube = defineCube({ - name: 'td_delivery_cube', - title: 'TD Delivery Analytics', - sql: 'td_delivery', - measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - total_estimate_hours: { - name: 'total_estimate_hours', - label: 'Total Estimated Hours', - type: 'sum', - sql: 'estimate_hours', - }, - }, - dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, - due_date: { name: 'due_date', label: 'Due Date', type: 'time', sql: 'due_date' }, - }, - public: false, -}); - -export const tdFixtureStack = defineStack({ - manifest: { - id: 'com.dogfood.td_fixture', - namespace: 'td', - version: '0.0.0', - type: 'app', - name: 'Time-Dimension Fixture', - // The tracker id lives in the suite's header, not in a runtime string an - // author or operator would read with no way to resolve it. - description: 'One object plus one cube, for the analytics granularity gate.', - }, - objects: [TdDelivery], -}); diff --git a/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts b/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts new file mode 100644 index 0000000000..1ac543da6d --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#13714 — a cube query carrying a `timeDimensions[].granularity` is + * SERVED, through the analytics strategy fork onto a REAL SQL driver. + * + * ## Why this suite runs the road rather than a unit of it + * + * The card is a field report from a running deployment and it names the trap + * itself: the date-bucket parity pins (#3773 date-bucket-parity, #3839 + * empty-group-parity) were GREEN the whole time the endpoint answered 500, + * because they exercise bucketing at a layer that never carries the input that + * breaks. So this file does what they cannot — it drives `AnalyticsService` + * with a real `SqlDriver` on **better-sqlite3, the driver the report was filed + * against**, and asserts the rows come back. + * + * ## The reported request + * + * ``` + * POST /api/v1/analytics/query + * { "cube": "showcase_delivery", + * "measures": ["showcase_delivery.count"], + * "timeDimensions": [{ "dimension": "showcase_delivery.due_date", + * "granularity": "month" }] } + * → 500 DATABASE_ERROR + * ``` + * + * with controls from the same session that SUCCEED: the same measure by + * `dimensions: [...status]` → 200 reconciling against `/data`, a second measure + * by status → 200, and malformed bodies → 400 at the entry validator. + * + * ## The chain, and which link each pin owns + * + * 1. A granularity routes OFF the native-SQL face — `NativeSQLStrategy.canHandle` + * declines exactly on `timeDimensions[].granularity`. ⭐ Already pinned, both + * directions, by `native-sql-granularity-decline.test.ts`; a DECLARED CONTROL + * here, deliberately not duplicated. + * 2. `ObjectQLStrategy` then hands the driver an aggregation whose `alias` is the + * caller's CUBE-QUALIFIED measure name — `.` — because that is + * the key the caller reads its own number back under. **That is this file's + * first block**, and it was the unpinned link. + * 3. `driver-sql` must emit that alias as ONE identifier. Before the repair it + * bound it through knex's `??`, which parses an identifier rather than quoting + * one and splits on `.`, so the statement reached the database as + * ``count(*) as `showcase_delivery`.`count` `` and was refused before it ran. + * Pinned per dialect — SQLite embedded, Postgres and MySQL live — by + * `packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts`. + * + * ⚠️ Link 1 is why the fault LOOKED like a date-bucketing fault and is not one: + * the granularity is the ROUTER. The native face hand-writes `AS ""` — + * one quoted identifier, already correct — so an un-bucketed cube query never + * reached the broken door while a bucketed one always did. That is exactly why + * the report's controls were 200, and it means a repair aimed at + * `buildDateBucketExpr` would have left the defect untouched. + * + * ## What the bridge here stands in for + * + * `executeAggregate` is bridged to `driver.aggregate`, the same shape + * `cross-field-engine-fallback.test.ts` uses and for the same stated reason: in + * production the bridge is `engine.aggregate`, and `driver.aggregate` is what + * that call reaches. The one behaviour the engine adds on top is the + * `supports.queryDateGranularity` fork — for a granularity a dialect does not + * bucket natively it buckets IN MEMORY instead of calling this door. SQLite + * declines `week` (its `%V` needs SQLite 3.46), so `week` is asserted here as + * what the DIRECT caller gets: the declared #6212 `NOT_IMPLEMENTED`/501 + * capability refusal, never a `DATABASE_ERROR`. The in-memory leg it stands for + * is the engine's, and is what the parity suites measure. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { DateGranularity } from '@objectstack/spec/data'; +import type { AggregationNode, Cube } from '@objectstack/spec/data'; +import type { AnalyticsQuery, DriverQuery } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +const OBJECT = 'td_delivery'; +const CUBE_NAME = 'td_delivery_cube'; +const COUNT = `${CUBE_NAME}.count`; +const HOURS = `${CUBE_NAME}.total_estimate_hours`; +const DUE_DATE = `${CUBE_NAME}.due_date`; +const STATUS = `${CUBE_NAME}.status`; + +/** `showcase_delivery` with the names changed and the joins dropped. */ +const CUBE: Cube = { + name: CUBE_NAME, + title: 'TD Delivery Analytics', + sql: OBJECT, + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + total_estimate_hours: { + name: 'total_estimate_hours', label: 'Total Estimated Hours', type: 'sum', sql: 'estimate_hours', + }, + }, + dimensions: { + status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + // `type: 'time'` over a `Field.date` column — the shape the report buckets. + due_date: { name: 'due_date', label: 'Due Date', type: 'time', sql: 'due_date' }, + }, +} as unknown as Cube; + +/** Two months, three rows — enough for a `month` bucket to have work to do. */ +const ROWS = [ + { id: 'r1', status: 'open', due_date: '2026-01-15', estimate_hours: 2 }, + { id: 'r2', status: 'open', due_date: '2026-01-20', estimate_hours: 3 }, + { id: 'r3', status: 'done', due_date: '2026-02-05', estimate_hours: 4 }, +]; + +describe('[#13714] a cube time-dimension granularity reaches a real SQL driver and is served', () => { + let driver: SqlDriver; + let service: AnalyticsService; + /** Every driver-level aggregate call the run made, as the driver saw it. */ + let aggregateCalls: DriverQuery[]; + /** What this dialect PUBLISHES — the record `engine.aggregate` dispatches on. */ + let caps: Record; + + beforeAll(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: OBJECT, + fields: { + status: { type: 'string' }, + due_date: { type: 'date' }, + estimate_hours: { type: 'number' }, + }, + }, + ] as never); + for (const row of ROWS) await driver.create(OBJECT, { ...row }, { bypassTenantAudit: true } as never); + caps = ((driver as unknown as { supports: { queryDateGranularity?: Record } }) + .supports.queryDateGranularity ?? {}); + + aggregateCalls = []; + service = new AnalyticsService({ + cubes: [CUBE], + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (objectName, options) => { + // `{field, method, alias}` → `{field, function, alias}`: the analytics + // contract spells it `method`, the Query Protocol's + // `AggregationNodeSchema` spells it `function`, and `engine.aggregate` + // is what renames it in production. Mapped here rather than worked + // around, so the bridge keeps the shape a real host writes. + const query: DriverQuery = { + where: options.filter as DriverQuery['where'], + groupBy: options.groupBy, + aggregations: options.aggregations?.map(({ field, method, alias }) => ({ + field, + function: method as AggregationNode['function'], + alias, + })), + }; + aggregateCalls.push(query); + return (await driver.aggregate(objectName, query)) as Record[]; + }, + }); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + const run = async (query: Partial) => { + aggregateCalls = []; + return service.query({ cube: CUBE_NAME, ...query } as AnalyticsQuery); + }; + + const caught = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch (err) { + return err; + } + return null; + }; + + // ─────────────────────────────────────────────────────────────── + // LINK 2 — the alias the strategy hands the driver + // ─────────────────────────────────────────────────────────────── + + it('the driver is handed the CUBE-QUALIFIED measure name as the aggregation alias', async () => { + // The unpinned link, and the reason the defect only ever showed through + // analytics: nothing else routinely puts a dot in an `alias`. Asserted on + // the call the driver actually received, not on a rendering of it. + await run({ measures: [COUNT], timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }] }); + + expect(aggregateCalls).toHaveLength(1); + expect(aggregateCalls[0].aggregations?.map((a) => a.alias)).toEqual([COUNT]); + // And the bucket really is a structured groupBy item, so this call is the + // one that reaches the date-bucket emission path. + expect(aggregateCalls[0].groupBy).toEqual([{ field: 'due_date', dateGranularity: 'month' }]); + }); + + // ─────────────────────────────────────────────────────────────── + // THE CARD — every granularity the spec declares, not just `month` + // ─────────────────────────────────────────────────────────────── + + for (const granularity of DateGranularity.options) { + it(`granularity '${granularity}' is served end to end, never a DATABASE_ERROR`, async () => { + const err = await caught(() => + run({ measures: [COUNT], timeDimensions: [{ dimension: DUE_DATE, granularity }] }), + ); + + // ⛔ The one answer this card forbids, whatever this dialect buckets: a + // backend fault for a query that is spelled correctly. + expect(err?.code, `${granularity}: must not be a backend fault`).not.toBe('DATABASE_ERROR'); + + if (caps[granularity] !== true) { + // The declared capability gap (#6212). In production `engine.aggregate` + // reads the same record and buckets in memory instead of calling this + // door; the direct caller gets the refusal, and it is a statement about + // the BACKEND, never about the request. + expect(err?.code, `${granularity}: declined natively → the #6212 refusal`).toBe('NOT_IMPLEMENTED'); + expect(err?.status).toBe(501); + return; + } + + expect(err, `${granularity}: served natively, so nothing may throw`).toBeNull(); + const result = await run({ + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity }], + }); + expect(result.rows.length, `${granularity}: at least one bucket`).toBeGreaterThan(0); + for (const row of result.rows) { + expect(Object.keys(row), `${granularity}: the caller's own measure key`).toContain(COUNT); + } + const total = result.rows.reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); + expect(total, `${granularity}: no row dropped, none double-counted`).toBe(ROWS.length); + }); + } + + it("'month' — the reported request — separates January from February", async () => { + // That the buckets are the RIGHT buckets, not merely present: one collapsed + // bucket is the #3773 failure mode and would satisfy the total above. + const result = await run({ + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + const byBucket = new Map(result.rows.map((r) => [String(r[DUE_DATE]).slice(0, 7), Number(r[COUNT])])); + expect(byBucket.get('2026-01')).toBe(2); + expect(byBucket.get('2026-02')).toBe(1); + }); + + it('a bucketed SUM measure is served too — count is not a special case', async () => { + const result = await run({ + measures: [HOURS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + const total = result.rows.reduce((sum, r) => sum + Number(r[HOURS] ?? 0), 0); + expect(total).toBe(9); + }); + + it('two measures bucketed together keep their own two columns', async () => { + const result = await run({ + measures: [COUNT, HOURS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + for (const row of result.rows) { + expect(Object.keys(row)).toEqual(expect.arrayContaining([COUNT, HOURS])); + } + }); + + it('a granularity ALONGSIDE a plain dimension is served', async () => { + const result = await run({ + measures: [COUNT], + dimensions: [STATUS], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + + const total = result.rows.reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); + expect(total).toBe(ROWS.length); + }); + + // ─────────────────────────────────────────────────────────────── + // THE AXIS — the same door, WITHOUT a granularity + // ─────────────────────────────────────────────────────────────── + // + // ⚠️ These are deliberately NOT a reproduction of the reporter's controls, + // and the ablation is what proves they are not: they redden along with the + // bucketed cases. `queryCapabilities` here declares `nativeSql: false`, so an + // un-bucketed query is forced down the SAME door instead of being served by + // `NativeSQLStrategy` as it is in the field. That is the point — it isolates + // the granularity as a ROUTER rather than the fault: strip the bucket, keep + // the cube-qualified alias, and the door still fails before the repair. + // + // The reporter's actual controls travel the native face, and the fork that + // sends them there is pinned in both directions by + // `native-sql-granularity-decline.test.ts` — a DECLARED CONTROL for this card, + // green throughout and not re-measured here. + + it('the same door serves an un-bucketed cube query — the bucket is not the fault', async () => { + const result = await run({ measures: [COUNT], dimensions: [STATUS] }); + + const byStatus = new Map(result.rows.map((r) => [String(r[STATUS]), Number(r[COUNT])])); + expect(byStatus.get('open')).toBe(2); + expect(byStatus.get('done')).toBe(1); + }); + + it('a second measure by status is served on that door too', async () => { + const result = await run({ measures: [HOURS], dimensions: [STATUS] }); + + const byStatus = new Map(result.rows.map((r) => [String(r[STATUS]), Number(r[HOURS])])); + expect(byStatus.get('open')).toBe(5); + expect(byStatus.get('done')).toBe(4); + }); + + it('the bucket totals reconcile against the rows the driver actually holds', async () => { + // The reporter reconciled their controls against `/data`; this reconciles + // against the same driver's own `find`, so a green total cannot come from an + // empty or differently-scoped read. + const all = (await driver.find(OBJECT, {})) as unknown[]; + expect(all).toHaveLength(ROWS.length); + + const bucketed = await run({ + measures: [COUNT], + timeDimensions: [{ dimension: DUE_DATE, granularity: 'month' }], + }); + const total = bucketed.rows.reduce((sum, r) => sum + Number(r[COUNT] ?? 0), 0); + expect(total).toBe(all.length); + }); +});