@@ -37,7 +37,7 @@ import { projectedDimensions } from '../order-key-door.js';
3737// dataset door's post-pass (#3588) — this face orders by it, never by a copy.
3838import { applyOrdering , applyWindow } from '../dataset-executor.js' ;
3939import { type LikeShape } from '../like-pattern.js' ;
40- import { textMatchPredicateSql , sqlDialectFor } from '../text-match-sql.js' ;
40+ import { textMatchPredicateSql , sqlDialectFor , type AnalyticsSqlDialect } from '../text-match-sql.js' ;
4141import { resolveAnalyticsDateRangeString } from '@objectstack/core' ;
4242import { explicitDateRangeWindow } from '../date-range-array-arm.js' ;
4343import {
@@ -142,26 +142,41 @@ interface CrossObjectPlan {
142142}
143143
144144/**
145- * The echo of a date bucket that no statement the engine runs produces: it
146- * refuses rather than print one.
145+ * Why the echo of a date bucket refuses: the in-memory zone, or no driver
146+ * expression for the bucket (with the dialect the host names, `'unknown'` when
147+ * it names none).
148+ */
149+ type BucketEchoRefusalCause = { zone : string } | { dialect : AnalyticsSqlDialect } ;
150+
151+ /**
152+ * The echo of a date bucket that no driver expression stands for: it refuses
153+ * rather than print one.
147154 *
148- * `generateSql` prints a date-bucketed dimension in the expression the driver
149- * groups by (the `dateBucketSql` hook). Two cases have no such expression, and
150- * both refuse here:
155+ * [#21647] One rule. `generateSql` prints a date-bucketed dimension only in
156+ * the expression the driver itself renders for it (the `dateBucketSql` hook),
157+ * at a UTC or unset `timezone`. Everything else refuses here, and the
158+ * refusal names its cause:
151159 *
152- * - [#21630] **A non-UTC `timezone`, on every dialect .** The engine then
160+ * - [#21630] **A non-UTC `timezone`, on every driver .** The engine then
153161 * buckets in memory on that zone's calendar (ADR-0053 Phase 2, D2;
154- * `tzRequiresInMemory` in objectql's `engine.ts`) on every driver: the driver
155- * only fetches the rows, and no statement the database runs groups by those
156- * keys. The representative `date_trunc('<granularity>', col)` this printed
157- * instead groups on the database SESSION's calendar where it runs at all.
158- * Measured on PostgreSQL 16.14 with the server at `Asia/Shanghai`, it
159- * answered timestamp keys such as `2025-12-31T16:00:00.000Z` where the face
160- * answers `2026-01`, and at `America/New_York` it grouped 20 and 8 where the
161- * face groups 27 and 1. SQLite and MySQL have no `date_trunc` at all.
162- * - [#21595] **SQLite, where no driver expression answers**: a host whose hook
163- * answers nothing for a SQLite datasource. `date_trunc` is not a SQLite
164- * function, so the representative text is a statement SQLite refuses.
162+ * `tzRequiresInMemory` in objectql's `engine.ts`): the driver only fetches
163+ * the rows, and no statement the database runs groups by those keys. The
164+ * `date_trunc('<granularity>', col)` this used to print groups on the
165+ * database SESSION's calendar where it runs at all. Measured on PostgreSQL
166+ * 16.14 with the server at `Asia/Shanghai`, it answered timestamp keys such
167+ * as `2025-12-31T16:00:00.000Z` where the face answers `2026-01`, and at
168+ * `America/New_York` it grouped 20 and 8 where the face groups 27 and 1.
169+ * - [#21647] **No driver expression for the bucket, on every driver.** The
170+ * hook answers nothing: a driver that runs no SQL (`driver-memory`, whose
171+ * bucket the engine computes in memory; `driver-mongodb`, which buckets in
172+ * its own aggregation pipeline), a SQL driver with no expression for the
173+ * granularity (the engine buckets it in memory), or a host that wires no
174+ * hook. The echo used to print `date_trunc` there and call it
175+ * representative. Measured on `driver-memory` at UTC, the face answered
176+ * `2026-01` and `2026-W02` from rows the driver only fetched, while both
177+ * faces printed `date_trunc('month', closed_at)`, a statement nothing ran.
178+ * [#21595] SQLite, which has no `date_trunc` at all, was the first case of
179+ * this arm.
165180 *
166181 * `NOT_IMPLEMENTED` / 501, for the reason `driver-sql`'s own bucket refusal
167182 * gives: the query is spelled correctly and served, and the gap is the
@@ -174,21 +189,27 @@ interface CrossObjectPlan {
174189 * `sql`, and the dry run (`/analytics/sql`) refuses. Both are within the
175190 * declared response contracts: `sql` is optional on the query answer, and the
176191 * dry run answers in the error envelope.
177- *
178- * `zone` is the non-UTC `timezone` of the first case; `undefined` is the
179- * SQLite case.
180192 */
181- function bucketEchoRefused ( dimension : string , granularity : string , zone : string | undefined ) : Error {
193+ function bucketEchoRefused ( dimension : string , granularity : string , cause : BucketEchoRefusalCause ) : Error {
182194 const err = new Error (
183195 `[analytics] cannot render display SQL for the "${ granularity } " bucket of "${ dimension } "` +
184- ( zone !== undefined
185- ? ` with timezone "${ zone } ". The query itself is SERVED: the engine buckets it in memory on that zone's ` +
186- `calendar, so no statement the database runs groups by those bucket keys. Refusing rather than printing ` +
187- `one that groups on another calendar. Run the query itself (/analytics/query) to get its rows; with ` +
188- `timezone "UTC" or none, a SQL driver groups the bucket itself and this dry run renders its expression.`
189- : ` on SQLite. The query itself is SERVED, but the driver behind this datasource renders no bucket ` +
190- `expression for it. Refusing rather than printing date_trunc(...), which is not a SQLite function. ` +
191- `Run the query itself (/analytics/query) to get its rows.` ) ,
196+ ( 'zone' in cause
197+ ? ` with timezone "${ cause . zone } ". The query itself is SERVED: the engine buckets it in memory on that ` +
198+ `zone's calendar, so no statement the database runs groups by those bucket keys. Refusing rather than ` +
199+ `printing one that groups on another calendar. Run the query itself (/analytics/query) to get its rows; ` +
200+ `with timezone "UTC" or none, this dry run renders the expression the driver groups the bucket by, ` +
201+ `where the driver has one.`
202+ : ( cause . dialect === 'unknown'
203+ ? `: the driver behind this datasource names no SQL dialect and renders no expression for it. A ` +
204+ `driver that runs no SQL, such as the in-memory or MongoDB driver, buckets it without one: the ` +
205+ `engine in memory, MongoDB in its own aggregation pipeline. A host that wires no dateBucketSql hook ` +
206+ `cannot ask its driver at all.`
207+ : `: no SQL expression for it is known on this "${ cause . dialect } " datasource. Either its driver ` +
208+ `renders none for this granularity, and the engine buckets it in memory, or this host wires no ` +
209+ `dateBucketSql hook to ask the driver.` ) +
210+ ` The query itself is SERVED. Refusing rather than printing a bucket expression nothing here ran: ` +
211+ `this dry run prints only the expression the driver itself groups the bucket by. Run the query itself ` +
212+ `(/analytics/query) to get its rows.` ) ,
192213 ) as Error & { code ?: string ; status ?: number ; refusal ?: true } ;
193214 err . code = StandardErrorCode . enum . NOT_IMPLEMENTED ;
194215 err . status = 501 ;
@@ -411,7 +432,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
411432 } ) ;
412433
413434 const fields = this . buildFieldMeta ( query , cube ) ;
414- // Echo a representative SQL alongside the rows (#3588). `NativeSQLStrategy`
435+ // Echo the SQL the query stands for alongside the rows (#3588). `NativeSQLStrategy`
415436 // returns the statement it actually ran, and dataset responses surface that
416437 // string — it is how an author checks what their widget compiled to. This
417438 // path builds an AST, so it had nothing to echo, and the `sql` field simply
@@ -476,15 +497,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
476497 *
477498 * [#21441] A date bucket renders the expression the driver itself groups by
478499 * for its dialect, through the `dateBucketSql` hook, so the echo runs there
479- * and answers the face's bucket keys. [#21630] A non-UTC `timezone`, which
480- * the engine buckets in memory on that zone's calendar, refuses on every
481- * dialect ({@link bucketEchoRefused}): no statement the engine runs groups
482- * by those keys. At a UTC or unset `timezone`, the bucket stays
483- * REPRESENTATIVE where the hook answers nothing (no hook, or a driver with no
484- * bucket expression, such as a non-SQL driver) and prints
485- * `date_trunc('<granularity>', col)`. [#21595] Except on SQLite, which has no
486- * `date_trunc`: there the echo refuses too, so it never prints a statement
487- * SQLite refuses.
500+ * and answers the face's bucket keys. [#21647] That expression is the only
501+ * thing a bucket is ever printed as. Where there is none, the echo refuses
502+ * ({@link bucketEchoRefused}), on every driver and every dialect: a non-UTC
503+ * `timezone`, which the engine buckets in memory on that zone's calendar
504+ * (#21630), and a hook that answers nothing at UTC, which is a driver that
505+ * runs no SQL, a granularity the driver leaves to the engine, or a host that
506+ * wires no hook (#21595 was SQLite's case of it). No bucket expression is
507+ * written here.
488508 *
489509 * Filter VALUES are rendered as `$n` placeholders and returned in `params`,
490510 * never inlined: the echoed statement travels to the browser, and a filter
@@ -601,9 +621,24 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
601621 // `tzRequiresInMemory` in objectql's `engine.ts`, the same test on the same
602622 // `timezone`), so no statement the database runs groups by those keys, and
603623 // the echo refuses on every dialect before the hook is asked
604- // ({@link bucketEchoRefused}). Where nothing answers at UTC, the bucket
605- // keeps the representative `date_trunc`, except on SQLite, which has no
606- // `date_trunc`: there the echo refuses too (#21595).
624+ // ({@link bucketEchoRefused}).
625+ //
626+ // [#21647] Where the hook answers nothing at UTC, the echo refuses too, on
627+ // every dialect. That is the engine's other in-memory condition, read off
628+ // the driver rather than restated here: every `driver-sql` dialect answers
629+ // `null` exactly where its `supports.queryDateGranularity` is false (both
630+ // read `dateGranularityCapabilities`), which is where the engine buckets in
631+ // memory. A driver that runs no SQL has no hook to answer: `driver-memory`
632+ // advertises no granularity, so the engine buckets in memory, and
633+ // `driver-mongodb` buckets in its own pipeline, which no SQL stands for
634+ // either. The engine's predicate itself is not reachable from here.
635+ //
636+ // Two cells print the driver's expression while the engine buckets in
637+ // memory, because the hook answers there and the expression, run on that
638+ // dialect, answers the face's keys: a measure carrying its own `filter`
639+ // (the engine aggregates every such query in memory, #10576), and
640+ // `driver-turso`'s remote face (it advertises no granularity, and inherits
641+ // the SQLite expression, which libSQL runs).
607642 const zone = query . timezone ;
608643 const inMemoryZone = zone && zone !== 'UTC' ? zone : undefined ;
609644 const driverBucketSql = ( col : string , granularity : string ) : string | undefined => {
@@ -621,11 +656,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
621656 const col = this . resolveFieldName ( cube , dim , 'dimension' ) ;
622657 const gran = granByDim . get ( dim ) ;
623658 if ( ! gran ) return col ;
624- if ( inMemoryZone !== undefined ) throw bucketEchoRefused ( dim , gran , inMemoryZone ) ;
659+ if ( inMemoryZone !== undefined ) throw bucketEchoRefused ( dim , gran , { zone : inMemoryZone } ) ;
625660 const bucket = driverBucketSql ( col , gran ) ;
626- if ( bucket !== undefined ) return bucket ;
627- if ( sqlDialectFor ( ctx , tableName ) === 'sqlite' ) throw bucketEchoRefused ( dim , gran , undefined ) ;
628- return `date_trunc('${ gran } ', ${ col } )` ;
661+ if ( bucket === undefined ) throw bucketEchoRefused ( dim , gran , { dialect : sqlDialectFor ( ctx , tableName ) } ) ;
662+ return bucket ;
629663 } ;
630664
631665 if ( query . dimensions ) {
0 commit comments