diff --git a/.changeset/21441-objectql-echo-date-bucket.md b/.changeset/21441-objectql-echo-date-bucket.md new file mode 100644 index 0000000000..f42ef7cf08 --- /dev/null +++ b/.changeset/21441-objectql-echo-date-bucket.md @@ -0,0 +1,19 @@ +--- +"@objectstack/service-analytics": minor +"@objectstack/driver-sql": minor +"@objectstack/driver-turso": patch +--- + +fix(service-analytics): the ObjectQL face echoes a date-bucketed dimension in the bucket expression the driver itself groups by, so SQLite runs the statement it prints + +Clause-②: yes (widening) + +**Before**, the ObjectQL strategy printed every date-bucketed dimension as `date_trunc('', col)` in the `sql` it echoes and in the `POST /analytics/sql` body, on every dialect. The native strategy declines a granularity, so every bucketed query lands on this face. Measured through `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` in the default composition: the rows were right. On SQLite the echo failed with `no such function: date_trunc` (month, quarter and week). On PostgreSQL 16.14 it ran but answered `2026-01-01T00:00:00.000Z` where the face answers `2026-01`. The driver groups by `strftime('%Y-%m', …)` on SQLite and `to_char((…)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')` on PostgreSQL. + +**Now** the echo prints the driver's own expression, so it runs on that dialect and answers the face's bucket keys. + +- **`@objectstack/driver-sql`**: `SqlDriver.dateBucketSql(objectName, field, granularity)` returns the expression `aggregate` groups by, rendered as SQL text: the existing `buildDateBucketExpr`, unchanged, with each identifier quoted by the dialect. It returns `null` for a granularity the dialect buckets in memory (`week` on SQLite). The MySQL arm (`date_format(convert_tz(…))`) is checked by code read only, because no MySQL server was available. +- **`@objectstack/service-analytics`**: the new optional `AnalyticsServiceConfig.dateBucketSql` hook carries the expression to the ObjectQL strategy. `AnalyticsServicePlugin` wires it from the driver that serves the object, as it wires `sqlDialect`. +- **`@objectstack/driver-turso`**: a comment that said `SqlDriver` buckets with `date_trunc` now names the SQLite `strftime` expression it emits. The inherited `dateBucketSql` answers on the remote face too: it renders the same SQLite expression with no connection, and libSQL runs it. + +**Unchanged.** The rows every face answers. The echo keeps `date_trunc(…)` where nothing answers: a host that wires no hook, a driver with no bucket expression (memory, MongoDB), a granularity the driver buckets in memory, and a query with a non-UTC `timezone`, which the engine buckets in memory on that zone's calendar. diff --git a/packages/client/src/envelope-caller-census.test.ts b/packages/client/src/envelope-caller-census.test.ts index a2d4051b06..384a426f59 100644 --- a/packages/client/src/envelope-caller-census.test.ts +++ b/packages/client/src/envelope-caller-census.test.ts @@ -487,6 +487,12 @@ const LEDGER: readonly LedgerRow[] = [ method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK', why: 'a MemoryAnalyticsService bound to `analytics`, called directly (no HTTP, no dispatcher envelope) to assert the cube face refuses a non-boolean $exists and answers find()\'s rows for true / false', }, + // ── [#21441] the ObjectQL face's date-bucket echo pin: producer reads only ── + { + file: 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', + method: 'analytics.query', receiver: 'service', count: 1, verdict: 'NOT_SDK', + why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to read the ObjectQL face\'s rows and the echoed `sql` it runs against them', + }, { file: 'packages/client/src/analytics-automation-json-erasure.test.ts', method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT', @@ -689,11 +695,16 @@ describe('#13079 §2 — positive controls on the matcher itself', () => { // nested-relation pin, and driver-memory's cube service // (`MemoryAnalyticsService`) in its own refusal suite. None of the // receivers is the client, and every file is pinned. - expect(service.length, literalNote()).toBe(8); + // [#21441] A fourth: `service-analytics`' date-bucket echo pin calls + // the real AnalyticsService that `AnalyticsServicePlugin` registers + // over a live engine, to read the ObjectQL face's rows beside its + // echoed `sql`. Its receiver is that service, not the client. + expect(service.length, literalNote()).toBe(9); expect([...new Set(service.map((s) => s.file))].sort()).toEqual([ 'packages/client/src/analytics-automation-json-erasure.test.ts', 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', 'packages/rest/src/analytics-nested-relation-filter.test.ts', + 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', ]); expect(service.every((s) => s.method === 'analytics.query')).toBe(true); }); @@ -740,10 +751,11 @@ describe('#13079 §3 — every call site is classified', () => { expect(production, literalNote()).toEqual([]); }); - it('records the split: 18 payload pins, 10 result-insensitive, 8 not-SDK', () => { + it('records the split: 18 payload pins, 10 result-insensitive, 9 not-SDK', () => { expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18); expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10); - expect(verdictTotal('NOT_SDK')).toBe(8); + // [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2). + expect(verdictTotal('NOT_SDK')).toBe(9); // The three above are LEDGER sums and cannot move on a census reading; // this one is census-derived, so it carries the note. [#13874] expect(sdkSites.length, literalNote()).toBe(28); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index ae07547bcd..164cb6b66b 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7,7 +7,7 @@ * Supports PostgreSQL, MySQL, SQLite, and other SQL databases. */ -import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; +import type { DateGranularityValue, DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; // The ONE introspection contract (ADR-0015 / `ISchemaDiffService`). This // driver's introspection types are DERIVED from these rather than // re-declared next to them — see the `Introspection Types` region below. @@ -6166,6 +6166,36 @@ export class SqlDriver implements IDataDriver { return null; } + /** + * [#21441] The date-bucket expression this dialect groups `field` by at + * `granularity` (the one {@link aggregate} runs), rendered as SQL text, or + * `null` where {@link buildDateBucketExpr} has none: a granularity this + * dialect buckets in memory (`week` on SQLite, see + * {@link dateGranularityCapabilities}), or a client this driver does not + * model. + * + * It is for callers that PRINT the statement an aggregate stands for rather + * than run it. `service-analytics`' ObjectQL face echoes a date-bucketed + * query as SQL (`/analytics/sql`). While it spelled the bucket itself it + * printed `date_trunc(…)` on every dialect: SQLite refuses that, and + * PostgreSQL answers a timestamp where this driver answers `2026-01`. + * + * Reading the expression from here keeps this driver the one source of its + * bucketing. The text is `buildDateBucketExpr`'s, unchanged, with each `??` + * rendered by knex (`Raw.toQuery`) through this dialect's own identifier + * quoting. The expression binds identifiers only, so the text carries no + * value placeholder. `objectName` goes in as the coercion key, as + * `aggregate` passes it, so a SQLite `Field.datetime` column that may still + * hold pre-canonical values renders the repair the GROUP BY runs (#3773). + * + * Read structurally by its caller, like {@link dialectName}. It is not a + * member of the `IDataDriver` contract. + */ + public dateBucketSql(objectName: string, field: string, granularity: DateGranularityValue): string | null { + const bucket = this.buildDateBucketExpr(field, granularity, objectName); + return bucket ? this.knex.raw(bucket.sql, bucket.bindings).toQuery() : null; + } + /** * Schema ownership mode (ADR-0015). When not `'managed'`, all * schema-mutating DDL is rejected by {@link assertSchemaMutable}. The diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index cc15138aba..0f732b98aa 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -940,6 +940,11 @@ export const REMOTE_FACE_ANSWERS = { // Pure functions of the registries the remote arms read themselves. temporalFilterValue: 'inherited', temporalFilterColumnSql: 'inherited', + // The SQLite bucket expression, rendered by Knex's compiler, which needs no + // connection; libSQL runs it. The remote `aggregate` buckets nothing (its + // `queryDateGranularity` is empty), so the engine buckets in memory, on the + // UTC calendar whose keys this expression answers. + dateBucketSql: 'inherited', } as const satisfies Record; // ── Remote operation timeout ───────────────────────────────────────────────── @@ -1609,7 +1614,8 @@ export class TursoDriver extends SqlDriver { batchSchemaSync: true, // Remote transport does NOT do native date bucketing. SqlDriver's - // `aggregate` — which emits `date_trunc`/`strftime` for structured + // `aggregate` — which emits its dialect's bucket expression (`strftime` + // here; see `SqlDriver.buildDateBucketExpr`) for structured // `{ field, dateGranularity }` groupBy items — is only reached in // local/replica mode; remote mode delegates `aggregate` to // `RemoteTransport.aggregate`, which accepts only string group-by diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts new file mode 100644 index 0000000000..c0eb312b25 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21441] The ObjectQL face echoes a date-bucketed dimension in the bucket + * expression the driver itself groups by, so the echo runs on that dialect and + * answers the face's rows. + * + * ## The shape this closes + * + * `ObjectQLStrategy.generateSql` printed `date_trunc('', col)` on + * every dialect. Measured at `POST /api/v1/analytics/query` and + * `POST /api/v1/analytics/sql` on `main` `0bddffd55`, default composition (the + * native face declines a granularity, so every bucketed query lands here): + * + * | cell | the driver grouped by | that echo, run | + * |:--|:--|:--| + * | SQLite, month | `strftime('%Y-%m', …)` | `no such function: date_trunc` | + * | SQLite, quarter | `(strftime('%Y', …) \|\| '-Q' \|\| …)` | `no such function: date_trunc` | + * | PostgreSQL 16.14, month | `to_char((…)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')` | `2026-01-01T00:00:00.000Z` where the face answers `2026-01` | + * + * The rows were right. The echo now reads the bucket from the `dateBucketSql` + * hook, which the plugin fills from `SqlDriver.dateBucketSql`: the driver's + * own `buildDateBucketExpr`, rendered. No second bucketing table. + * + * ## The cells + * + * - **sqlite** (better-sqlite3), every run. Month and quarter are grouped by + * the driver; week is bucketed in memory (the driver declares no `week`). + * - **postgres** where `OS_TEST_POSTGRES_URL` is set, a named skip otherwise. + * Month, quarter and week are grouped by the driver. No CI step provisions + * that variable for this package, so the live cell is red-capable and + * un-run in CI. + * + * Where the hook answers nothing, the bucket keeps `date_trunc`: a granularity + * the driver buckets in memory, a non-UTC `timezone` (the engine buckets in + * memory on that zone's calendar), and a host that wires no hook. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import type { StrategyContext } from '../strategies/types.js'; + +const DEAL = 'os21441_bucket_deal'; + +const DEAL_OBJECT = { + name: DEAL, + label: 'Bucket echo deal', + fields: { + closed_on: { name: 'closed_on', type: 'date' as const }, + closed_at: { name: 'closed_at', type: 'datetime' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +// d2 closes at 20:00 UTC on 31 January, which is 1 February in Asia/Shanghai. +const DEALS = [ + { id: 'd1', closed_on: '2026-01-10', closed_at: '2026-01-10T10:00:00.000Z', amount: 20 }, + { id: 'd2', closed_on: '2026-01-25', closed_at: '2026-01-31T20:00:00.000Z', amount: 7 }, + { id: 'd3', closed_on: '2026-02-03', closed_at: '2026-02-03T08:00:00.000Z', amount: 1 }, + { id: 'd4', closed_on: '2026-03-14', closed_at: '2026-03-14T12:00:00.000Z', amount: 10 }, + { id: 'd5', closed_on: '2026-04-02', closed_at: '2026-04-02T00:30:00.000Z', amount: 5 }, +] as const; + +const CUBE = 'os21441_bucket_cube'; +const CUBES = [ + { + name: CUBE, + title: 'Bucket echo cube', + sql: DEAL, + public: true, + measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } }, + dimensions: { + closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' }, + closed_at: { type: 'time', sql: 'closed_at', label: 'Closed at' }, + }, + }, +] as unknown as Cube[]; + +/** A dataset whose measure carries its own `filter`, which the engine aggregates in memory. */ +const FILTERED = DatasetSchema.parse({ + name: 'os21441_bucket_filtered', + label: 'Bucket echo filtered', + object: DEAL, + dimensions: [{ name: 'closed_on', label: 'Closed on', field: 'closed_on', type: 'date' }], + measures: [{ name: 'big_sum', label: 'Big', aggregate: 'sum', field: 'amount', filter: { amount: { $ne: 7 } } }], +}); + +const bucketed = (dim: string, granularity: string, extra: Record = {}) => ({ + cube: CUBE, + measures: ['amount_sum'], + timeDimensions: [{ dimension: dim, granularity }], + order: { [dim]: 'asc' }, + ...extra, +}); + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; + /** The granularities the driver groups by in SQL; the rest it buckets in memory. */ + driverGrouped: readonly string[]; +} + +const CELLS: readonly Cell[] = [ + { + id: 'sqlite', + label: 'sqlite', + env: null, + config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + driverGrouped: ['month', 'quarter'], + }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + driverGrouped: ['month', 'quarter', 'week'], + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Row = Record; + +/** Rows as tuples of the named columns, in arrival order; a numeric cell reads as a number on every dialect. */ +const tuples = (rows: unknown, columns: readonly string[]) => + (rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c]))); + +/** The bucket expression an echo selects for `dim`: everything between `SELECT ` and ` AS ""`. */ +const selectedBucket = (sql: string, dim: string) => sql.slice('SELECT '.length, sql.indexOf(` AS "${dim}"`)); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21441] the ObjectQL face echoes a date bucket in the driver's own expression (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + let analytics: AnalyticsService; + /** Every statement the driver ran, in order. */ + const driverRan: string[] = []; + + const dropTables = async () => { + if (cell.id !== 'pg') return; + await driver?.execute(`drop table if exists ${DEAL}`).catch(() => {}); + }; + + /** One `query()` and the statements the driver ran for it. */ + const ask = async (query: Record) => { + const before = driverRan.length; + const res = await analytics.query(query as any); + return { res, ran: driverRan.slice(before) }; + }; + + /** Run an echo through the engine's raw-SQL bridge, as the native face runs its own statement. */ + const run = async (sql: string, params: unknown[]) => { + const result = await (engine as any).execute(sql.replace(/\$(\d+)/g, '?'), { args: params, object: DEAL }); + return Array.isArray(result) ? result : (result as { rows: Row[] }).rows; + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTables(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(DEAL_OBJECT as any); + await engine.syncSchemas(); + for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); + driver.knex.on('query', (q: { sql: string }) => { driverRan.push(q.sql); }); + + // The default composition: no `queryCapabilities` override. + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + analytics = registered.analytics as AnalyticsService; + analytics.registerDataset(FILTERED); + }); + + afterAll(async () => { + await dropTables(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const dim of ['closed_on', 'closed_at']) { + it.each(cell.driverGrouped)(`${dim}, %s: the echo selects and groups by the expression the driver ran, and it runs`, async (granularity) => { + const query = bucketed(dim, granularity); + const { res, ran } = await ask(query); + const echo = res.sql!; + // `generateSql` is the body `POST /analytics/sql` answers with. + const dryRun = await analytics.generateSql(query as any); + expect(dryRun.sql).toBe(echo); + expect(dryRun.params).toEqual([]); + + const bucket = selectedBucket(echo, dim); + expect(bucket).not.toContain('date_trunc'); + expect(echo).toContain(`GROUP BY ${bucket}`); + expect(ran, 'the driver grouped by that expression').toHaveLength(1); + expect(ran[0]).toContain(bucket); + + expect(tuples(await run(echo, dryRun.params), [dim, 'amount_sum'])).toEqual(tuples(res.rows, [dim, 'amount_sum'])); + }); + } + + it('a measure filter, which the engine aggregates in memory: the echo keeps the driver expression and runs with its params', async () => { + const query = { cube: FILTERED.name, measures: ['big_sum'], timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }], order: { closed_on: 'asc' } }; + const { res, ran } = await ask(query); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(tuples(res.rows, ['closed_on', 'big_sum'])).toEqual([['2026-01', 20], ['2026-02', 1], ['2026-03', 10], ['2026-04', 5]]); + const dryRun = await analytics.generateSql(query as any); + expect(dryRun.sql).toBe(res.sql); + expect(selectedBucket(dryRun.sql, 'closed_on')).not.toContain('date_trunc'); + expect(tuples(await run(dryRun.sql, dryRun.params), ['closed_on', 'big_sum'])).toEqual(tuples(res.rows, ['closed_on', 'big_sum'])); + }); + + it.each(['month', 'quarter', 'week'].filter((g) => !cell.driverGrouped.includes(g)))( + 'FALLBACK: %s, which the driver buckets in memory, keeps `date_trunc`', + async (granularity) => { + const { res, ran } = await ask(bucketed('closed_on', granularity)); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(selectedBucket(res.sql!, 'closed_on')).toBe(`date_trunc('${granularity}', closed_on)`); + }, + ); + + it('FALLBACK: a non-UTC timezone buckets in memory on that zone\'s calendar, and the echo keeps `date_trunc`', async () => { + const { res, ran } = await ask(bucketed('closed_at', 'month', { timezone: 'Asia/Shanghai' })); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(tuples(res.rows, ['closed_at', 'amount_sum'])).toEqual([['2026-01', 20], ['2026-02', 8], ['2026-03', 10], ['2026-04', 5]]); + expect(selectedBucket(res.sql!, 'closed_at')).toBe("date_trunc('month', closed_at)"); + }); + }, + ); +} + +describe('[#21441] FALLBACK: a host that wires no dateBucketSql hook', () => { + it('echoes the bucket as `date_trunc`', async () => { + const ctx = { getCube: (name: string) => (name === CUBE ? CUBES[0] : undefined) } as unknown as StrategyContext; + const { sql } = await new ObjectQLStrategy().generateSql(bucketed('closed_on', 'month') as any, ctx); + expect(selectedBucket(sql, 'closed_on')).toBe("date_trunc('month', closed_on)"); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index ae5535888d..b9fb64d62f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1137,6 +1137,16 @@ export interface AnalyticsServiceConfig { * "cannot answer, do not block". */ sqlDialect?: (object: string) => AcceptedSqlDialect | undefined; + /** + * [#21441] The date-bucket expression the driver backing `object` groups + * `field` by at `granularity`, as SQL text in its own dialect, or + * `undefined` when the host cannot answer. See + * `DatasetScopedStrategyContext.dateBucketSql` (`strategies/types.ts`). + * + * Answered by the plugin from the driver that executes the aggregate. A host + * that wires nothing keeps the echo's representative `date_trunc(…)`. + */ + dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined; /** * [#19995, ruling C] The data engine's judge-only `where` admission, * `IObjectQLEngine.judgeFilter` (#20157): would the engine admit this @@ -1490,6 +1500,9 @@ export class AnalyticsService implements IAnalyticsService { this.diagnoseSqlDialectAnswer(object, answered); return answered; }, + // [#21441] The executing driver's own bucket expression, for the ObjectQL + // face's echo; passed through untouched, `undefined` included. + dateBucketSql: config.dateBucketSql, // [#19995, ruling C] The engine's own admission verdict on a read scope, // asked by `ObjectQLStrategy` at its engine-bound merges. The host's // answer is passed through untouched, `undefined` included. A host that diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 6ca3353bac..e1378f313e 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -97,6 +97,16 @@ type TemporalDriverSurface = Pick< */ type DialectNamingDriver = { readonly dialectName?: unknown }; +/** + * [#21441] The slice of a SQL driver that renders its OWN date-bucket + * expression, `SqlDriver.dateBucketSql`. Read structurally, as + * {@link DialectNamingDriver} is and for the same reason: bucketing SQL is a + * property of the SQL driver family, not of every driver. + */ +type DateBucketingDriver = { + dateBucketSql?(objectName: string, field: string, granularity: string): unknown; +}; + /** * Re-parse a bridge-supplied aggregation `method` as the engine contract's * `AggregationFunction` before it is forwarded as `function`, refusing @@ -1215,6 +1225,30 @@ export class AnalyticsServicePlugin implements Plugin { } }; + /** + * [#21441] The expression the driver that owns the object groups a + * date-bucketed dimension by, as SQL text: what `ObjectQLStrategy.generateSql` + * echoes for the bucket. Asked of the DRIVER through the same + * `getDriverForObject` seam `sqlDialect` uses, so the driver stays the + * single source of its bucketing and no second table lives here. + * + * `undefined` on every tier that cannot answer: no data engine, a driver + * without the member (memory, mongo), a granularity the driver buckets in + * memory (it answers `null`), a throw. `undefined` keeps the echo's + * representative `date_trunc(…)`. + */ + const dateBucketSql = (objectName: string, field: string, granularity: string): string | undefined => { + try { + const svc = ctx.getService('data'); + const driver = svc?.getDriverForObject?.(objectName) as DateBucketingDriver | undefined; + if (typeof driver?.dateBucketSql !== 'function') return undefined; + const rendered = driver.dateBucketSql(objectName, field, granularity); + return typeof rendered === 'string' && rendered !== '' ? rendered : undefined; + } catch { + return undefined; + } + }; + /** * [#21080] The data engine's answer to "is a middleware registered for * this object?" (`IObjectQLEngine.hasObjectMiddleware`), resolved per call @@ -1339,6 +1373,9 @@ export class AnalyticsServicePlugin implements Plugin { getObjectDatasource: (objectName: string) => dataEngine()?.resolveEffectiveDatasource?.(objectName), // [#15684] The executing driver's own dialect — see `sqlDialect` above. sqlDialect, + // [#21441] The executing driver's own bucket expression — see + // `dateBucketSql` above. + dateBucketSql, // [#19995, ruling C] The executing engine's own `where` admission — see // `judgeFilter` beside the `executeAggregate` auto-bridge above. judgeFilter, diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index eedaeae5aa..3769d2062c 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -163,10 +163,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // Build groupBy from dimensions, honouring `timeDimensions` granularity. // A date dimension with a granularity becomes a STRUCTURED groupBy item - // `{ field, dateGranularity }` — which `engine.aggregate()` buckets (driver - // date_trunc or in-memory). Without this the ObjectQL path grouped raw - // timestamps (one bucket per row) and date-bucketed dataset widgets never - // matched their legacy `categoryGranularity` counterpart. + // `{ field, dateGranularity }` — which `engine.aggregate()` buckets (the + // driver's own SQL expression, or in memory). Without this the ObjectQL + // path grouped raw timestamps (one bucket per row) and date-bucketed + // dataset widgets never matched their legacy `categoryGranularity` + // counterpart. type GroupByItem = string | { field: string; dateGranularity: string }; const granByDim = new Map(); for (const td of query.timeDimensions ?? []) { @@ -408,14 +409,23 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } /** - * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query. + * Render the SQL statement an ObjectQL aggregate query stands for. * * This path executes through `engine.aggregate()`, not raw SQL, so the string * is documentation rather than the literal statement — but it must be an * honest account of what the query does, because dataset responses echo it * and authors read it to verify their widget options landed (#3588). It - * therefore renders date bucketing (`date_trunc`), the WHERE predicate, - * ordering, and the row window. + * therefore renders date bucketing, the WHERE predicate, ordering, and the + * row window. + * + * [#21441] A date bucket renders the expression the driver itself groups by + * for its dialect, through the `dateBucketSql` hook, so the echo runs there + * and answers the face's bucket keys. The bucket stays REPRESENTATIVE where + * the hook answers nothing: no hook, a driver with no bucket expression (a + * non-SQL driver), a granularity the driver buckets in memory (`week` on + * SQLite), or a non-UTC `timezone`, which the engine buckets in memory on + * that zone's calendar. There it prints `date_trunc('', col)`, + * which SQLite refuses. * * Filter VALUES are rendered as `$n` placeholders and returned in `params`, * never inlined: the echoed statement travels to the browser, and a filter @@ -478,9 +488,9 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const groupByParts: string[] = []; const params: unknown[] = []; - // Date-bucketed dimensions render as `date_trunc('', col)` — - // the SQL shape the driver's own bucketing implements — so a `month` trend - // no longer reads as if it grouped by the raw column. + // Date-bucketed dimensions render as a bucket expression, so a `month` + // trend does not read as if it grouped by the raw column — see `dimExpr` + // below for which expression. const granByDim = new Map(); for (const td of query.timeDimensions ?? []) { if (td.granularity) granByDim.set(td.dimension, td.granularity); @@ -515,6 +525,26 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ); const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd])); const joinClauses: string[] = []; + // [#21441] The bucket expression the driver itself groups by for its + // dialect, read from the `dateBucketSql` hook: `strftime('%Y-%m', …)` on + // SQLite, `to_char(… AT TIME ZONE 'UTC', 'YYYY-MM')` on PostgreSQL. The + // echo then runs there and answers the face's bucket keys. It used to + // print `date_trunc('', col)` on every dialect, calling that + // the driver's own bucketing: no driver buckets with it, SQLite refuses it + // (`no such function`), and PostgreSQL answers a timestamp where the face + // answers `2026-01`. + // + // Asked only for a UTC or unset `timezone`. The driver's expression is a + // UTC bucket, and a non-UTC zone makes the engine bucket in memory on that + // zone's calendar instead (ADR-0053 Phase 2, D2; `tzRequiresInMemory` in + // objectql's `engine.ts`), which no driver expression describes. Where + // nothing answers, the bucket keeps the representative `date_trunc`. + const zone = query.timezone; + const driverBucketSql = (col: string, granularity: string): string | undefined => { + if (zone && zone !== 'UTC') return undefined; + const answered = (ctx as DatasetScopedStrategyContext).dateBucketSql?.(tableName, col, granularity); + return typeof answered === 'string' && answered !== '' ? answered : undefined; + }; const dimExpr = (dim: string): string => { const cd = crossByDim.get(dim); if (cd) { @@ -525,7 +555,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } const col = this.resolveFieldName(cube, dim, 'dimension'); const gran = granByDim.get(dim); - return gran ? `date_trunc('${gran}', ${col})` : col; + if (!gran) return col; + return driverBucketSql(col, gran) ?? `date_trunc('${gran}', ${col})`; }; if (query.dimensions) { diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index b5a6e69092..43163d1410 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -160,6 +160,25 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * know the hook keeps the behaviour it had — "cannot answer, do not block". */ sqlDialect?(objectName: string): string | undefined; + /** + * [#21441] The date-bucket expression the driver backing `objectName` + * groups `field` by at `granularity`, as SQL text in that driver's dialect: + * `strftime('%Y-%m', …)` on SQLite, `to_char(… AT TIME ZONE 'UTC', + * 'YYYY-MM')` on PostgreSQL. `undefined` when the host cannot answer: no + * hook wired, a driver with no bucket expression (a non-SQL driver), or a + * granularity the driver buckets in memory (`week` on SQLite). + * + * `ObjectQLStrategy.generateSql` prints a date-bucketed dimension in this + * expression, so its echo runs on that dialect and answers the face's + * bucket keys. The service answers it from + * `AnalyticsServiceConfig.dateBucketSql`, which the plugin fills from the + * driver that EXECUTES the aggregate: the driver stays the single source of + * its bucketing, the posture `sqlDialect` takes. Declared HERE rather than + * on the spec's {@link StrategyContext} for the reason `declaredFieldType` + * is: nothing about it is an authorable surface, and a strategy that does + * not know the hook keeps the behaviour it had. + */ + dateBucketSql?(objectName: string, field: string, granularity: string): string | undefined; /** * [#19995] The ENGINE's own `where` admission verdict for `objectName`, * `IObjectQLEngine.judgeFilter` (#20157), or `undefined` when the host diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index f9ca86cf95..3d8349306c 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -759,6 +759,12 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // moves the census verdict, so a change to it has to re-run this suite. // Per-file, not `packages/**`, for the price the `scripts/**` entry records. 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', + // [#21441] Declared by name for the same reason: the census LEDGER + // carries a NOT_SDK row for service-analytics' date-bucket echo pin, + // which calls `analytics.query(` on the real AnalyticsService (receiver + // `service`). A call added to or removed from that file moves the census + // verdict, so a change to it has to re-run this suite. + 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', ], heldBy: { // `scripts/**` is rostered TODAY through the census's own diff --git a/turbo.json b/turbo.json index 476ef6b94d..f85028436e 100644 --- a/turbo.json +++ b/turbo.json @@ -261,7 +261,8 @@ "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts", - "$TURBO_ROOT$/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts" + "$TURBO_ROOT$/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts", + "$TURBO_ROOT$/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts" ] }, "@objectstack/lint#test": {