Skip to content

Commit 7879391

Browse files
fix(objectql): bucket a declared Field.date by its calendar day under every timezone (#24618) (#24642)
Fixes #24618 Clause-②: no ## What was wrong A non-UTC `timezone` forces `engine.aggregate` onto the in-memory rows path (ADR-0053 Phase 2). On that path `projectGroupValue` passed every date-bucketed value to `bucketDateKey` together with the zone. It had no `date` / `datetime` branch. `bucketDateKey` takes an INSTANT, so it read `'2026-10-01'` as `2026-10-01T00:00Z` and placed it on the zone's calendar. In every zone west of UTC, a `Field.date` therefore landed one day early. The SQL pushdown path, which runs only at UTC, labelled the stored day. Measured at bucketDateKey on the base `973917c57`: `'2026-10-01'` under America/New_York gives `2026-09-30 / 2026-W40 / 2026-09 / 2026-Q3 / 2026`. America/Los_Angeles gives the same. UTC and Asia/Tokyo give `2026-10-01 / 2026-W40 / 2026-10 / 2026-Q4 / 2026`. A `Date` at that day's UTC midnight shifts the same way. ## The fix - `packages/objectql/src/in-memory-aggregation.ts`: `applyInMemoryAggregation` already receives the object's declared field map, because `engine.aggregate` passes `declaredFields`. Two new internal helpers (neither is exported): - `calendarDayGroupFields` reads which date-bucketed `groupBy` fields the object declares a calendar day. It reads them once per call, from the declaration, through `declaredFieldClasses` (class `date`, that is, `CALENDAR_DATE_TYPES`). - `bucketCalendarDay` puts such a value through `temporalStorageForm(value, 'date')`, the one storage rule. It then labels the value with `bucketDateKey` and no zone. - Fields that keep today's zone path: - a `datetime`; - any field the declaration cannot type: no `fields` argument, an undeclared field, or a `formula`. - `packages/core/src/utils/datetime.ts`: comment only. `bucketDateKey`'s docblock now says that `value` is an instant, and that a caller bucketing a `date` passes no zone. - No exported signature changes and no new export. The built `.d.ts` still reads `bucketDateKey(value, granularity, timezone?)`, `bucketDateValue(value, granularity, timezone?)` and `applyInMemoryAggregation(rows, ast, timezone?, fields?, reportWithheld?)`. Neither helper appears in any `.d.ts` as a declaration. Where it lands: the field type was already available inside `packages/objectql`, so `packages/services/service-analytics` needs no change. The cube path reaches this function through `ObjectQLStrategy`, then `ctx.executeAggregate`, then `engine.aggregate`, then `applyInMemoryAggregation(raw, ast, tz, declaredFields)`. `dataset-executor.ts` passes its timezone into the same `engine.aggregate`. ## Fixture triage `packages/objectql/src/engine-aggregate-timezone.test.ts` declared `closed_at` a `date`, but its rows hold instants such as `2024-03-01T03:00:00.000Z`, and its case pins the New York shift. - Disposition: the declaration is corrected to `datetime`, the type of what the rows store. The file keeps pinning the routing it is about, and the new file pins the `date` reading. - I scanned every test that combines a date bucket with a non-UTC zone: 7 files. The other six use mocks, refusals or totals only, and none of them changes. - `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` buckets `created_at`. The registry injects that field as a `datetime`, so it is unaffected. ## Tests New file `packages/objectql/src/in-memory-aggregation-date-calendar-day.test.ts`, 30 cases. All expected labels are written as literals. - **(a)** A declared `date` equals the stored days under America/New_York, America/Los_Angeles, UTC and Asia/Tokyo, at day, week, month, quarter and year. The fixture covers these edges: - a quarter start (10-01); - two ISO-week Mondays (10-05, 11-02); - a month start on a Sunday (11-01); - a year start in the previous year's W53 (2027-01-01); - an unset day. Also in (a): - the door's expected month `{2026-10: 6, 2026-11: 4}` and quarter `{2026-Q4: 10}` under New York; - a `Date` value at UTC midnight buckets by its UTC day; - an `alias` does not change which field's declaration is read. - **(b)** Control: a declared `datetime` under New York still lands on the New York day. `2026-10-01T02:00Z` gives `2026-09-30` and quarter `2026-Q3`. Under UTC the same instants land on the UTC day. - **(c)** `engine.aggregate`: the pushdown route and the rows route give the same labels for a `date`. The pushdown route runs at UTC; a driver double answers the literal labels and counts its native calls. The rows route runs under each non-UTC zone. **Ablation.** The fix was committed first (`3e648b2eb`). `node scripts/ablation-replace.mjs` then replaced `calendarDayFields.has(field)` with `false`: anchor 1 to 0, blob `2f518a44` to `ca64430f`. After restore, the blob equals HEAD's and `git diff HEAD` is empty. - Predicted: (a) red under New York and Los Angeles, green under UTC and Tokyo; (b) green; (c) red. - Observed: **18 failed / 14 passed**, over the new file plus `engine-aggregate-timezone.test.ts`. The 18 are the 13 New York / Los Angeles cases of (a) and the 5 of (c). For the door case, the received month under the mutation was `{2026-09: 1, 2026-10: 6, 2026-11: 3}`. - Green: (b), the UTC and Tokyo cases, and the routing file. - The suite imports `./in-memory-aggregation.js` relatively, so the mutation ran against `src` and needed no build leg. **Real-SQL parity.** This was a one-off measurement and is not committed. - Setup: `@objectstack/driver-sql` (better-sqlite3 `:memory:`) built in this worktree, with `@objectstack/objectql` built at `d789f7e23`. Same 12 rows, `due_date` declared `date`. - `driver.aggregate` (the real SQL) against `applyInMemoryAggregation(find(), ast, tz, fields)`, for New York, Los Angeles and Tokyo at all five granularities: **15/15 agree**. - Control leg, the same call without the field map (the zone path the code took before): **10/15 disagree**. Those are New York and Los Angeles at all five granularities; Tokyo agrees. - `engine.aggregate` over the same `SqlDriver`, UTC (real pushdown) against each non-UTC zone (rows route): **15/15 agree**. - SQLite's `find()` presented the `date` column as a `string`. driver-memory's `find()` returned both a string write and a `Date` write as a `'YYYY-MM-DD'` string. It advertises no native granularity, so its `date` buckets always take this path. **Runs:** - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 448 files / 8444 tests passed at `d789f7e23`. `src` is unchanged since then; only the new test file's typing changed. Re-run at `bf7b57a5d`: the new file plus the routing file, 2 files / 32 tests passed. - `pnpm --filter @objectstack/objectql typecheck` (tsc plus `check:test-typecheck`, whose program lists both test files) exited 0 at `bf7b57a5d`. - `pnpm --filter @objectstack/core typecheck` and its local tests (106 files / 2580 tests) passed at `d789f7e23`. - Local gate union at `bf7b57a5d`: 68 derived commands (`node scripts/pm/dispatch-gates.mjs --commands`), 67 exit 0. `node scripts/check-engine-split-ratio.mjs --days 90` exited 2: it refuses on a shallow clone, and the deepening fetch was cut off by the proxy (`fatal: early EOF`), so it is NOT MEASURED. `--ran` reconciliation: 68 derived, 68 run, 0 unrun. Not run locally, left to CI: the `repo` vitest project (`test:repo`), the workspace type-check lanes, and the families the derivation names as outside its list. ## Not in this PR: the parity gate's non-UTC probe `packages/verify/src/date-bucket-parity.ts` belongs to `domain:cli` and was not edited. It calls `applyInMemoryAggregation(rows, ast)` with no zone and no field map, so it cannot see this class. To see it, the `on` column (`Field.date`) needs a second comparison: the same pushed-down answer against `applyInMemoryAggregation(rows, ast, 'America/New_York', FIELDS)`, plus a positive-offset zone. A `date` pushdown answer has no zone. The probe's existing `on` values `2024-07-01` and `2025-01-01` already cross a quarter edge and a year edge under New York. This half of the card's "expected fix shape" goes to that lane through the seat. ## Acceptance notes - `packages/drivers/driver-memory/src/memory-analytics.ts`, `MemoryAnalyticsService.aggregateWithTimeBuckets`, calls `bucketDateKey(value, granularity, timezone)` with no `date` / `datetime` branch. That is the same class of defect. No stock composition registers `MemoryAnalyticsService`: it is exported, and `service-analytics` names it only as a fallback a host supplies. I measured no reach, so nothing is filed. Owner: none. - `engine.aggregate` still forces the rows route for any non-UTC zone, even when every date-bucketed item is a declared `date`, whose pushdown answer does not depend on the zone. This is a performance note, not a defect, and nothing pulls on it. - A `formula` whose result is a date stays on the zone path, because its declaration names no stored value class. --- _Generated by [Claude Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14ccb1d commit 7879391

5 files changed

Lines changed: 340 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a `date` dimension buckets on its own calendar day under every timezone on the in-memory path, as the SQL pushdown path already did
6+
7+
Clause-②: no (no authorable key, export or signature moves; the in-memory answer for a `date` dimension under a non-UTC zone now equals the stored day, which is what the pushdown path answers)
8+
9+
- **A `Field.date` is a calendar day, not an instant (ADR-0053).** When `engine.aggregate` buckets in memory (any non-UTC `timezone` forces that path), a `groupBy` item with a `dateGranularity` on a field the object declares a `date` is now labelled by that field's own `YYYY-MM-DD`, with no zone. Before, the value was read as that day's UTC midnight and placed on the requested zone's calendar, so in every zone west of UTC each row landed one day early: `2026-10-01` bucketed as `2026-09-30`, and month, quarter and year totals moved across their edges (`POST /api/v1/analytics/query` with `timezone: "America/New_York"` answered `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for six October and four November deliveries).
10+
- **A `datetime` keeps the zone.** An instant still lands on the requested zone's calendar day, unchanged. So does a field the declaration cannot type: `applyInMemoryAggregation` called without its `fields` argument, an undeclared field, or a `formula`.
11+
- **The value is read by the `date` storage rule first** (`temporalStorageForm(value, 'date')` in `@objectstack/core`): a bare `YYYY-MM-DD` is itself, and a `Date` handed back by a driver is its UTC calendar day.

‎packages/core/src/utils/datetime.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,16 @@ export function isBucketGranularity(value: unknown): value is BucketGranularity
290290
* built from those parts — the parts already carry the zone shift, so the week
291291
* boundary lands correctly without re-applying any offset.
292292
*
293+
* [#24618] `value` is an INSTANT, and the zone places it on a calendar. A
294+
* `Field.date` value is not one: it is a timezone-naive calendar day (ADR-0053,
295+
* "never converted to an instant and never timezone-shifted"), and handed in
296+
* with a zone it is read as that day's UTC midnight and lands on the PREVIOUS
297+
* day in every zone west of UTC. A caller bucketing a `date` therefore passes
298+
* no `timezone` — after putting the value into the `date` storage form
299+
* (`temporalStorageForm(value, 'date')`) — which is what
300+
* `@objectstack/objectql`'s rows path does for a field its object declares a
301+
* `date`. The field type is the caller's to read; this function never sees it.
302+
*
293303
* A finite NUMBER is read as epoch milliseconds — the form SQLite stores a
294304
* `Field.datetime` in, and what any driver that hands back raw storage values
295305
* yields. `new Date(String(1767225600000))` is an Invalid Date, so without this

‎packages/objectql/src/engine-aggregate-timezone.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ async function makeEngine(rows: any[]) {
6161
const engine = new ObjectQL();
6262
engine.registerDriver(driver, true);
6363
await engine.init();
64+
// [#24618] `closed_at` holds INSTANTS, so it is declared a `datetime` — the
65+
// type whose buckets follow the reference zone. Declared a `date` (as it
66+
// was), the rows path buckets it by its calendar day under every zone
67+
// (ADR-0053), which is what in-memory-aggregation-date-calendar-day.test.ts
68+
// pins; the routing this file is about is the same for both types.
6469
engine.registry.registerObject({
6570
name: 'deal',
66-
fields: { closed_at: { type: 'date' }, amount: { type: 'number' } },
71+
fields: { closed_at: { type: 'datetime' }, amount: { type: 'number' } },
6772
} as any);
6873
return { engine, nativeCalls };
6974
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24618] A `Field.date` dimension buckets on its OWN calendar day under every
5+
* reference timezone — on the in-memory rows path exactly as on the pushdown
6+
* path.
7+
*
8+
* ADR-0053: a `date` is a timezone-naive calendar day, "never converted to an
9+
* instant and never timezone-shifted"; a `datetime` keeps the requested zone.
10+
* The rows path used to hand a `date` value to `bucketDateKey` WITH the zone,
11+
* which read `'2026-10-01'` as the instant `2026-10-01T00:00Z` and placed it on
12+
* that zone's calendar: `2026-09-30` in America/New_York. Measured at the
13+
* public door (`POST /api/v1/analytics/query`, stock showcase): every day key
14+
* one day early, month `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for a stored
15+
* `{2026-10: 6, 2026-11: 4}`, quarter `{2026-Q3: 1, 2026-Q4: 9}` for
16+
* `{2026-Q4: 10}` — while the SQL path, taken at UTC, answered the stored days.
17+
*
18+
* The expected labels below are LITERALS, the calendar facts of each stored
19+
* day — never computed by the labeller under test.
20+
*
21+
* Three parts:
22+
* (a) the rows path, declared `date`, under a negative-offset, a zero and a
23+
* positive-offset zone, at every granularity — and the `Date` value shape;
24+
* (b) the control: a declared `datetime` still follows the zone;
25+
* (c) `engine.aggregate`: the pushdown route (taken only at UTC) and the rows
26+
* route (forced by any other zone) answer ONE set of labels for a `date`.
27+
* The pushdown side here is a driver double answering the literal labels;
28+
* the REAL SQL faces are held to the same labels at UTC by
29+
* `checkDateBucketParity` (`@objectstack/verify`), so (c) + that check
30+
* are the parity under a non-UTC zone.
31+
*/
32+
33+
import { describe, it, expect } from 'vitest';
34+
import type { EngineAggregateOptions } from '@objectstack/spec/data';
35+
import { ObjectQL } from './engine.js';
36+
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
37+
38+
type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
39+
const GRANULARITIES: readonly Granularity[] = ['day', 'week', 'month', 'quarter', 'year'];
40+
41+
/**
42+
* Each stored day's label at each granularity — its own calendar, no zone.
43+
* Chosen for the edges a one-day-early shift crosses: a quarter and month start
44+
* (10-01), two ISO-week Mondays (10-05, 11-02), a month start on a Sunday
45+
* (11-01) and a year start whose ISO week is the previous year's 53rd (2027-01-01).
46+
*/
47+
const STORED_DAY_LABELS: Readonly<Record<string, Readonly<Record<Granularity, string>>>> = {
48+
'2026-10-01': { day: '2026-10-01', week: '2026-W40', month: '2026-10', quarter: '2026-Q4', year: '2026' },
49+
'2026-10-05': { day: '2026-10-05', week: '2026-W41', month: '2026-10', quarter: '2026-Q4', year: '2026' },
50+
'2026-10-13': { day: '2026-10-13', week: '2026-W42', month: '2026-10', quarter: '2026-Q4', year: '2026' },
51+
'2026-10-21': { day: '2026-10-21', week: '2026-W43', month: '2026-10', quarter: '2026-Q4', year: '2026' },
52+
'2026-10-31': { day: '2026-10-31', week: '2026-W44', month: '2026-10', quarter: '2026-Q4', year: '2026' },
53+
'2026-11-01': { day: '2026-11-01', week: '2026-W44', month: '2026-11', quarter: '2026-Q4', year: '2026' },
54+
'2026-11-02': { day: '2026-11-02', week: '2026-W45', month: '2026-11', quarter: '2026-Q4', year: '2026' },
55+
'2026-11-16': { day: '2026-11-16', week: '2026-W47', month: '2026-11', quarter: '2026-Q4', year: '2026' },
56+
'2026-11-30': { day: '2026-11-30', week: '2026-W49', month: '2026-11', quarter: '2026-Q4', year: '2026' },
57+
'2027-01-01': { day: '2027-01-01', week: '2026-W53', month: '2027-01', quarter: '2027-Q1', year: '2027' },
58+
};
59+
60+
/** The empty bucket's label, chosen so no `String(value)` can produce it (#3839). */
61+
const EMPTY = '‹empty bucket›';
62+
63+
/**
64+
* Ten deliveries in the shape of the door's fixture (six in October, four in
65+
* November, so the stored month and quarter counts are the door's expected
66+
* ones), plus a year-edge row and an unset day.
67+
*/
68+
const DUE_DATES: ReadonlyArray<string | null> = [
69+
'2026-10-01', '2026-10-05', '2026-10-13', '2026-10-21', '2026-10-21', '2026-10-31',
70+
'2026-11-01', '2026-11-02', '2026-11-16', '2026-11-30',
71+
'2027-01-01',
72+
null,
73+
];
74+
75+
/** `{label: count}` per granularity, for the rows above, as literals. */
76+
const EXPECTED: Readonly<Record<Granularity, Readonly<Record<string, number>>>> = {
77+
day: {
78+
'2026-10-01': 1, '2026-10-05': 1, '2026-10-13': 1, '2026-10-21': 2, '2026-10-31': 1,
79+
'2026-11-01': 1, '2026-11-02': 1, '2026-11-16': 1, '2026-11-30': 1, '2027-01-01': 1, [EMPTY]: 1,
80+
},
81+
week: {
82+
'2026-W40': 1, '2026-W41': 1, '2026-W42': 1, '2026-W43': 2, '2026-W44': 2,
83+
'2026-W45': 1, '2026-W47': 1, '2026-W49': 1, '2026-W53': 1, [EMPTY]: 1,
84+
},
85+
month: { '2026-10': 6, '2026-11': 4, '2027-01': 1, [EMPTY]: 1 },
86+
quarter: { '2026-Q4': 10, '2027-Q1': 1, [EMPTY]: 1 },
87+
year: { '2026': 10, '2027': 1, [EMPTY]: 1 },
88+
};
89+
90+
const FIELDS = {
91+
due_date: { type: 'date' },
92+
shipped_at: { type: 'datetime' },
93+
n: { type: 'number' },
94+
} as const;
95+
96+
const ROWS = DUE_DATES.map((due_date, i) => ({ id: `d${i}`, due_date, n: 1 }));
97+
98+
/** A negative-offset pair, the zero zone, and a positive-offset zone. */
99+
const ZONES = ['America/New_York', 'America/Los_Angeles', 'UTC', 'Asia/Tokyo'] as const;
100+
101+
function bucketAst(field: string, granularity: Granularity): EngineAggregateOptions {
102+
return {
103+
groupBy: [{ field, dateGranularity: granularity }],
104+
aggregations: [{ function: 'count', alias: 'n' }],
105+
};
106+
}
107+
108+
function labelCounts(rows: any[], field: string): Record<string, number> {
109+
const out: Record<string, number> = {};
110+
for (const row of rows) {
111+
const v = row?.[field];
112+
out[v == null ? EMPTY : String(v)] = Number(row?.n ?? 0);
113+
}
114+
return out;
115+
}
116+
117+
describe('(a) the rows path buckets a declared `date` by its stored day under every zone (#24618)', () => {
118+
for (const tz of ZONES) {
119+
for (const granularity of GRANULARITIES) {
120+
it(`${granularity} under ${tz}`, () => {
121+
const out = applyInMemoryAggregation(ROWS, bucketAst('due_date', granularity), tz, FIELDS);
122+
expect(labelCounts(out, 'due_date')).toEqual(EXPECTED[granularity]);
123+
});
124+
}
125+
}
126+
127+
it('the door\'s expected month and quarter counts, under America/New_York', () => {
128+
const month = labelCounts(
129+
applyInMemoryAggregation(ROWS.slice(0, 10), bucketAst('due_date', 'month'), 'America/New_York', FIELDS),
130+
'due_date',
131+
);
132+
const quarter = labelCounts(
133+
applyInMemoryAggregation(ROWS.slice(0, 10), bucketAst('due_date', 'quarter'), 'America/New_York', FIELDS),
134+
'due_date',
135+
);
136+
expect(month).toEqual({ '2026-10': 6, '2026-11': 4 });
137+
expect(quarter).toEqual({ '2026-Q4': 10 });
138+
});
139+
140+
it('a `Date` value of a `date` field buckets by its UTC calendar day — the `date` storage rule', () => {
141+
// Some drivers hand a `date` back as a `Date` at that day's UTC midnight;
142+
// `temporalStorageForm(value, 'date')` reads it as its UTC calendar day.
143+
const asDates = ROWS.map((r) => ({ ...r, due_date: r.due_date === null ? null : new Date(`${r.due_date}T00:00:00.000Z`) }));
144+
for (const granularity of GRANULARITIES) {
145+
const out = applyInMemoryAggregation(asDates, bucketAst('due_date', granularity), 'America/New_York', FIELDS);
146+
expect(labelCounts(out, 'due_date')).toEqual(EXPECTED[granularity]);
147+
}
148+
});
149+
150+
it('an alias names the output column only — the declaration is read off `field`', () => {
151+
const out = applyInMemoryAggregation(
152+
ROWS,
153+
{
154+
groupBy: [{ field: 'due_date', dateGranularity: 'month', alias: 'due_month' }],
155+
aggregations: [{ function: 'count', alias: 'n' }],
156+
},
157+
'America/Los_Angeles',
158+
FIELDS,
159+
);
160+
expect(labelCounts(out, 'due_month')).toEqual(EXPECTED.month);
161+
});
162+
});
163+
164+
describe('(b) control: a declared `datetime` keeps the reference zone (#24618)', () => {
165+
// Two instants either side of the New York midnight that opens 2026-10-01
166+
// (04:00Z, EDT −04), and one an hour into 2026-11-01 UTC.
167+
const SHIPPED = [
168+
{ id: 's1', shipped_at: '2026-10-01T02:00:00.000Z', n: 1 }, // NY 2026-09-30 22:00
169+
{ id: 's2', shipped_at: '2026-10-01T15:00:00.000Z', n: 1 }, // NY 2026-10-01 11:00
170+
{ id: 's3', shipped_at: '2026-11-01T01:00:00.000Z', n: 1 }, // NY 2026-10-31 21:00
171+
];
172+
173+
it('under America/New_York an instant lands on the New York calendar day', () => {
174+
const day = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'day'), 'America/New_York', FIELDS);
175+
expect(labelCounts(day, 'shipped_at')).toEqual({ '2026-09-30': 1, '2026-10-01': 1, '2026-10-31': 1 });
176+
const quarter = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'quarter'), 'America/New_York', FIELDS);
177+
expect(labelCounts(quarter, 'shipped_at')).toEqual({ '2026-Q3': 1, '2026-Q4': 2 });
178+
});
179+
180+
it('under UTC the same instants land on the UTC calendar day', () => {
181+
const day = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'day'), 'UTC', FIELDS);
182+
expect(labelCounts(day, 'shipped_at')).toEqual({ '2026-10-01': 2, '2026-11-01': 1 });
183+
});
184+
});
185+
186+
describe('(c) engine.aggregate answers one set of labels for a `date` on both routes (#24618)', () => {
187+
/**
188+
* A driver advertising native bucketing at every granularity. Its native
189+
* `aggregate` is the pushdown route: it labels each row by the literal table
190+
* above — what a SQL face answers for a `date` column, where no zone is ever
191+
* applied — and counts how often it ran, so each case can assert the route.
192+
*/
193+
function makeEngine() {
194+
let nativeCalls = 0;
195+
const driver: any = {
196+
name: 'date-bucket-pushdown-double',
197+
version: '0.0.0',
198+
supports: { queryDateGranularity: { day: true, week: true, month: true, quarter: true, year: true } },
199+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
200+
async find() { return ROWS.map((r) => ({ ...r })); },
201+
async findOne() { return null; },
202+
async create(_o: string, d: any) { return d; },
203+
async update(_o: string, _id: string, d: any) { return d; },
204+
async delete() { return true; },
205+
async count() { return ROWS.length; },
206+
async bulkCreate(_o: string, r: any[]) { return r; },
207+
async bulkUpdate() { return []; }, async bulkDelete() {},
208+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
209+
async commit() {}, async rollback() {},
210+
async aggregate(_object: string, ast: any) {
211+
nativeCalls += 1;
212+
const { field, dateGranularity } = ast.groupBy[0];
213+
const counts = new Map<string | null, number>();
214+
for (const r of ROWS) {
215+
const v = (r as Record<string, unknown>)[field] as string | null;
216+
const label = v == null ? null : STORED_DAY_LABELS[v][dateGranularity as Granularity];
217+
counts.set(label, (counts.get(label) ?? 0) + 1);
218+
}
219+
return Array.from(counts, ([label, n]) => ({ [field]: label, n }));
220+
},
221+
};
222+
const engine = new ObjectQL();
223+
engine.registerDriver(driver, true);
224+
return { engine, driver, nativeCalls: () => nativeCalls };
225+
}
226+
227+
for (const granularity of GRANULARITIES) {
228+
it(`${granularity}: the rows route under every non-UTC zone equals the pushdown route at UTC`, async () => {
229+
const { engine, nativeCalls } = makeEngine();
230+
await engine.init();
231+
engine.registry.registerObject({ name: 'delivery', fields: FIELDS } as any);
232+
233+
const pushedDown = await engine.aggregate('delivery', { ...bucketAst('due_date', granularity), timezone: 'UTC' });
234+
expect(nativeCalls()).toBe(1); // the pushdown route
235+
expect(labelCounts(pushedDown, 'due_date')).toEqual(EXPECTED[granularity]);
236+
237+
for (const tz of ZONES.filter((z) => z !== 'UTC')) {
238+
const inMemory = await engine.aggregate('delivery', { ...bucketAst('due_date', granularity), timezone: tz });
239+
expect(nativeCalls()).toBe(1); // a non-UTC zone forced the rows route
240+
expect(labelCounts(inMemory, 'due_date')).toEqual(labelCounts(pushedDown, 'due_date'));
241+
}
242+
});
243+
}
244+
});

0 commit comments

Comments
 (0)