Skip to content

Commit ee0abf9

Browse files
committed
fix(service-analytics): the echo prints a date bucket only in the driver's own expression
ObjectQLStrategy.generateSql no longer prints a representative date_trunc for a bucket no driver expression stands for. At a UTC or unset timezone, a dateBucketSql hook that answers nothing (driver-memory, driver-mongodb, a granularity the driver leaves to the engine, a host that wires no hook) now refuses with the NOT_IMPLEMENTED / 501 declared refusal the non-UTC arm already answers, on every dialect. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1968d5e commit ee0abf9

3 files changed

Lines changed: 88 additions & 53 deletions

File tree

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,9 +1145,9 @@ export interface AnalyticsServiceConfig {
11451145
*
11461146
* Answered by the plugin from the driver that executes the aggregate. Asked
11471147
* only at a UTC or unset `timezone`: a non-UTC one is bucketed in memory, and
1148-
* the echo refuses it whatever is wired (#21630). At UTC, a host that wires
1149-
* nothing keeps the echo's representative `date_trunc(…)`, except on SQLite,
1150-
* where the echo refuses (#21595).
1148+
* the echo refuses it whatever is wired (#21630). At UTC, where this answers
1149+
* `undefined` or is not wired, the echo refuses the bucket too, on every
1150+
* dialect (#21647): it prints a bucket in no expression but the driver's.
11511151
*/
11521152
dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined;
11531153
/**

‎packages/services/service-analytics/src/plugin.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,9 +1233,10 @@ export class AnalyticsServicePlugin implements Plugin {
12331233
* single source of its bucketing and no second table lives here.
12341234
*
12351235
* `undefined` on every tier that cannot answer: no data engine, a driver
1236-
* without the member (memory, mongo), a granularity the driver buckets in
1237-
* memory (it answers `null`), a throw. `undefined` keeps the echo's
1238-
* representative `date_trunc(…)`.
1236+
* without the member (memory, mongo), a granularity the driver leaves to
1237+
* the engine's in-memory bucketing (it answers `null`), a throw. On
1238+
* `undefined` the echo refuses the bucket (#21647): it prints a bucket in
1239+
* no expression but the one answered here.
12391240
*/
12401241
const dateBucketSql = (objectName: string, field: string, granularity: string): string | undefined => {
12411242
try {

‎packages/services/service-analytics/src/strategies/objectql-strategy.ts‎

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
3838
import { applyOrdering, applyWindow } from '../dataset-executor.js';
3939
import { 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';
4141
import { resolveAnalyticsDateRangeString } from '@objectstack/core';
4242
import { explicitDateRangeWindow } from '../date-range-array-arm.js';
4343
import {
@@ -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

Comments
 (0)