Skip to content
13 changes: 13 additions & 0 deletions .changeset/21647-echo-never-in-memory-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-analytics': patch
---

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).

Clause-②: no

- **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.
- **What it does now.** Wherever no driver expression stands for the bucket, on every driver and dialect:
- `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.
- `POST /api/v1/analytics/query` answers the same rows as before, and its answer carries no `sql`.
- **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(...)`.
17 changes: 14 additions & 3 deletions packages/client/src/envelope-caller-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@ const LEDGER: readonly LedgerRow[] = [
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',
},
// ── [#21647] the bucket echo's driver x timezone x granularity enumeration: producer reads only ──
{
file: 'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts',
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
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',
},
{
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT',
Expand Down Expand Up @@ -699,11 +705,15 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
// 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);
// [#21647] A fifth, two sites: the bucket echo's enumeration pin calls
// the same plugin-registered AnalyticsService, over an engine with the
// driver's data doors spied, to read whether the answer carries `sql`.
expect(service.length, literalNote()).toBe(11);
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-bucket-enumeration.test.ts',
'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts',
]);
expect(service.every((s) => s.method === 'analytics.query')).toBe(true);
Expand Down Expand Up @@ -751,11 +761,12 @@ describe('#13079 §3 — every call site is classified', () => {
expect(production, literalNote()).toEqual([]);
});

it('records the split: 18 payload pins, 10 result-insensitive, 9 not-SDK', () => {
it('records the split: 18 payload pins, 10 result-insensitive, 11 not-SDK', () => {
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
// [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2).
expect(verdictTotal('NOT_SDK')).toBe(9);
// [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2).
expect(verdictTotal('NOT_SDK')).toBe(11);
// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,19 @@ describe('an authored time dimension\'s single declared granularity is its defau
expect(res.body.data.rows).toEqual([{ placed_at: '2026-07', count: 2 }]);
});

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

expect(declared.statusCode).toBe(200);
expect(declared.body.data.sql).toMatch(/date_trunc\('month'/i);
expect(stated.statusCode).toBe(200);
expect(stated.body.data.sql).toMatch(/date_trunc\('year'/i);
for (const [res, granularity] of [[declared, 'month'], [stated, 'year']] as const) {
expect(res.statusCode, granularity).toBe(501);
expect(res.body.success, granularity).toBe(false);
expect(res.body.error.code, granularity).toBe('NOT_IMPLEMENTED');
expect(res.body.error.message, granularity).toContain(`"${granularity}" bucket of "placed_at"`);
expect(res.body.error.message, granularity).toContain('dateBucketSql');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,15 @@ const dataset = DatasetSchema.parse({

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

/** An ObjectQL-only host that records the `groupBy` every aggregate ran with. */
function objectqlService(cubes: Cube[] = [authored]) {
/**
* An ObjectQL-only host that records the `groupBy` every aggregate ran with.
* [#21647] `dateBucketSql` is the host's driver-expression hook, unwired by
* default.
*/
function objectqlService(
cubes: Cube[] = [authored],
dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined,
) {
const groupBys: GroupByItem[][] = [];
const service = new AnalyticsService({
logger: silentLogger,
Expand All @@ -97,6 +104,7 @@ function objectqlService(cubes: Cube[] = [authored]) {
groupBys.push((options.groupBy ?? []) as GroupByItem[]);
return [{ status: 'open', count: 2, revenue: 10, margin: 0.25, placed_at: '2026-07' }];
},
dateBucketSql,
});
return { service, groupBys };
}
Expand Down Expand Up @@ -238,14 +246,34 @@ describe('analytics_cube.dimensions.granularities — the declared single granul
expect(groupBys).toEqual([[]]);
});

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

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

expect(declared.sql).toMatch(/date_trunc\('month'/i);
expect(undeclared.sql).not.toMatch(/date_trunc/i);
expect(asked).toEqual(['placed_at:month']);
expect(declared.sql).toContain(`GROUP BY driver_bucket('month', placed_at)`);
expect(undeclared.sql).not.toContain('driver_bucket');

const { service: noHook } = objectqlService();
const refused = await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }).then(
() => { throw new Error('expected the echo to refuse'); },
(e) => e as Error & { code?: string; status?: number; refusal?: unknown },
);
expect([refused.code, refused.status, refused.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]);
expect((await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] })).sql)
.toBe(undeclared.sql);
});

it('native SQL declines a bucketed query, so a declared default routes to the engine path as a dataset does', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ type AggCall = { object: string; options: Record<string, unknown> };
* development. Only the tests that read `result.sql` — the block asserting the
* echo tells the truth — need it on; the rest of this file measures the CALL
* the executor made, which the gate does not touch.
*
* [#21647] `dateBucketSql` is the host's driver-expression hook; the echo
* prints a date bucket in nothing else.
*/
function aggService(
rows: Record<string, unknown>[],
calls: AggCall[] = [],
options: { debugSql?: boolean } = {},
options: { debugSql?: boolean; dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined } = {},
) {
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
Expand All @@ -61,6 +64,7 @@ function aggService(
return rows;
},
debugSql: options.debugSql,
dateBucketSql: options.dateBucketSql,
});
return { svc, calls };
}
Expand Down Expand Up @@ -347,16 +351,35 @@ describe('#3588 — ordering never corrupts a multi-query selection', () => {
* state it rather than inherit whatever `NODE_ENV` the runner happens to have.
*/
describe('#3588 — the echoed SQL tells the truth on the ObjectQL path', () => {
it('renders date_trunc for a bucketed dimension instead of the bare column', async () => {
const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true });
const result = await svc.queryDataset(
accounts,
{ dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' },
CTX,
);
expect(result.sql).toContain(`date_trunc('month', created_at)`);
expect(result.sql).toContain('GROUP BY');
// [#21647] This case asserted `date_trunc('month', created_at)`, which its
// host, wiring no `dateBucketSql` hook, got as a representative bucket no
// driver groups by. The echo now prints the driver's own expression or
// nothing, so the case runs both hosts: with the hook (a stub standing for
// the driver's answer; which driver answers what is pinned in
// `objectql-echo-date-bucket.test.ts`), and without it.
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 () => {
const DRIVER_BUCKET = `driver_bucket('month', created_at)`;
const asked: string[] = [];
const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], {
debugSql: true,
dateBucketSql: (_object, field, granularity) => {
asked.push(`${field}:${granularity}`);
return `driver_bucket('${granularity}', ${field})`;
},
});
const selection = { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' as const };
const result = await svc.queryDataset(accounts, selection, CTX);
expect(asked).toContain('created_at:month');
expect(result.sql).toContain(`${DRIVER_BUCKET} AS "created_at"`);
expect(result.sql).toContain(`GROUP BY ${DRIVER_BUCKET}`);
expect(result.sql).toContain('COUNT(*) AS "account_count"');

// No hook: the echo refuses the bucket, so the answer carries its rows
// and no `sql`, never one grouping by the bare column.
const { svc: noHook } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true });
const bare = await noHook.queryDataset(accounts, selection, CTX);
expect(bare.rows).toEqual([{ created_at: '2026-06', account_count: 4 }]);
expect(bare.sql).toBeUndefined();
});

it('renders the ordering and window that the response rows actually reflect', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,16 @@ const DECLARED_DATETIME = {
describe('ObjectQLStrategy.generateSql — window rendering (#3650)', () => {
it('renders the window as a parameterised half-open pair', async () => {
const seen: AggOpts[] = [];
const svc = makeService(seen, DECLARED_DATETIME);
// [#21647] The bucket in this statement used to be the representative
// `date_trunc('month', close_date)` a host with no `dateBucketSql` hook
// got. That host's dry run now refuses the bucket outright, which would
// take this case's subject, the window, with it. So the host wires the
// hook (a stub standing for the driver's answer), and the bucket is
// asserted as that answer.
const svc = makeService(seen, {
...DECLARED_DATETIME,
dateBucketSql: (_object: string, field: string, granularity: string) => `driver_bucket('${granularity}', ${field})`,
});

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