Skip to content

Commit 31e3e00

Browse files
fix(service-analytics): the SQL echo prints a date bucket only in the driver's own expression, on every driver (#21647) (#21664)
Part of #21647 Clause-②: no The ObjectQL face's SQL echo now prints a date-bucketed dimension in one thing only: the expression the driver itself renders for it (the `dateBucketSql` hook), at a `timezone` of `UTC` or none. Everywhere else it answers the declared refusal (`NOT_IMPLEMENTED` / 501, `refusal: true`) that the non-UTC arm and SQLite already gave. The `date_trunc('GRANULARITY', col)` fallback is gone, and so is the docblock that declared it "representative", per triage's ruling `5974759561`. ## Reproduction, on `main` `1968d5e812`, real `driver-memory` A throwaway test (never committed, deleted) served `analytics.query` and `analytics.generateSql`, the seams `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` forward, through the real `AnalyticsServicePlugin` and a real `ObjectQL` engine over `InMemoryDriver`, with the driver's `aggregate` and `find` spied. | timezone | granularity | driver asked | face rows | `/analytics/query` `sql` | `/analytics/sql` | |:--|:--|:--|:--|:--|:--| | UTC | month | `find` only | `2026-01` 27, `2026-02` 1 | `date_trunc('month', closed_at)` | `date_trunc('month', closed_at)` | | UTC | week | `find` only | `2026-W02` 20, `2026-W05` 7, `2026-W06` 1 | `date_trunc('week', closed_at)` | `date_trunc('week', closed_at)` | | unset | month, week | `find` only | the same | the same | the same | After the fix (`ee0abf936e`), the same harness answered the same rows, with no `sql` on the query and `NOT_IMPLEMENTED` / 501 on the dry run, in all four cells. ## The rule, and how it relates to the engine `dimExpr` in `ObjectQLStrategy.generateSql`: 1. a non-UTC `timezone` refuses (the #21630 arm, unchanged); 2. otherwise the hook's answer is printed; 3. otherwise it refuses, on every dialect. The SQLite arm of #21595 is now just the first case of this. The refusal names its cause. The zone arm keeps its message. The no-expression arm says either that the driver names no SQL dialect (the in-memory and MongoDB drivers, or a host with no hook), or that no expression is known on this dialect (the engine buckets it in memory, or the host wires no hook). **The engine's own predicate is not reachable from this package.** `engine.aggregate` decides pushdown inline: `typeof driver.aggregate === 'function'`, every granularity in `supports.queryDateGranularity`, `!tzRequiresInMemory`, and no `aggregations[].filter`. No `IDataEngine` member exposes that decision, and this card allows no `objectql` edit. So the strategy reads the zone (the same test on the same value as `tzRequiresInMemory`) and the hook. Why the hook is the engine's capability signal: every `driver-sql` dialect's `dateBucketSql` answers `null` exactly where its `supports.queryDateGranularity` is false. Both read `dateGranularityCapabilities`, the first line of `buildDateBucketExpr`. Where this reading and the engine's predicate differ, the cases are named, not hidden: - **The engine buckets in memory, and the echo still prints**, because the hook answers and the expression, run on that dialect, answers the face's keys: - a measure carrying its own `filter`: the engine aggregates every such query in memory. The existing echo pin measures that running the echo reproduces the face's rows, live on SQLite and PostgreSQL; - `driver-turso`'s remote face: it advertises `queryDateGranularity: {}`, inherits the SQLite expression (`REMOTE_FACE_ANSWERS.dateBucketSql: 'inherited'`), and libSQL runs it. See the open question in the report: the literal headline of triage's ruling covers both. - **The engine pushes down, and the echo refuses:** `driver-mongodb` (by code path) publishes `queryDateGranularity` for all five granularities (`MONGODB_DATE_GRANULARITIES`), so its own pipeline groups the bucket. This corrects the card's premise that MongoDB buckets in memory. MongoDB runs no SQL, and triage's ruling names the refusal as the honest answer on a non-SQL driver. A SQL host that wires no hook refuses too: it cannot name the expression. ## The enumeration pin: `objectql-echo-bucket-enumeration.test.ts` Driver x timezone class x granularity, 200 cells. Every cell runs through the real plugin bridges and a real engine with the driver's two data doors spied. Each cell therefore **observes** where the engine put the bucket, and its expected echo is derived from that observation: - pushed down to a driver that renders an expression for the bucket: that expression on both faces; - anything else: the refusal on `/analytics/sql`, and no `sql` on `/analytics/query`. The single named exception is Turso's remote face. - **Driver axis:** `BUILTIN_DRIVER_IDS` from `@objectstack/spec/data`. `DRIVER_ROWS` satisfies a `Record` keyed by `BuiltinDriverId`, and a case asserts the key sets are equal, so a new builtin with no row fails both typecheck and the file. - Real: `sqlite` (better-sqlite3), `sqlite-wasm` (sql.js), and `postgres` / `mysql` (`driver-sql`, never connected). - By code path: `memory` (`supports = {}`, no `dialectName`, no `dateBucketSql`), `mongodb`, and `turso`'s local and remote faces. - **Timezone axis:** `UTC`, unset, and any other zone (`Asia/Shanghai`, `America/New_York`, `Etc/UTC`). No enum declares these classes; the engine's inline test does. So a case asserts each probe's tier on the engine, and a zone that moves class turns red. `Etc/UTC` is in the in-memory class because the engine treats only the literal `UTC` as UTC. - **Granularity axis:** `TimeUpdateInterval.options`, the enum `timeDimensions[].granularity` parses. The cells are generated from it, so a new granularity has its cells the moment it is declared. Observed matrix at `1ef5b5e198`, with a temporary log line, since removed: | face | UTC / unset | any other zone | |:--|:--|:--| | memory (by code path) | in memory, refusal | in memory, refusal | | mongodb (by code path) | pushdown, refusal | in memory, refusal | | sqlite, sqlite-wasm, postgres, mysql, turso local | pushdown, the driver's expression | in memory, refusal | | turso remote (by code path) | in memory, the driver's expression (the named exception) | in memory, refusal | A rows case serves real rows through the engine over `driver-memory`'s surface. These are the rows measured above: `2026-01` 27 and `2026-02` 1; `2026-W02` 20, `2026-W05` 7 and `2026-W06` 1. `/analytics/query` answers them with no `sql`, and the dry run refuses. ## Pins turned (no assertion deleted) | pin | asserted | now | why | |:--|:--|:--|:--| | `objectql-echo-date-bucket.test.ts`, FALLBACK block | `date_trunc('month', closed_on)` on a host with no hook, at UTC | the refusal, at UTC and unset | the no-hook UTC arm is the case this card retires | | `dataset-selection-window.test.ts` (was :350) | `date_trunc('month', created_at)` | with a hook: the hook's expression in `SELECT` and `GROUP BY`; with no hook: the rows and no `sql` | its subject, "a bucket expression instead of the bare column", needs a host that has one | | `objectql-daterange.test.ts` (was :423) | `date_trunc('month', close_date)` beside the window | the hook's expression beside the same window | without a hook the dry run refuses, and the case's subject, the window, would go with it | | `cube-authored-format-granularity.test.ts` (was :247) | `date_trunc('month'` | the hook is asked for `placed_at:month` and its expression groups; the no-hook host refuses; the undeclared dimension renders identically on both hosts | its subject is the declared default granularity reaching the dry run | The measure-filter case in the echo pin keeps its assertion, with a comment naming it as one of the two in-memory cells that print. ## Ablation The prediction was written before running. Restoring `main`'s fallback (SQLite refuses, any other dialect prints `date_trunc`) in place of the no-expression refusal should turn exactly 28 cases red: - enumeration: memory UTC and unset, 10; mongodb UTC and unset, 10; the rows case, 4; - echo pin: the no-hook UTC and unset cases, 2; - `dataset-selection-window`: 1; - `cube-authored-format-granularity`: 1. Everything else should stay green: every SQL-driver UTC/unset cell, every non-UTC cell, the SQLite no-hook case, the Turso exception and `objectql-daterange`. Observed at `1ef5b5e198` (fix committed first), through `scripts/ablation-replace.mjs` in wrap mode under the verify lock: - the anchor went from 1 hit to 0, and the replacement from 0 to 1; the blob went `b6ff007d3371` to `895dda1491ae`; - **28 failed, 275 passed, 9 skipped (312)**, exactly the predicted set. Restore: the blob after restore is `b6ff007d3371`, equal to HEAD; `git diff HEAD` is empty; `git status --porcelain` is empty; there are 0 marker hits. The same result was observed earlier at `d0875f3a82`, when the memory row was the real driver. There is no dist leg: every subject is imported from `src` by relative path. ## Tests, at `1ef5b5e198` - **Full suite:** `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: 177 files passed; 4403 tests passed, 262 skipped. The skips are the live-server cells. - **Live PostgreSQL 16.14** (throwaway cluster, since stopped and deleted), `OS_TEST_POSTGRES_URL` set: the echo pin plus the enumeration, 260 of 260 passed, 0 skipped, the live postgres cell included. - **Typecheck:** `pnpm --filter @objectstack/service-analytics typecheck` exits 0, and `tsc --listFiles` includes all five touched test files. - **Build:** `pnpm --filter @objectstack/service-analytics build` exits 0. `dist/index.d.ts` carries the corrected `AnalyticsServiceConfig.dateBucketSql` TSDoc, and the old "representative `date_trunc`" text has 0 hits. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 64 commands. All 64 exited 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`) and exited 0 after a full `turbo run build`, which had 71 of 73 tasks cached. `--ran` with exit codes reports 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint, a declared narrowing:** 1. eslint's own config admits all 8 changed `.ts` files (`--print-config` answers for each; none is reported ignored); 2. `--no-inline-config --format json`: 8 files, 0 errors, 0 warnings; 3. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on an untouched file. ## Docs `content/docs/**` (outside `releases/`) has no sentence this makes false. `data-api.mdx` says the dry run's "support depends on the underlying driver/strategy". In `skills/**`, `objectstack-ui/rules/dashboards.md:326` and `objectstack-query/rules/aggregation.md:82` name `date_trunc` for the engine's pushdown, not for the echo. Both are carried by #21588, and neither is edited here. ## Acceptance notes - The PM's assumption to pin `/analytics/query` on the real `driver-memory` was falsified by `check:driver-memory-census`. Every consumer of that package is ledgered, and a new one is a maintainer ruling, not a ledger edit. The memory row and the rows case are pinned by code path under the real engine, and the real driver was measured before and after the change in an uncommitted repro. An intermediate commit (`d0875f3a82`) added the devDependency; `1ef5b5e198` withdrew it, and `package.json`, `vitest.config.ts` and `pnpm-lock.yaml` are byte-identical to `main`. - `native-sql-strategy.ts`, around `canHandle`, says the engine buckets with "native date_trunc when UTC-safe". The drivers bucket with `to_char`, `date_format` and `strftime`. The comment predates this PR, this PR does not make it false, and nobody has been named to carry it. - `main` gained two commits after this branch merged `759dbe9ed`: a spec inline-grid retirement and a service-automation fix. Both are disjoint from this package. The PR's CI runs on the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72f3c74 commit 31e3e00

13 files changed

Lines changed: 688 additions & 89 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
The analytics SQL echo prints a date bucket only in the expression the driver itself groups it by, and refuses everywhere else, including on the in-memory and MongoDB drivers (#21647).
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** At a `timezone` of `UTC`, or with none, the ObjectQL face of `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` echoed a date-bucketed dimension as `date_trunc('month', col)` (or the asked granularity) wherever the driver renders no bucket expression of its own, and documented that as representative. On `driver-memory` the engine only fetches the rows and buckets them itself, answering keys such as `2026-01` and `2026-W02`, while both faces printed `date_trunc(...)`, a statement nothing ran. `driver-mongodb`, which groups the bucket in its own aggregation pipeline, took the same path. So did any host that wires no `dateBucketSql` hook.
10+
- **What it does now.** Wherever no driver expression stands for the bucket, on every driver and dialect:
11+
- `POST /api/v1/analytics/sql` refuses with `NOT_IMPLEMENTED` / 501, declared as a refusal so its message reaches the caller. Its message names the cause. A non-UTC `timezone` and SQLite already answered this way.
12+
- `POST /api/v1/analytics/query` answers the same rows as before, and its answer carries no `sql`.
13+
- **Unchanged.** On PostgreSQL, MySQL and SQLite at `UTC` or with no `timezone`, the echo still prints the expression the driver groups by: `to_char(...)`, `date_format(...)` and `strftime(...)`.

‎packages/client/src/envelope-caller-census.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,12 @@ const LEDGER: readonly LedgerRow[] = [
493493
method: 'analytics.query', receiver: 'service', count: 1, verdict: 'NOT_SDK',
494494
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',
495495
},
496+
// ── [#21647] the bucket echo's driver x timezone x granularity enumeration: producer reads only ──
497+
{
498+
file: 'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts',
499+
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
500+
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over an ObjectQL engine with the driver\'s data doors spied, called directly (no HTTP, no dispatcher envelope) to read whether the ObjectQL face\'s answer carries an echoed `sql`, and its rows',
501+
},
496502
{
497503
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
498504
method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT',
@@ -699,11 +705,15 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
699705
// the real AnalyticsService that `AnalyticsServicePlugin` registers
700706
// over a live engine, to read the ObjectQL face's rows beside its
701707
// echoed `sql`. Its receiver is that service, not the client.
702-
expect(service.length, literalNote()).toBe(9);
708+
// [#21647] A fifth, two sites: the bucket echo's enumeration pin calls
709+
// the same plugin-registered AnalyticsService, over an engine with the
710+
// driver's data doors spied, to read whether the answer carries `sql`.
711+
expect(service.length, literalNote()).toBe(11);
703712
expect([...new Set(service.map((s) => s.file))].sort()).toEqual([
704713
'packages/client/src/analytics-automation-json-erasure.test.ts',
705714
'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
706715
'packages/rest/src/analytics-nested-relation-filter.test.ts',
716+
'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts',
707717
'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts',
708718
]);
709719
expect(service.every((s) => s.method === 'analytics.query')).toBe(true);
@@ -751,11 +761,12 @@ describe('#13079 §3 — every call site is classified', () => {
751761
expect(production, literalNote()).toEqual([]);
752762
});
753763

754-
it('records the split: 18 payload pins, 10 result-insensitive, 9 not-SDK', () => {
764+
it('records the split: 18 payload pins, 10 result-insensitive, 11 not-SDK', () => {
755765
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
756766
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
757767
// [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2).
758-
expect(verdictTotal('NOT_SDK')).toBe(9);
768+
// [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2).
769+
expect(verdictTotal('NOT_SDK')).toBe(11);
759770
// The three above are LEDGER sums and cannot move on a census reading;
760771
// this one is census-derived, so it carries the note. [#13874]
761772
expect(sdkSites.length, literalNote()).toBe(28);

‎packages/runtime/src/analytics-authored-cube-format-granularity.test.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,19 @@ describe('an authored time dimension\'s single declared granularity is its defau
144144
expect(res.body.data.rows).toEqual([{ placed_at: '2026-07', count: 2 }]);
145145
});
146146

147-
it('POST /analytics/sql dry-runs the bucketed statement, and a stated granularity still wins', async () => {
147+
// [#21647] This case asserted 200 and `date_trunc('month'` / `date_trunc('year'`:
148+
// the representative bucket the SQL echo printed for a host with no
149+
// `dateBucketSql` hook, which no driver groups by. This host composes the
150+
// service directly, with no hook and no driver behind it, so the dry run now
151+
// answers the service's declared refusal (`NOT_IMPLEMENTED` / 501,
152+
// `refusal: true` at throw time). This exit reads that declaration to keep
153+
// the producer's message instead of withholding a 5xx as a fault, so the
154+
// message reaching the wire IS the declaration's effect: it names the bucket
155+
// it refused and the cause, the declared default for the first request and
156+
// the stated granularity for the second. Where a hook answers, the
157+
// service-level pin (`service-analytics` `cube-authored-format-granularity.test.ts`)
158+
// asserts the driver's expression.
159+
it('POST /analytics/sql dry-runs the bucket at the declared granularity, and a stated granularity still wins (no dateBucketSql hook: the declared refusal)', async () => {
148160
const declared = await post(analytics().service, 'sql', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] });
149161
const stated = await post(analytics().service, 'sql', {
150162
cube: 'orders',
@@ -153,9 +165,12 @@ describe('an authored time dimension\'s single declared granularity is its defau
153165
timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }],
154166
});
155167

156-
expect(declared.statusCode).toBe(200);
157-
expect(declared.body.data.sql).toMatch(/date_trunc\('month'/i);
158-
expect(stated.statusCode).toBe(200);
159-
expect(stated.body.data.sql).toMatch(/date_trunc\('year'/i);
168+
for (const [res, granularity] of [[declared, 'month'], [stated, 'year']] as const) {
169+
expect(res.statusCode, granularity).toBe(501);
170+
expect(res.body.success, granularity).toBe(false);
171+
expect(res.body.error.code, granularity).toBe('NOT_IMPLEMENTED');
172+
expect(res.body.error.message, granularity).toContain(`"${granularity}" bucket of "placed_at"`);
173+
expect(res.body.error.message, granularity).toContain('dateBucketSql');
174+
}
160175
});
161176
});

‎packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,15 @@ const dataset = DatasetSchema.parse({
8686

8787
type GroupByItem = string | { field: string; dateGranularity?: string };
8888

89-
/** An ObjectQL-only host that records the `groupBy` every aggregate ran with. */
90-
function objectqlService(cubes: Cube[] = [authored]) {
89+
/**
90+
* An ObjectQL-only host that records the `groupBy` every aggregate ran with.
91+
* [#21647] `dateBucketSql` is the host's driver-expression hook, unwired by
92+
* default.
93+
*/
94+
function objectqlService(
95+
cubes: Cube[] = [authored],
96+
dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined,
97+
) {
9198
const groupBys: GroupByItem[][] = [];
9299
const service = new AnalyticsService({
93100
logger: silentLogger,
@@ -97,6 +104,7 @@ function objectqlService(cubes: Cube[] = [authored]) {
97104
groupBys.push((options.groupBy ?? []) as GroupByItem[]);
98105
return [{ status: 'open', count: 2, revenue: 10, margin: 0.25, placed_at: '2026-07' }];
99106
},
107+
dateBucketSql,
100108
});
101109
return { service, groupBys };
102110
}
@@ -238,14 +246,34 @@ describe('analytics_cube.dimensions.granularities — the declared single granul
238246
expect(groupBys).toEqual([[]]);
239247
});
240248

249+
// [#21647] This case asserted `date_trunc('month'` on a host that wires no
250+
// `dateBucketSql` hook: a representative bucket no driver groups by. The
251+
// echo now prints the driver's own expression or refuses, so the declared
252+
// default is asserted as the granularity the hook is asked for and the
253+
// expression it answers, and the no-hook host as the refusal, which only a
254+
// bucketed dimension draws.
241255
it('`generateSql()` dry-runs the bucketed statement `query()` runs', async () => {
242-
const { service } = objectqlService();
256+
const asked: string[] = [];
257+
const { service } = objectqlService([authored], (_object, field, granularity) => {
258+
asked.push(`${field}:${granularity}`);
259+
return `driver_bucket('${granularity}', ${field})`;
260+
});
243261

244262
const declared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] });
245263
const undeclared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] });
246264

247-
expect(declared.sql).toMatch(/date_trunc\('month'/i);
248-
expect(undeclared.sql).not.toMatch(/date_trunc/i);
265+
expect(asked).toEqual(['placed_at:month']);
266+
expect(declared.sql).toContain(`GROUP BY driver_bucket('month', placed_at)`);
267+
expect(undeclared.sql).not.toContain('driver_bucket');
268+
269+
const { service: noHook } = objectqlService();
270+
const refused = await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }).then(
271+
() => { throw new Error('expected the echo to refuse'); },
272+
(e) => e as Error & { code?: string; status?: number; refusal?: unknown },
273+
);
274+
expect([refused.code, refused.status, refused.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]);
275+
expect((await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] })).sql)
276+
.toBe(undeclared.sql);
249277
});
250278

251279
it('native SQL declines a bucketed query, so a declared default routes to the engine path as a dataset does', async () => {

‎packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@ type AggCall = { object: string; options: Record<string, unknown> };
4848
* development. Only the tests that read `result.sql` — the block asserting the
4949
* echo tells the truth — need it on; the rest of this file measures the CALL
5050
* the executor made, which the gate does not touch.
51+
*
52+
* [#21647] `dateBucketSql` is the host's driver-expression hook; the echo
53+
* prints a date bucket in nothing else.
5154
*/
5255
function aggService(
5356
rows: Record<string, unknown>[],
5457
calls: AggCall[] = [],
55-
options: { debugSql?: boolean } = {},
58+
options: { debugSql?: boolean; dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined } = {},
5659
) {
5760
const svc = new AnalyticsService({
5861
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
@@ -61,6 +64,7 @@ function aggService(
6164
return rows;
6265
},
6366
debugSql: options.debugSql,
67+
dateBucketSql: options.dateBucketSql,
6468
});
6569
return { svc, calls };
6670
}
@@ -347,16 +351,35 @@ describe('#3588 — ordering never corrupts a multi-query selection', () => {
347351
* state it rather than inherit whatever `NODE_ENV` the runner happens to have.
348352
*/
349353
describe('#3588 — the echoed SQL tells the truth on the ObjectQL path', () => {
350-
it('renders date_trunc for a bucketed dimension instead of the bare column', async () => {
351-
const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true });
352-
const result = await svc.queryDataset(
353-
accounts,
354-
{ dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' },
355-
CTX,
356-
);
357-
expect(result.sql).toContain(`date_trunc('month', created_at)`);
358-
expect(result.sql).toContain('GROUP BY');
354+
// [#21647] This case asserted `date_trunc('month', created_at)`, which its
355+
// host, wiring no `dateBucketSql` hook, got as a representative bucket no
356+
// driver groups by. The echo now prints the driver's own expression or
357+
// nothing, so the case runs both hosts: with the hook (a stub standing for
358+
// the driver's answer; which driver answers what is pinned in
359+
// `objectql-echo-date-bucket.test.ts`), and without it.
360+
it('renders the driver\'s bucket expression for a bucketed dimension instead of the bare column, or no sql where the host has none', async () => {
361+
const DRIVER_BUCKET = `driver_bucket('month', created_at)`;
362+
const asked: string[] = [];
363+
const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], {
364+
debugSql: true,
365+
dateBucketSql: (_object, field, granularity) => {
366+
asked.push(`${field}:${granularity}`);
367+
return `driver_bucket('${granularity}', ${field})`;
368+
},
369+
});
370+
const selection = { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' as const };
371+
const result = await svc.queryDataset(accounts, selection, CTX);
372+
expect(asked).toContain('created_at:month');
373+
expect(result.sql).toContain(`${DRIVER_BUCKET} AS "created_at"`);
374+
expect(result.sql).toContain(`GROUP BY ${DRIVER_BUCKET}`);
359375
expect(result.sql).toContain('COUNT(*) AS "account_count"');
376+
377+
// No hook: the echo refuses the bucket, so the answer carries its rows
378+
// and no `sql`, never one grouping by the bare column.
379+
const { svc: noHook } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true });
380+
const bare = await noHook.queryDataset(accounts, selection, CTX);
381+
expect(bare.rows).toEqual([{ created_at: '2026-06', account_count: 4 }]);
382+
expect(bare.sql).toBeUndefined();
360383
});
361384

362385
it('renders the ordering and window that the response rows actually reflect', async () => {

‎packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,16 @@ const DECLARED_DATETIME = {
402402
describe('ObjectQLStrategy.generateSql — window rendering (#3650)', () => {
403403
it('renders the window as a parameterised half-open pair', async () => {
404404
const seen: AggOpts[] = [];
405-
const svc = makeService(seen, DECLARED_DATETIME);
405+
// [#21647] The bucket in this statement used to be the representative
406+
// `date_trunc('month', close_date)` a host with no `dateBucketSql` hook
407+
// got. That host's dry run now refuses the bucket outright, which would
408+
// take this case's subject, the window, with it. So the host wires the
409+
// hook (a stub standing for the driver's answer), and the bucket is
410+
// asserted as that answer.
411+
const svc = makeService(seen, {
412+
...DECLARED_DATETIME,
413+
dateBucketSql: (_object: string, field: string, granularity: string) => `driver_bucket('${granularity}', ${field})`,
414+
});
406415

407416
const { sql, params } = await svc.generateSql!({
408417
cube: 'sales',
@@ -420,7 +429,7 @@ describe('ObjectQLStrategy.generateSql — window rendering (#3650)', () => {
420429
// datetime column; a BETWEEN would hand a debugger SQL that drops the
421430
// final day's rows.
422431
expect(sql).toContain('(close_date >= $1 AND close_date < $2)');
423-
expect(sql).toContain("date_trunc('month', close_date)");
432+
expect(sql).toContain(`driver_bucket('month', close_date) AS "close_date"`);
424433
// Bounds bind as parameters — the echoed string travels to the browser.
425434
expect(params).toEqual(['2026-01-01', '2026-03-01']);
426435
expect(sql).not.toContain('2026-01-01');

0 commit comments

Comments
 (0)