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/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); + }); +});