Skip to content

Commit d3958ba

Browse files
fix(driver-sql): a MySQL date reads back the day it stores, so a year below 100 no longer comes back a century late (#20306)
Part of #20280 — the `date` half. The `datetime` half stays open on the card: it needs a decision about ADR-0053 D-F2 (see "Open: the datetime half" below), so merging this must leave #20280 open. Clause-②: no ## What was wrong On MySQL, driver-sql took mysql2's JS `Date` for a `DATE` column. mysql2 3.23.1 `Packet#parseDate` rebuilds it as `new Date(Date.UTC(y, m - 1, d))` under the driver's `timezone: 'Z'` pin (`new Date(y, m - 1, d)` under `'local'`), and both constructors read a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: `where placed_on $eq '0009-03-04'` found the row and presented `1909-03-04`. ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`: a new `withMysqlCalendarDayAsText`, chained in `withConnectBound` beside `withUtcSession` and `withPostgresCalendarDayAsText`, sets `dateStrings: ['DATE']` on a MySQL connection (`mysql` / `mysql2`, object or URL connection) unless the host set `dateStrings` itself. A function-valued connection is left alone, as `withUtcSession` leaves it. - The read doors already present a `date` through `toDateOnly`, which is `@objectstack/core`'s `temporalStorageForm`. With the wire text arriving, that rule's string arm hands back the stored `YYYY-MM-DD`: the same path PostgreSQL's `date` takes since its calendar-day parser. No presenter changed and no copy of the rule was added. - Docblocks of `withPostgresCalendarDayAsText` and `toDateOnly` updated: a `date` now reaches the read doors as text on every dialect, MySQL included. ## Measured (live MySQL 8.0.46, server `time_zone='+08:00'`, process `TZ=America/New_York`, mysql2 3.23.1) Records written through `POST /api/v1/data/:object`, read through `driver.find` / `findOne`, `engine.find` / `findOne`, `POST …/query` and `GET …/:id` (all six agree in every cell). Base `89f87f2344`, head this branch. | year | kind | stored (`CAST(… AS CHAR)`) | presented at base | presented at head | |:--|:--|:--|:--|:--| | 0009 | date | `0009-03-04` | `1909-03-04` | `0009-03-04` | | 0099 | date | `0099-03-04` | `1999-03-04` | `0099-03-04` | | 0999 | date | `0999-06-15` | `0999-06-15` | `0999-06-15` | | 0000 | date | `0000-06-15` | `1900-06-15` | `0000-06-15` | | 1000 | date | `1000-01-01` | `1000-01-01` | `1000-01-01` | | 2026 | date | `2026-03-04` | `2026-03-04` | `2026-03-04` | | 9999 | date | `9999-12-31` | `9999-12-31` | `9999-12-31` | | 0009 | datetime | `0009-03-04 10:00:00.000` | `2004-09-03T10:00:00.000Z` | unchanged | | 0099 | datetime | `0099-03-04 10:00:00.000` | `1999-03-04T10:00:00.000Z` | unchanged | | 0000 | datetime | `0000-06-15 10:00:00.000` | `2000-06-15T10:00:00.000Z` | unchanged | | 0999 / 1000 / 2026 / 9999 | datetime | stored as written | the stored instant | unchanged | A `groupBy` key on the `date` field, `distinct`, and `min` moved the same way (base `1900-06-15` / `1909-03-04` / `1999-03-04`; head `0000-06-15` / `0009-03-04` / `0099-03-04`). `$eq` and `$gt` found the same rows at base and head on both kinds. Year 0000 is recorded, not decided: it is inside what this head stores and reads, and the range decision on #20264 refuses it once that lands. ## Remedy choice (the two measured on PR #20261's review) Measured on a mysql2 connection per remedy, same rows: | remedy | `DATE` 0009 / 0099 / 0000 | `DATETIME` 0009 / 0099 / 0000 | zero `DATE` / `DATETIME` | |:--|:--|:--|:--| | `timezone: 'Z'` (base) | 1909 / 1999 / 1900 as a `Date` | 2004-09-03 / 1999 / 2000 as a `Date` | `1899-11-30` / Invalid `Date` | | `timezone: '+00:00'` | right, as a `Date` | still folded | Invalid `Date` / Invalid `Date` | | `dateStrings: ['DATE', 'DATETIME']` | right, as text | right, as text | text / text | - `dateStrings` for `DATE` is taken. It is the narrower change: it touches only `DATE` columns and no write, and it is the shape PostgreSQL already reads a day in. `'+00:00'` would also move the zone every bound `Date` and every `DATETIME` is rendered in, and fixes nothing more. - `dateStrings` for `DATETIME` is NOT taken, because ADR-0053 D-F2 (accepted; anchored in `scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json`) says the mysql2 parser keeps materialising an instant as a `Date`, folded to text only at the driver's read doors. Text at the client parser for an instant is the ADR's "B1-full", an option not taken, and three landed pins encode it (`sql-driver-13973-canonical-iso-read-door.test.ts` §C, `sql-driver-13567-audit-stamp-materialisation.test.ts` §B3, `sql-driver-14078-invalid-date-materialisation.test.ts` §B2). Reversing that is an ADR decision, not a changeset. ## Open: the datetime half A MySQL `DATETIME` in years 0001..0099 still reads a century late. No read-door repair exists (the fold is not invertible: `2004-09-03` may be a real stored day), so any fix is at the client parser. The options and their measured costs are in the report on the card; the tests here pin the current `datetime` reading as OBSERVED so a decision that moves it has to move them. ## Collateral (MySQL only) - Every read of a year from 1000 to 9999, on `date`, `datetime`, `time`, a `TIMESTAMP` column and `null`, is byte-identical base to head on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read (265 cells compared; the 25 that moved are exactly the year 0 / 9 / 99 `date` cells above, their `groupBy` / `distinct` / `min` keys, the raw `execute()` `DATE`, and the new connection option). - A raw `execute()` read, and a `DATE` column read under a field not declared `date`, now receive `YYYY-MM-DD` text where they received a `Date` (measured: `Date(2026-03-04T00:00:00.000Z)` became `"2026-03-04"`). A `DATETIME` there is still a `Date`. PostgreSQL has answered a `date` this way since its calendar-day parser. - A zero day (`0000-00-00`, only storable with `NO_ZERO_DATE` off) presents as that text, where mysql2 invented `1899-11-30`. The zero `DATETIME` pin of #14078 is untouched (still an Invalid `Date`). - SQLite and PostgreSQL: `withMysqlCalendarDayAsText` returns their config object unchanged (pinned), and the whole driver-sql suite is green on both. ## Tests - New `packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts`: the connection option (URL and object, host-set `dateStrings` kept, other dialects untouched, `DATETIME` / `TIMESTAMP` not in the list), then per dialect cell of the live matrix: stored text by raw cast, `find` / `findOne`, `groupBy` / `distinct` / `min` / `max`, `$eq` / `$gt`, the raw wire (`date` text everywhere, `datetime` a `Date` on live cells), a create / update / read round trip of years 42 and 1, and the `datetime` reading as observed. - New `packages/rest/src/data-date-read-year-below-100.test.ts`: the same read through `engine.find` / `findOne` / `aggregate` / `count` and `POST /api/v1/data/:object`, `POST …/query`, `GET …/:id`, on a SQLite cell (every runner) and a live MySQL cell (`OS_TEST_MYSQL_URL`; a named skip otherwise, a failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). No CI job runs `packages/rest` against MySQL today, so that cell runs only where a server is provisioned; the CI-run pin of the same read is the driver-sql file under `Temporal Conformance (live PG + MySQL)`. - Corrected pins that described the old read: `sql-driver-connect-bound.test.ts` (the exact MySQL URL config gains `dateStrings`), `sql-driver-11389-date-tz-skew.test.ts` (two comments, plus the option asserted), `sql-driver-20240-date-year-spelling.test.ts` (header no longer says MySQL reads a year below 100 a century late; the write-path cell now also reads the year-9 row back through the driver). Runs (live MySQL 8.0.46 on a private server at `+08:00`, live PostgreSQL 16.13 at `Asia/Shanghai`, `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`): - whole `@objectstack/driver-sql` suite at `e9d1a26529`: 203 files, 4669 passed, 1 skipped, "all 3 dialects were exercised"; the one test file changed after it (`sql-driver-20280-mysql-date-read.test.ts`, query options typed) re-run at `93af6580d2`: 25 passed on 3 dialects. - REST query files (`data-query-date-year-range`, `data-query-epoch-ms-date-comparand`, the new file) at `93af6580d2`: 3 files, 36 passed, SQLite and live MySQL cells. - `pnpm --filter @objectstack/driver-sql typecheck` exit 0 (the new test is in the `tsc` program, `--listFiles` count 1); `pnpm --filter @objectstack/rest typecheck` exit 0 (test layer: 0 files / 0 errors held). - eslint `--no-inline-config` on the 6 changed TypeScript files: 6 files, 0 errors, 0 warnings. That narrowing is complete: `eslint.config.mjs` lints `**/*.{ts,…}` and enables no type-aware rule, so this diff cannot move a verdict on an untouched file. Ablation (committed first; restore proved by blob equal to `HEAD` and a clean `git status --porcelain`): removing the `withMysqlCalendarDayAsText` call from `withConnectBound` - driver-sql, 4 files on 3 dialects: 9 failed / 107 passed. Every red is a MySQL cell or a config pin; every SQLite and PostgreSQL cell stayed green. - REST, with driver-sql's `dist/` rebuilt around the mutation (`ablation-dist-preflight` proved the call absent from `dist/` in the mutate leg, present again after the restore rebuild): 4 failed / 6 passed, the four being every `date` cell of the live MySQL cell. ## Changesets - `.changeset/20280-mysql-date-read-text.md`: `@objectstack/driver-sql` patch, `Clause-②: no`. - DELIBERATE CORRECTION of the pending `.changeset/20240-date-year-four-digits.md`, one clause. It said a stored MySQL year below 100 "still reads back a century late … which this change does not touch", which this PR makes false for the release both notes ship in. It now reads "read back a century late … which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text." `check-empty-changeset` is red on that file by design, and asks for exactly this: please confirm the correction. ## Acceptance notes - `check:dual-build-cjs-loads` printed PREREQUISITE NOT MET (it reads every package's `dist/`; this worktree built only the driver-sql and REST closures): NOT MEASURED here, CI measures it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a78f731 commit d3958ba

8 files changed

Lines changed: 579 additions & 14 deletions

‎.changeset/20240-date-year-four-digits.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ What changes:
3535

3636
**Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year.
3737

38-
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 still reads back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
38+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a MySQL `date` field reads back the day it stores, so a year below 100 no longer comes back a century late (#20280)
6+
7+
Clause-②: no
8+
9+
On MySQL the driver took mysql2's JS `Date` for a `DATE` column. mysql2 rebuilds it from the three stored numbers with `new Date(Date.UTC(y, m - 1, d))`, and `Date.UTC` reads a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: `where placed_on $eq '0009-03-04'` found the row, then presented it as `1909-03-04`. Measured on MySQL 8.0.46 (server `time_zone='+08:00'`), on records created through `POST /api/v1/data/:object`:
10+
11+
| stored (`CAST(… AS CHAR)`) | `find` / `findOne`, the engine, `…/query`, `GET …/:id`, a `groupBy` key, `min`, `distinct`: before | now |
12+
|:--|:--|:--|
13+
| `0009-03-04` | `1909-03-04` | `0009-03-04` |
14+
| `0099-03-04` | `1999-03-04` | `0099-03-04` |
15+
| `0000-06-15` | `1900-06-15` | `0000-06-15` |
16+
| `0999-06-15` | `0999-06-15` | `0999-06-15` |
17+
| `2026-03-04` | `2026-03-04` | `2026-03-04` |
18+
19+
The MySQL connection now asks mysql2 for a `DATE` as its `YYYY-MM-DD` wire text (`dateStrings: ['DATE']`), and the read doors present that text through `temporalStorageForm`, the rule the write and `where` paths already use. PostgreSQL has read a day as text the same way since its calendar-day parser.
20+
21+
**Unchanged**, measured identical before and after on MySQL through the driver, the engine and REST: every read of a year from 1000 to 9999 on a `date`, `datetime` or `time` field, and of a `TIMESTAMP` column and a `null`, on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read; every `$eq` / `$gt` answer. SQLite and PostgreSQL reads do not move.
22+
23+
**Also moved, on MySQL only:**
24+
25+
- A raw `execute()` read, and a `DATE` column read under a field that is not declared `date`, now receive the `YYYY-MM-DD` text where they received a `Date` (a `datetime` column still arrives as a `Date`). PostgreSQL already answers a `date` column this way.
26+
- A zero day (`0000-00-00`, storable only with `NO_ZERO_DATE` off) is presented as that text, where mysql2 made up `1899-11-30`.
27+
- A connection whose host already set `dateStrings` is left as the host set it.
28+
29+
**Not changed:** a `datetime` field. A MySQL `DATETIME` in years 0..99 still reads a century late (`0009-03-04T10:00:00.000Z` comes back as `2004-09-03T10:00:00.000Z`). ADR-0053 D-F2 keeps the client parser's `Date` for an instant, so that half stays open on #20280.

‎packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,13 @@ describe('#11389 — a Postgres `date` never becomes a JS Date', () => {
285285
expect((sqlite as any).knex.client.config.pool?.afterCreate).toBeUndefined();
286286

287287
// MySQL keeps exactly the UTC-session hook of #3942 — the date fix must not
288-
// have displaced it, and mysql2 needs no parser override (its DATE arrives
289-
// at UTC midnight because `withUtcSession` pins `connection.timezone: 'Z'`).
288+
// have displaced it. Its DATE takes no type parser either: since #20280 the
289+
// connection asks mysql2 for the DATE's wire text (`dateStrings`), which no
290+
// process clock can move.
290291
const mysql = make({ client: 'mysql2', connection: 'mysql://u:p@host:3306/d' });
291292
expect(typeof (mysql as any).knex.client.config.pool?.afterCreate).toBe('function');
292293
expect((mysql as any).knex.client.config.connection.timezone).toBe('Z');
294+
expect((mysql as any).knex.client.config.connection.dateStrings).toEqual(['DATE']);
293295
});
294296
});
295297

@@ -422,10 +424,12 @@ function declareZoneSweep(cell: DialectCell): void {
422424

423425
declareDialectCell(PG_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep);
424426
// MySQL is in the matrix because it is the dialect that PROVES the asymmetry:
425-
// mysql2 materialises a DATE at local midnight too, exactly like pg, and is
426-
// nevertheless correct today because `withUtcSession` already pins
427-
// `connection.timezone: 'Z'` (#3942). Losing that pin would reproduce #11389
428-
// one dialect over, and this cell is what would say so.
427+
// under its default `'local'` zone mysql2 materialises a DATE at local
428+
// midnight, exactly like pg. It was correct because `withUtcSession` pins
429+
// `connection.timezone: 'Z'` (#3942), and since #20280 the connection asks for
430+
// the DATE's wire text (`dateStrings`), which no zone reaches at all. Losing
431+
// both pins would reproduce #11389 one dialect over, and this cell is what
432+
// would say so.
429433
declareDialectCell(MYSQL_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep);
430434

431435
// ── The raw wire form, on a live server ─────────────────────────────────────

‎packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
* `9-03-04` as year 9 but stored `99-03-04` as 1999-03-04.
2626
*
2727
* The write path is asserted on the STORED text, read by a raw cast
28-
* (`storedText`), never through the driver's read path. On MySQL that path
29-
* hands a year below 100 back a century late: mysql2 rebuilds a `DATE` as
28+
* (`storedText`), and not only through the driver's read path. On MySQL that
29+
* path handed a year below 100 back a century late: mysql2 rebuilt a `DATE` as
3030
* `new Date(Date.UTC(y, m - 1, d))` (`parseDate`, with the driver's
3131
* `timezone: 'Z'`), and `Date.UTC` maps years 0..99 to 1900..1999, so a stored
32-
* `0009-03-04` is presented as `1909-03-04`. That is a read-path defect of its
33-
* own, unchanged by this card and reported beside it. Each row below asserts
32+
* `0009-03-04` was presented as `1909-03-04`. That read-path defect was
33+
* reported beside this card and closed by #20280, which reads a MySQL `DATE`
34+
* as its wire text, so the write-path cell reads the year-9 row back through
35+
* the driver on every dialect too. Each row below asserts
3436
* one answer for the number, its `Date` and its ISO string, plus a 2026
3537
* control. A year below 0 or above 9999 is refused
3638
* as a comparand one layer up, at the engine's temporal-comparand door
@@ -147,6 +149,8 @@ function measure(cell: DialectCell): void {
147149
await driver.update(WRITES, 'w2', { placed_on: new Date(Y0099) }, NO_AUDIT);
148150
expect(await stored('w1')).toBe('0009-03-04');
149151
expect(await stored('w2')).toBe('0099-03-04');
152+
// [#20280] …and so does a year below 100, MySQL included.
153+
expect((await driver.findOne(WRITES, { where: { id: 'w1' } }, NO_AUDIT))?.placed_on).toBe('0009-03-04');
150154
// …so the stored day and a comparand for it are one day.
151155
expect(await ids(WRITES, { placed_on: { $eq: '0009-03-04' } })).toEqual(['w1']);
152156
expect(await ids(WRITES, { placed_on: { $eq: new Date(Y0099) } })).toEqual(['w2']);
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20280] A MySQL `DATE` reads back the day it stores, in every year a
5+
* `YYYY-MM-DD` day spells.
6+
*
7+
* mysql2 rebuilt a `DATE` from its three numbers, `new Date(Date.UTC(y, m - 1,
8+
* d))` under the driver's `timezone: 'Z'`, and `Date.UTC` reads a year from 0
9+
* to 99 as 1900 + year. The write was right and the read was wrong. Measured on
10+
* MySQL 8.0.46 (server `time_zone='+08:00'`), through the driver, the engine and
11+
* `POST /api/v1/data/:object/query`, which present the same value:
12+
*
13+
* | stored `date` | presented before | presented now |
14+
* |:--|:--|:--|
15+
* | `0009-03-04` | `1909-03-04` | `0009-03-04` |
16+
* | `0099-03-04` | `1999-03-04` | `0099-03-04` |
17+
* | `0999-06-15` | `0999-06-15` | `0999-06-15` |
18+
* | `2026-03-04` | `2026-03-04` | `2026-03-04` |
19+
*
20+
* The connection now asks mysql2 for the `DATE`'s wire text (`dateStrings:
21+
* ['DATE']`, `withMysqlCalendarDayAsText`), and the read doors present it
22+
* through `toDateOnly`, which is `@objectstack/core`'s `temporalStorageForm`.
23+
* That is how PostgreSQL already reads a day. SQLite and PostgreSQL read these
24+
* years right before and after, so their cells are the control.
25+
*
26+
* `datetime` is outside this change. A MySQL `DATETIME` in years 0..99 still
27+
* reads a century late (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`): mysql2's
28+
* `parseDateTime` hands the wire text to V8's non-ISO `Date` parser. Fixing
29+
* that means text (or a new parse) at the client parser, which ADR-0053 D-F2
30+
* declines for an instant, so it waits on a decision. The MySQL `datetime`
31+
* cells below pin it as OBSERVED, beside the raw `Date` that D-F2 keeps, so a
32+
* decision that moves it has to move this file too.
33+
*/
34+
35+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
36+
import type { DriverQuery } from '@objectstack/spec/contracts';
37+
import type { FilterCondition } from '@objectstack/spec/data';
38+
import { SqlDriver } from './sql-driver.js';
39+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
40+
41+
const TABLE = 'os20280_days';
42+
const NO_AUDIT = { bypassTenantAudit: true };
43+
44+
/** id · the stored day · the stored instant */
45+
const ROWS = [
46+
{ id: 'y9', placed_on: '0009-03-04', opened_at: '0009-03-04T10:00:00.000Z' },
47+
{ id: 'y99', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' },
48+
{ id: 'y999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' },
49+
{ id: 'y2026', placed_on: '2026-03-04', opened_at: '2026-03-04T10:00:00.123Z' },
50+
] as const;
51+
const DAYS = ROWS.map((r) => r.placed_on);
52+
53+
/**
54+
* What a MySQL `DATETIME` in years 0..99 still reads as — the half this card
55+
* does not decide (see the header). Observed, not desired.
56+
*/
57+
const MYSQL_DATETIME_FOLD: Record<string, string> = {
58+
y9: '2004-09-03T10:00:00.000Z',
59+
y99: '1999-03-04T10:00:00.000Z',
60+
};
61+
62+
/** The instant each row presents on this cell. */
63+
function presentedInstant(cell: DialectCell, row: (typeof ROWS)[number]): string {
64+
return cell.id === 'mysql' ? (MYSQL_DATETIME_FOLD[row.id] ?? row.opened_at) : row.opened_at;
65+
}
66+
67+
/** knex's raw result shape differs per client; this is the only place that knows. */
68+
function rowsOf(cell: DialectCell, res: any): any[] {
69+
if (cell.id === 'pg') return res?.rows ?? [];
70+
if (cell.id === 'mysql') return Array.isArray(res) ? (res[0] ?? []) : [];
71+
return Array.isArray(res) ? res : (res?.rows ?? []);
72+
}
73+
74+
describe('[#20280] a MySQL connection asks mysql2 for a DATE as its wire text', () => {
75+
const drivers: SqlDriver[] = [];
76+
const make = (config: any): SqlDriver => {
77+
// knex builds its client eagerly and opens a pool connection only on the
78+
// first query, so nothing here connects.
79+
const d = new SqlDriver(config);
80+
drivers.push(d);
81+
return d;
82+
};
83+
const connectionOf = (d: SqlDriver): any => (d as any).knex.client.config.connection;
84+
afterAll(async () => {
85+
for (const d of drivers) await d.disconnect().catch(() => {});
86+
});
87+
88+
it('a URL and an object connection both carry `dateStrings: [DATE]` beside the UTC pin', () => {
89+
// `mysql2` only: knex loads the `mysql` package when it builds that client,
90+
// and this workspace does not install it.
91+
for (const connection of ['mysql://u:p@127.0.0.1:1/d', { host: '127.0.0.1', port: 1, database: 'd' }]) {
92+
const conn = connectionOf(make({ client: 'mysql2', connection }));
93+
expect(conn.dateStrings, typeof connection).toEqual(['DATE']);
94+
expect(conn.timezone, typeof connection).toBe('Z');
95+
}
96+
});
97+
98+
it('only `DATE`: an instant keeps the client parser, ADR-0053 D-F2', () => {
99+
const conn = connectionOf(make({ client: 'mysql2', connection: 'mysql://u:p@127.0.0.1:1/d' }));
100+
expect(conn.dateStrings).not.toContain('DATETIME');
101+
expect(conn.dateStrings).not.toContain('TIMESTAMP');
102+
});
103+
104+
it("leaves a host's own `dateStrings` alone, as `withUtcSession` leaves its `timezone`", () => {
105+
for (const own of [true, false, ['DATE', 'DATETIME']]) {
106+
const conn = connectionOf(make({ client: 'mysql2', connection: { host: '127.0.0.1', database: 'd', dateStrings: own } }));
107+
expect(conn.dateStrings).toEqual(own);
108+
}
109+
});
110+
111+
it('touches no other dialect', () => {
112+
for (const config of [
113+
{ client: 'pg', connection: 'postgres://u:p@127.0.0.1:1/d' },
114+
{ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true },
115+
]) {
116+
expect(connectionOf(make(config)).dateStrings, config.client).toBeUndefined();
117+
}
118+
});
119+
});
120+
121+
function measure(cell: DialectCell): void {
122+
describe(`[#20280] a date reads back the day it stores — ${cell.label}`, () => {
123+
let driver: SqlDriver;
124+
const ids = async (where: FilterCondition) =>
125+
(await driver.find(TABLE, { where }, NO_AUDIT)).map((r: any) => r.id).sort();
126+
const findOne = async (id: string): Promise<any> => driver.findOne(TABLE, { where: { id } }, NO_AUDIT);
127+
128+
beforeAll(async () => {
129+
driver = new SqlDriver(cell.config());
130+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
131+
await driver.initObjects([
132+
{ name: TABLE, fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } } },
133+
] as any);
134+
for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT);
135+
});
136+
137+
afterAll(async () => {
138+
await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {});
139+
await driver?.disconnect();
140+
});
141+
142+
it('the server stores each day as written — a raw cast, past every read path', async () => {
143+
const sql =
144+
cell.id === 'pg'
145+
? `select "id", "placed_on"::text as t from "${TABLE}"`
146+
: cell.id === 'mysql'
147+
? `select \`id\`, cast(\`placed_on\` as char) as t from \`${TABLE}\``
148+
: `select "id", cast("placed_on" as text) as t from "${TABLE}"`;
149+
const stored = Object.fromEntries(rowsOf(cell, await driver.execute(sql)).map((r) => [r.id, r.t]));
150+
expect(stored).toEqual(Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on])));
151+
});
152+
153+
it('find() and findOne() present the stored day', async () => {
154+
const rows = await driver.find(TABLE, {}, NO_AUDIT);
155+
expect(Object.fromEntries(rows.map((r: any) => [r.id, r.placed_on])))
156+
.toEqual(Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on])));
157+
for (const row of ROWS) {
158+
expect((await findOne(row.id))?.placed_on, row.id).toBe(row.placed_on);
159+
}
160+
});
161+
162+
it('a groupBy key, distinct() and min / max present the stored day', async () => {
163+
const byDay: DriverQuery = {
164+
groupBy: ['placed_on'],
165+
aggregations: [{ function: 'count', alias: 'n' }],
166+
};
167+
const grouped = await driver.aggregate(TABLE, byDay);
168+
expect(grouped.map((r: any) => r.placed_on).sort()).toEqual([...DAYS].sort());
169+
expect((await driver.distinct(TABLE, 'placed_on', undefined, NO_AUDIT)).sort()).toEqual([...DAYS].sort());
170+
const firstAndLast: DriverQuery = {
171+
aggregations: [
172+
{ function: 'min', field: 'placed_on', alias: 'first' },
173+
{ function: 'max', field: 'placed_on', alias: 'last' },
174+
],
175+
};
176+
const [range]: any[] = await driver.aggregate(TABLE, firstAndLast);
177+
expect(range.first).toBe('0009-03-04');
178+
expect(range.last).toBe('2026-03-04');
179+
});
180+
181+
it('$eq finds each stored day, $gt orders by it, and the misread day finds nothing', async () => {
182+
for (const row of ROWS) expect(await ids({ placed_on: { $eq: row.placed_on } }), row.id).toEqual([row.id]);
183+
expect(await ids({ placed_on: { $gt: '0099-03-04' } })).toEqual(['y2026', 'y999']);
184+
expect(await ids({ placed_on: { $eq: '1909-03-04' } })).toEqual([]);
185+
});
186+
187+
it('a datetime is presented as before: right on SQLite and PostgreSQL, a MySQL year below 100 still folded (observed)', async () => {
188+
const rows = await driver.find(TABLE, {}, NO_AUDIT);
189+
expect(Object.fromEntries(rows.map((r: any) => [r.id, r.opened_at])))
190+
.toEqual(Object.fromEntries(ROWS.map((r) => [r.id, presentedInstant(cell, r)])));
191+
});
192+
193+
it('the raw wire: a date is text on every dialect, and a datetime stays the client\'s Date on a live one (ADR-0053 D-F2)', async () => {
194+
const sql =
195+
cell.id === 'mysql'
196+
? `select \`placed_on\`, \`opened_at\` from \`${TABLE}\` where \`id\` = 'y9'`
197+
: `select "placed_on", "opened_at" from "${TABLE}" where "id" = 'y9'`;
198+
const [raw] = rowsOf(cell, await driver.execute(sql));
199+
expect(raw.placed_on).toBe('0009-03-04');
200+
if (cell.live) {
201+
expect(raw.opened_at instanceof Date, `${cell.label} raw datetime is ${typeof raw.opened_at}`).toBe(true);
202+
expect((raw.opened_at as Date).toISOString()).toBe(presentedInstant(cell, ROWS[0]));
203+
} else {
204+
expect(typeof raw.opened_at).toBe('string');
205+
}
206+
});
207+
208+
it('a write then a read round-trips a year below 100 — create, update, findOne', async () => {
209+
await driver.create(TABLE, { id: 'rt', placed_on: '0042-01-31' }, NO_AUDIT);
210+
expect((await findOne('rt'))?.placed_on).toBe('0042-01-31');
211+
await driver.update(TABLE, 'rt', { placed_on: '0001-12-31' }, NO_AUDIT);
212+
expect((await findOne('rt'))?.placed_on).toBe('0001-12-31');
213+
});
214+
});
215+
}
216+
217+
for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'mysql date read (#20280)', measure);

‎packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ describe('SqlDriver — connection-attempt bound (framework#3769)', () => {
7575
// `connection.timezone`, which defaults to the HOST's local zone. Pinned
7676
// to UTC so the recorded instant cannot depend on which machine wrote it.
7777
timezone: 'Z',
78+
// #20280 — a `DATE` comes back as its `YYYY-MM-DD` wire text, which
79+
// mysql2's `Date.UTC` rebuild read a century late below year 100.
80+
dateStrings: ['DATE'],
7881
});
7982
});
8083

0 commit comments

Comments
 (0)