Skip to content

Commit 440cd32

Browse files
fix(driver-sql): bucket a Field.date as its calendar day on PostgreSQL and MySQL (#21611)
Fixes #21485 Clause-②: no ## What changes `SqlDriver.buildDateBucketExpr` now reads the column's declared type from the object it already receives. A declared `Field.date` is bucketed as its own calendar day with no zone conversion. A `Field.datetime`, or a column with no declaration, keeps the UTC-instant expression byte for byte. | dialect | `Field.date` (new) | `Field.datetime` / undeclared (unchanged) | |:--|:--|:--| | PostgreSQL | `to_char((col)::date::timestamp, FMT)` | `to_char((col)::timestamptz AT TIME ZONE 'UTC', FMT)` | | MySQL | `date_format(col, FMT)` | `date_format(convert_tz(col, @@session.time_zone, '+00:00'), FMT)` | | SQLite | unchanged (`strftime` on TEXT, no zone) | unchanged | Both callers render this one function, so both follow without a second edit: `aggregate()`'s GROUP BY (`buildDateBucketExpr(g.field, g.dateGranularity, table)`, with `table = coercionKey(builder)`), and the public `dateBucketSql(objectName, …)` member that the analytics SQL echo reads. No `service-analytics` source is touched. ## The mechanism, measured PostgreSQL 16.14 with the server's `TimeZone` at `Asia/Shanghai`, at the expression: | `date` value | old day | old week | old month | new (all five granularities) | |:--|:--|:--|:--|:--| | `2026-06-01` | `2026-05-31` | `2026-W22` | `2026-05` | `2026-06-01` / `2026-W23` / `2026-06` / `2026-Q2` / `2026` | | `2026-01-01` | `2025-12-31` | `2026-W01` | `2025-12` | `2026-01-01` / `2026-W01` / `2026-01` / `2026-Q1` / `2026` | | `2024-12-30` | `2024-12-29` | `2024-W52` | `2024-12` | `2024-12-30` / `2025-W01` / `2024-12` / `2024-Q4` / `2024` | Casting a `date` to `timestamptz` makes midnight in the session zone; `AT TIME ZONE 'UTC'` then reads the previous UTC day on any session east of UTC. A session west of UTC does not shift (midnight local is later the same UTC day), which is why only east-of-UTC servers show it. **Why `::date::timestamp` and not the bare `::date` the dispatch suggested.** `EXPLAIN VERBOSE` shows `to_char((d)::date, …)` resolves to `to_char((d)::timestamp with time zone, …)` through the implicit cast, so the bare form still round-trips the session zone. Measured over every day 1900..2100 in ten zones, that round trip differs from the zone-free form on exactly one day each in `Pacific/Apia` (`2011-12-30` prints `2011-12-31`) and `Pacific/Kiritimati` (`1994-12-31` prints `1995-01-01`), the days those zones skipped. `::date::timestamp` resolves `to_char(timestamp without time zone, …)` and consults no zone. **MySQL (H2), measured on MySQL 8.0.46** (the Ubuntu `mysql-server-core-8.0` binary, run from a private datadir in this container) at a global `+08:00`. At the expression, `convert_tz(d, '+08:00', '+00:00')` shifts a `DATE` exactly like PostgreSQL: `2026-06-01` → day `2026-05-31`, month `2026-05`; `2024-12-30` → `2024-W52`. Through the driver's default composition it does not, because the driver pins its own session to `+00:00` (`withUtcSession`), which makes `convert_tz` the identity. A host `pool.afterCreate` that sets the session zone (the driver chains it after its own hook) brings the shift back. Per triage's ruling ("gets the same split if a `DATE` shifts"; "No server-timezone dependence for a `date` on any dialect"), the MySQL arm gets the same split. ## Pins - `packages/drivers/driver-sql/src/sql-driver-21485-date-bucket-calendar-day.test.ts` (new). One matrix over `DIALECT_CELLS` through `declareDialectCell`: `date` and `datetime` × every granularity the dialect buckets in SQL (declared per dialect and asserted equal to `supports.queryDateGranularity`, so the iterated set cannot shrink silently) × session zone. Each cell asserts both doors: `aggregate()` and the `dateBucketSql()` text run as SQL. The rows sit on month, quarter, year and ISO-week boundaries, plus the empty bucket. - sqlite: the zone-free control. - live postgres / live mysql, each twice: **as provisioned** (both axes, after `assertThreeWayZoneSkew`) and **session at +08:00 through a host `pool.afterCreate`** (the `date` axis, after asserting the session really is at `+08:00`). The second run is red-capable on any server, and it is the only red-capable route on MySQL. - `sql-driver-temporal-dialect.test.ts`: a no-server pin (runs in Test Core too). On `pg` and `mysql2`, a declared `Field.date` expression names no `timestamptz`, `time zone`, `convert_tz` or `time_zone`; on `pg` it carries `::date::timestamp`; the undeclared control keeps the UTC-instant arm. **Where the live pins run in CI (H4).** Job `Temporal Conformance (live PG + MySQL)` (required), step `Run driver-sql suite against both live servers`. It runs the whole driver-sql package with `OS_TEST_POSTGRES_URL`, `OS_TEST_MYSQL_URL`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1` and `TZ=America/New_York`, with PostgreSQL at `Asia/Shanghai` and MySQL at a global `+08:00`. No workflow edit is needed. PR #21577 (#21564) wires the separate `service-analytics` step and is not part of this PR. ## Reverse verification and ablation All runs: PostgreSQL 16.14 at `Asia/Shanghai`, MySQL 8.0.46 at `+08:00`, process `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`; the new file plus `sql-driver-temporal-dialect.test.ts`. 1. **Before the fix was written** (the test committed alone, `26b8310fb`): the new file 15 failed / 32 passed of 47. The `date` axis was red at all five granularities on live postgres as provisioned, live postgres at +08:00, and live mysql at +08:00. Live mysql as provisioned (the driver's UTC pin), every `datetime` cell and sqlite were green. 2. **Fix committed (`aa061685b`), then `sql-driver.ts` reverted to the base text** (`git restore --source=f6b752083`; on-disk anchors `calendarDay` 3 → 0, the old PostgreSQL month text 0 → 1): **16 failed / 45 passed of 61**. That is the same 15 live cells, plus the no-server pin. Restored with `git checkout HEAD -- packages/drivers/driver-sql/src/sql-driver.ts`. The `hash-object` was `be60fd469…`, equal to the HEAD blob; `git diff HEAD` and `git status --porcelain` were empty. 3. **Fix in place:** 61 of 61 green. 4. **Ablation, the `::timestamp` hop** (`scripts/ablation-replace.mjs`, anchor 1 → 0): `(??)::date::timestamp` replaced by the bare `(??)::date`. Predicted: the live pins stay green at `Asia/Shanghai` (the round trip is the identity there) and only the no-server pin reds. Observed: **1 failed / 60 passed**, the no-server pin. Restored blob = HEAD, `git diff HEAD` empty. 5. **The four `service-analytics` cells the card measured** (`objectql-face-order-limit.test.ts`, which resolves `@objectstack/driver-sql` through `dist/`): the fix was disabled in `dist/` (`const calendarDay = false`, rebuilt, `ablation-dist-preflight` marker present in 2 built files). Result: **4 failed / 22 passed of 26**, exactly the card's four. After restore + rebuild + `--absent` preflight: the file plus `objectql-echo-date-bucket.test.ts` gave 42 of 42 green against live PostgreSQL at `Asia/Shanghai`. **No assertion change is needed** in `objectql-face-order-limit.test.ts`, so this PR does not touch it. ## Dispatch hypotheses - **H1, holds.** Both call sites pass the object name as the coercion key (`aggregate`: `coercionKey(builder)`; `dateBucketSql`: `objectName`, which the analytics echo fills from `extractObjectName(cube)`). `temporalFieldKind(table, field)` answers `'date'` from `dateFields`, which only `type: 'date'` populates. No new parameter. - **H2, measured** (above). PostgreSQL: the card's mechanism, confirmed. MySQL: the `DATE` shifts under any east-of-UTC session; through the driver only with a host-set session zone. - **H3.** Only `type: 'date'` feeds `dateFields`. SQLite stores a `date` as TEXT, and `strftime` reads it with no zone, so the SQLite arm has no instance of this class; its sqlite cells are the control. The SQLite `week` arm is untouched (#21595). - **H4,** answered above. ## Driver conformance ledger `pnpm check:driver-conformance`, before (`f6b752083`) and after (`c20518cfd7`): **50 covered, 0 DEBT, 0 exempt**, both times. The dialect axis was 8 suites (7 matrix, 1 named cell), 0 DIALECT ledger, both times. The new file imports no shared `spec/data` case-set, so it adds no cell. A spec-level shared case-set would have made every one of the five drivers consume it or carry DEBT. That is outside this card's file surface and against the no-new-DEBT commitment, so the matrix is driver-local. ## Local verification - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`, with all three dialects live as in CI, at `eab3f3bde` (after merging `origin/main` `b610eabf7`): **228 files, 5521 passed, 1 skipped** (the skip is pre-existing, in `schema-drift.base-type-mismatch.test.ts`). The run printed "all 3 dialects were exercised". - At the final head `c20518cfd7`, whose last commit only removes the `as any` casts in the new test: `pnpm --filter @objectstack/driver-sql typecheck` was green (`--listFiles` lists all 228 test files). The new file, `sql-driver-temporal-dialect.test.ts` and `live-dialect-matrix.isolation.test.ts` gave **81 of 81** green with all three dialects live. - Gates at `c20518cfd7`: `node scripts/pm/dispatch-gates.mjs --commands` derived 65, and all 65 were run with exit 0. `--ran` printed: "65 derived famil(ies) accounted for, 65 run, 0 NOT-MEASURED". One gate was red on the way: `check:query-options-erasure`, because the new test's `as any` raised the test surface 236 → 237. It was answered by typing the query and the config, not by raising the number. - ESLint, a narrowed run: the three changed `.ts` files, all in the lint population (`--print-config` resolves a config for each), 3 files / 0 errors / 0 warnings in the `--format json` output at `c20518cfd7`. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **MySQL `datetime` under a host-set session zone (outside this card's ruling, not changed).** Measured through the driver on MySQL 8.0.46 with a host `pool.afterCreate` setting `time_zone = '+08:00'`: `2026-06-01T03:00:00.000Z` stores `2026-06-01 03:00:00.000` in `DATETIME(3)`, and `find()` reads it back correctly, but `aggregate()` by `day` answers `2026-05-31`. `convert_tz(…, @@session.time_zone, '+00:00')` is right for a legacy `TIMESTAMP` column and wrong for a `DATETIME(3)` that already holds the UTC wall clock. Under the driver's default composition it is the identity. No in-repo host sets the session zone, so this is noted here, not filed. - An external (federated) object that declares `type: 'date'` over a remote `timestamptz` column would now bucket by the session-local day (`::date` on a `timestamptz`). That is an inference only, not measured, and no producer is named. - `service-analytics` comments (`strategies/objectql-strategy.ts`, `strategies/types.ts`) and the `objectql-echo-date-bucket.test.ts` docblock quote the PostgreSQL `… AT TIME ZONE 'UTC' …` text. That is still the `datetime` expression; they are not edited, because a `service-analytics` source edit is outside this PR's surface. - Raise rule: no hosted or shipped deployment was measured running PostgreSQL at a non-UTC `TimeZone`. The non-UTC readings here are a private server in this container. - Merged `origin/main` `b610eabf7` (two commits, `packages/spec` and docs only, disjoint from this diff) before the full-suite run. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83b3d32 commit 440cd32

4 files changed

Lines changed: 350 additions & 11 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
A `Field.date` grouped by `day`, `week`, `month`, `quarter` or `year` buckets as its own calendar day on PostgreSQL and MySQL, whatever zone the server or the session is in (#21485).
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The PostgreSQL bucket cast every column to `timestamptz` and the MySQL bucket passed every column through `convert_tz`. A `date` has no instant, so both invented midnight in the session's zone, and on a session east of UTC the conversion to UTC read the previous day. On PostgreSQL with the server at `Asia/Shanghai`, `2026-06-01` grouped into month `2026-05`, and `2026-01-01` into year `2025`. MySQL did the same once the session zone was `+08:00`; the driver pins its own sessions to UTC, so there it took a host `pool.afterCreate` that sets the session zone.
10+
- **What it does now.** A declared `Field.date` buckets its calendar day with no zone conversion. A `Field.datetime`, and a column with no declaration, keep the UTC-instant expression, byte for byte. SQLite already bucketed a `date` as its calendar day and is unchanged.
11+
- **Where it shows.** `aggregate()` with a `dateGranularity` group, and the expression `SqlDriver.dateBucketSql()` renders for the analytics SQL echo, which reads the same expression.
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21485] A `Field.date` buckets as its own calendar day, whatever zone the
5+
* server or the session is in. A `Field.datetime` buckets as its UTC instant.
6+
*
7+
* ## The defect this pins closed
8+
*
9+
* `buildDateBucketExpr` used one expression per dialect for every column it
10+
* bucketed, the instant one: `to_char((col)::timestamptz AT TIME ZONE 'UTC', …)`
11+
* on PostgreSQL and `date_format(convert_tz(col, @@session.time_zone, '+00:00'), …)`
12+
* on MySQL. A `date` has no instant, so the cast INVENTED one: midnight in the
13+
* session's zone. On a session east of UTC that midnight is the previous UTC
14+
* day, and the UTC conversion then read the day before. Measured on
15+
* PostgreSQL 16.14 with the server's `TimeZone` at `Asia/Shanghai`, and on
16+
* MySQL 8.0.46 with the session at `+08:00`, both before the fix:
17+
*
18+
* | `Field.date` value | day | week | month | quarter | year |
19+
* |:--|:--|:--|:--|:--|:--|
20+
* | `2026-06-01` | `2026-05-31` | `2026-W22` | `2026-05` | `2026-Q2` | `2026` |
21+
* | `2026-01-01` | `2025-12-31` | `2026-W01` | `2025-12` | `2025-Q4` | `2025` |
22+
* | `2024-12-30` | `2024-12-29` | `2024-W52` | `2024-12` | `2024-Q4` | `2024` |
23+
*
24+
* At a UTC session every one of those is the value's own calendar day, which
25+
* is why the UTC cells stayed green: the zone decided the answer.
26+
*
27+
* ## The cells
28+
*
29+
* - **sqlite**, every run. A `date` is TEXT there and `strftime` reads it with
30+
* no zone, so this cell is the control the live cells must agree with.
31+
* - **live postgres** / **live mysql**, where `OS_TEST_POSTGRES_URL` /
32+
* `OS_TEST_MYSQL_URL` are provisioned (the `Temporal Conformance (live PG +
33+
* MySQL)` CI job, step "Run driver-sql suite against both live servers"),
34+
* and a named skip (red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`) otherwise.
35+
* Each runs twice:
36+
* 1. **as provisioned** — the session the driver leaves in place. On
37+
* PostgreSQL that is the server's `TimeZone` (`Asia/Shanghai` in CI),
38+
* which is this card's reproduction. On MySQL the driver pins its own
39+
* session to `+00:00` (#3942), so this run cannot see the defect there.
40+
* Both axes are asserted, and the three-way zone skew is asserted first.
41+
* 2. **session at +08:00** — a host `pool.afterCreate` sets the session
42+
* zone, which the driver chains after its own hook. This is the run that
43+
* is red-capable on any server, and the only one that is on MySQL. A
44+
* test asserts that the session really is at `+08:00`, so a hook that
45+
* stops taking effect is a red, not a quiet return to UTC.
46+
* Only the `date` axis is asserted here. A host-set MySQL session also
47+
* moves the `datetime` arm, whose `DATETIME(3)` holds the UTC wall
48+
* clock the driver wrote; that arm is outside this card's ruling, which
49+
* keeps it as it is, and it is recorded on the PR instead.
50+
*
51+
* Every cell checks both doors that render the expression: `aggregate()`'s
52+
* GROUP BY, and `dateBucketSql()` — the text the analytics echo prints (#21441)
53+
* — run as SQL against the same rows.
54+
*
55+
* ## Reverse verification
56+
*
57+
* Measured with all three cells provisioned (PostgreSQL 16.14 at
58+
* `Asia/Shanghai`, MySQL 8.0.46 at a global `+08:00`, process
59+
* `TZ=America/New_York`), this file beside `sql-driver-temporal-dialect.test.ts`:
60+
*
61+
* - fix in place: 61 of 61 green;
62+
* - `sql-driver.ts` reverted to its pre-fix text: 16 red, 45 green. The red
63+
* ones are the `date` axis at all five granularities on live postgres as
64+
* provisioned, live postgres at `+08:00`, and live mysql at `+08:00` (15),
65+
* plus the no-server pin in the dialect-gating file. Live mysql as
66+
* provisioned stayed green, because the driver's UTC session pin hides the
67+
* defect there. Every `datetime` cell and the sqlite cell stayed green.
68+
*/
69+
70+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
71+
import { SqlDriver, type SqlDriverConfig } from '../src/index.js';
72+
import {
73+
DIALECT_CELLS,
74+
assertThreeWayZoneSkew,
75+
declareDialectCell,
76+
readServerZone,
77+
type DialectCell,
78+
type DialectId,
79+
} from './live-dialect-matrix.testkit.js';
80+
81+
const TABLE = 'os21485_bucket';
82+
83+
type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
84+
type Axis = 'on' | 'at';
85+
86+
const GRANULARITIES: readonly Granularity[] = ['day', 'week', 'month', 'quarter', 'year'];
87+
88+
/**
89+
* The granularities each dialect buckets in SQL. Declared rather than read off
90+
* the driver, and checked against it below, so the set the tests iterate cannot
91+
* shrink without a red. SQLite buckets `week` in memory (#21595 owns that).
92+
*/
93+
const BUCKETED_IN_SQL: Record<DialectId, readonly Granularity[]> = {
94+
sqlite: ['day', 'month', 'quarter', 'year'],
95+
pg: GRANULARITIES,
96+
mysql: GRANULARITIES,
97+
};
98+
99+
type Labels = Record<Granularity, string>;
100+
const labels = (day: string, week: string, month: string, quarter: string, year: string): Labels =>
101+
({ day, week, month, quarter, year });
102+
103+
/**
104+
* Every row, and the label each value buckets under.
105+
*
106+
* `on` is a calendar day whose midnight at `+08:00` falls on the PREVIOUS UTC
107+
* day, at a month, a quarter, a year and an ISO-week boundary. `at` is an
108+
* instant whose `+08:00` wall clock is on the NEXT day, across the same
109+
* boundaries, so a fix that bucketed a `datetime` by its local day would be
110+
* one bucket off too. The last row is the empty bucket.
111+
*/
112+
const ROWS: ReadonlyArray<{ id: string; on: [string, Labels] | null; at: [string, Labels] | null }> = [
113+
{
114+
id: 'r1',
115+
on: ['2026-06-01', labels('2026-06-01', '2026-W23', '2026-06', '2026-Q2', '2026')],
116+
at: ['2026-05-31T20:00:00.000Z', labels('2026-05-31', '2026-W22', '2026-05', '2026-Q2', '2026')],
117+
},
118+
{
119+
id: 'r2',
120+
on: ['2026-04-01', labels('2026-04-01', '2026-W14', '2026-04', '2026-Q2', '2026')],
121+
at: ['2026-03-31T18:30:00.000Z', labels('2026-03-31', '2026-W14', '2026-03', '2026-Q1', '2026')],
122+
},
123+
{
124+
id: 'r3',
125+
on: ['2026-01-01', labels('2026-01-01', '2026-W01', '2026-01', '2026-Q1', '2026')],
126+
at: ['2025-12-31T20:00:00.000Z', labels('2025-12-31', '2026-W01', '2025-12', '2025-Q4', '2025')],
127+
},
128+
{
129+
id: 'r4',
130+
on: ['2024-12-30', labels('2024-12-30', '2025-W01', '2024-12', '2024-Q4', '2024')],
131+
at: ['2024-12-29T18:00:00.000Z', labels('2024-12-29', '2024-W52', '2024-12', '2024-Q4', '2024')],
132+
},
133+
{ id: 'r5', on: null, at: null },
134+
];
135+
136+
/** The empty bucket, keyed out of band so it cannot collide with a real label. */
137+
const EMPTY = '(empty bucket)';
138+
const labelOf = (v: unknown): string => (v == null ? EMPTY : String(v));
139+
140+
/** label -> row count: what `field` bucketed by `g` must answer. */
141+
function expectedBuckets(axis: Axis, g: Granularity): [string, number][] {
142+
const counts = new Map<string, number>();
143+
for (const row of ROWS) {
144+
const key = row[axis] ? row[axis]![1][g] : EMPTY;
145+
counts.set(key, (counts.get(key) ?? 0) + 1);
146+
}
147+
return [...counts].sort();
148+
}
149+
150+
function bucketsOf(rows: readonly Record<string, unknown>[], labelColumn: string): [string, number][] {
151+
return rows.map((r): [string, number] => [labelOf(r[labelColumn]), Number(r.n)]).sort();
152+
}
153+
154+
/** Unwrap a raw result across knex's three dialect shapes. */
155+
function rowsOf(res: any): Record<string, unknown>[] {
156+
if (Array.isArray(res) && Array.isArray(res[0])) return res[0]; // mysql2: [rows, fields]
157+
if (Array.isArray(res)) return res; // better-sqlite3
158+
return res?.rows ?? []; // pg
159+
}
160+
161+
/** The statement that sets a session to `+08:00`, per live dialect. */
162+
const SET_SESSION_ZONE: Partial<Record<DialectId, string>> = {
163+
pg: `SET TIME ZONE 'Asia/Shanghai'`,
164+
mysql: `SET time_zone = '+08:00'`,
165+
};
166+
167+
/** The session's own zone, read back through the driver. */
168+
const READ_SESSION_ZONE: Partial<Record<DialectId, string>> = {
169+
pg: `select current_setting('TimeZone') as tz`,
170+
mysql: `select @@session.time_zone as tz`,
171+
};
172+
173+
interface SessionRun {
174+
label: string;
175+
/** Statement a host `pool.afterCreate` runs on each new connection, if any. */
176+
hostSessionSql?: string;
177+
/** The axes asserted in this run. */
178+
axes: readonly Axis[];
179+
}
180+
181+
function sessionRuns(cell: DialectCell): SessionRun[] {
182+
if (!cell.live) return [{ label: 'in process', axes: ['on', 'at'] }];
183+
return [
184+
{ label: 'session as provisioned', axes: ['on', 'at'] },
185+
{ label: 'session at +08:00 (host pool.afterCreate)', hostSessionSql: SET_SESSION_ZONE[cell.id], axes: ['on'] },
186+
];
187+
}
188+
189+
function configFor(cell: DialectCell, run: SessionRun): SqlDriverConfig {
190+
const base = cell.config();
191+
const sql = run.hostSessionSql;
192+
if (!sql) return base;
193+
return {
194+
...base,
195+
pool: {
196+
afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) {
197+
connection.query(sql, (err: unknown) => done(err, connection));
198+
},
199+
},
200+
};
201+
}
202+
203+
function measure(cell: DialectCell): void {
204+
for (const run of sessionRuns(cell)) {
205+
describe(`[#21485] date buckets on ${cell.label}, ${run.label}`, () => {
206+
let driver: SqlDriver;
207+
208+
beforeAll(async () => {
209+
driver = new SqlDriver(configFor(cell, run));
210+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
211+
await driver.initObjects([{ name: TABLE, fields: { on: { type: 'date' }, at: { type: 'datetime' } } }]);
212+
for (const row of ROWS) {
213+
await driver.create(
214+
TABLE,
215+
{ id: row.id, on: row.on ? row.on[0] : null, at: row.at ? row.at[0] : null },
216+
{ bypassTenantAudit: true },
217+
);
218+
}
219+
});
220+
221+
afterAll(async () => {
222+
await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {});
223+
await driver?.disconnect();
224+
});
225+
226+
if (cell.live && !run.hostSessionSql) {
227+
it('runs where the server, the process and UTC are three different zones', async () => {
228+
assertThreeWayZoneSkew(cell, await readServerZone(cell, driver));
229+
});
230+
}
231+
232+
if (run.hostSessionSql) {
233+
it('the session really is at +08:00, so this run can see a zone leak', async () => {
234+
const rows = rowsOf(await driver.execute(READ_SESSION_ZONE[cell.id]!));
235+
expect(['Asia/Shanghai', '+08:00']).toContain(String(rows[0]?.tz));
236+
});
237+
}
238+
239+
it('buckets in SQL exactly the granularities this file iterates', () => {
240+
const advertised = Object.entries(driver.supports.queryDateGranularity as Record<string, boolean>)
241+
.filter(([, on]) => on)
242+
.map(([g]) => g)
243+
.sort();
244+
expect(advertised).toEqual([...BUCKETED_IN_SQL[cell.id]].sort());
245+
});
246+
247+
for (const axis of run.axes) {
248+
const subject = axis === 'on' ? 'a Field.date buckets as its calendar day' : 'a Field.datetime buckets as its UTC instant';
249+
it.each(BUCKETED_IN_SQL[cell.id])(`${subject}, by %s: aggregate() and the dateBucketSql echo`, async (g) => {
250+
const want = expectedBuckets(axis, g);
251+
252+
const grouped = await driver.aggregate(TABLE, {
253+
groupBy: [{ field: axis, dateGranularity: g }],
254+
aggregations: [{ function: 'count', alias: 'n' }],
255+
});
256+
expect(bucketsOf(grouped as Record<string, unknown>[], axis), 'aggregate()').toEqual(want);
257+
258+
const expr = driver.dateBucketSql(TABLE, axis, g);
259+
expect(expr, 'dateBucketSql renders an expression').toBeTypeOf('string');
260+
const echoed = rowsOf(
261+
await driver.execute(`select ${expr} as b, count(*) as n from ${TABLE} group by ${expr}`),
262+
);
263+
expect(bucketsOf(echoed, 'b'), 'the dateBucketSql echo, run').toEqual(want);
264+
});
265+
}
266+
});
267+
}
268+
}
269+
270+
for (const cell of DIALECT_CELLS) {
271+
declareDialectCell(cell, 'date-bucket calendar day', measure);
272+
}

‎packages/drivers/driver-sql/src/sql-driver-temporal-dialect.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,35 @@ describe('buildDateBucketExpr dialect gating (#3773)', () => {
215215
}
216216
});
217217

218+
/**
219+
* [#21485] The no-server half of the live pins in
220+
* `sql-driver-21485-date-bucket-calendar-day.test.ts`, so a regression is red
221+
* in every job, including the ones with no PostgreSQL or MySQL attached. A
222+
* `Field.date` is a calendar day: its bucket names no zone and converts none.
223+
* Each spelling refused below read the session's zone, and on a session east
224+
* of UTC moved the day into the previous bucket.
225+
*
226+
* On PostgreSQL the day must reach `to_char` as a `timestamp`. A bare `date`
227+
* there resolves `to_char(timestamptz, text)` through the implicit cast, so a
228+
* `timestamptz` is back without the text naming it.
229+
*/
230+
it('Postgres and MySQL bucket a declared Field.date with no zone conversion (#21485)', () => {
231+
for (const client of ['pg', 'mysql2']) {
232+
const d = makeDriver(client);
233+
d.seedDate('t', 'on');
234+
for (const g of [...GRANULARITIES, 'week']) {
235+
const sql = expr(d, 'on', g, 't')!.sql.toLowerCase();
236+
for (const zoned of ['timestamptz', 'time zone', 'convert_tz', 'time_zone']) {
237+
expect(sql, `${client} ${g}`).not.toContain(zoned);
238+
}
239+
if (client === 'pg') expect(sql, `${client} ${g}`).toContain('::date::timestamp,');
240+
// The control: the same column with no declaration keeps the UTC-instant arm.
241+
expect(expr(d, 'on', g)!.sql.toLowerCase(), `${client} ${g}, undeclared`)
242+
.toContain(client === 'pg' ? `at time zone 'utc'` : 'convert_tz(');
243+
}
244+
}
245+
});
246+
218247
it('every emitted expression binds exactly as many identifiers as it references', () => {
219248
// The quarter expression references the column twice; an epoch-normalised
220249
// one references it six times. A mismatch here is knex silently shifting

0 commit comments

Comments
 (0)