|
| 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); |
0 commit comments