|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20264] The supported years of a `date` and a `datetime` are 0001..9999, on |
| 5 | + * every dialect this driver speaks. The refusal outside them sits one layer up, |
| 6 | + * at the engine's two doors (the temporal-comparand door and the record |
| 7 | + * validator, both asking `@objectstack/core`'s `isOutsideTemporalYearRange`), so |
| 8 | + * this driver's `where` and write paths are not a door and are not pinned as |
| 9 | + * one. What each dialect owes the range is the other half: every year inside |
| 10 | + * it is STORED as the day or instant it names, and compared as it, the edges |
| 11 | + * included, beside a 2026 control. |
| 12 | + * |
| 13 | + * Measured on the base through the engine over this driver: a `datetime` bound |
| 14 | + * in year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 on SQLite and |
| 15 | + * answered 500 on PostgreSQL 16 (`22009` / `22007`) and MySQL 8.0; year 0 |
| 16 | + * answered 500 on PostgreSQL on both kinds (`22008` — PostgreSQL's `DATE` and |
| 17 | + * `timestamptz` have no year 0); a REST create of a `date` |
| 18 | + * `"+010000-01-01T00:00:00.000Z"` was stored verbatim on SQLite and a 500 on |
| 19 | + * PostgreSQL and MySQL. The engine refuses each of those now. |
| 20 | + * |
| 21 | + * ## The one cell read-back does not assert: MySQL `DATETIME` in 0001..0099 |
| 22 | + * |
| 23 | + * MySQL documents `DATETIME` from year 1000, and a `DATETIME` in years |
| 24 | + * 0001..0099 is stored right and read back a century late through mysql2's |
| 25 | + * instant parser (`0009-03-04 10:00` → `2004-09-03T10:00Z`), which ADR-0053 |
| 26 | + * D-F2 keeps. Those years are inside the range, so the doors accept them; the |
| 27 | + * MySQL cell asserts their STORED text, and the misread is a decision returned |
| 28 | + * to the maintainer. From year 0100 up MySQL reads a `DATETIME` back as written, |
| 29 | + * which the cell asserts. |
| 30 | + */ |
| 31 | + |
| 32 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 33 | +import type { FilterCondition } from '@objectstack/spec/data'; |
| 34 | +import { SqlDriver } from './sql-driver.js'; |
| 35 | +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; |
| 36 | + |
| 37 | +const TABLE = 'os20264_ledger'; |
| 38 | + |
| 39 | +const shape = (name: string) => ({ |
| 40 | + name, |
| 41 | + fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } }, |
| 42 | +}) as any; |
| 43 | + |
| 44 | +/** In chronological order on both fields: the edges, three early years and a 2026 control. */ |
| 45 | +const ROWS = [ |
| 46 | + { id: 'first', placed_on: '0001-01-01', opened_at: '0001-01-01T00:00:00.000Z' }, |
| 47 | + { id: 'y0099', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' }, |
| 48 | + { id: 'y0100', placed_on: '0100-03-04', opened_at: '0100-03-04T10:00:00.000Z' }, |
| 49 | + { id: 'y0999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' }, |
| 50 | + { id: 'c2026', placed_on: '2026-02-01', opened_at: '2026-02-01T10:00:00.000Z' }, |
| 51 | + { id: 'last', placed_on: '9999-12-31', opened_at: '9999-12-31T23:59:59.999Z' }, |
| 52 | +]; |
| 53 | +const ORDER = ROWS.map((r) => r.id); |
| 54 | + |
| 55 | +const NO_AUDIT = { bypassTenantAudit: true }; |
| 56 | + |
| 57 | +/** knex's raw result shape differs per client; this is the only place that knows. */ |
| 58 | +function rowsOf(cell: DialectCell, res: any): any[] { |
| 59 | + if (cell.id === 'pg') return res?.rows ?? []; |
| 60 | + if (cell.id === 'mysql') return Array.isArray(res) ? (res[0] ?? []) : []; |
| 61 | + return Array.isArray(res) ? res : (res?.rows ?? []); |
| 62 | +} |
| 63 | + |
| 64 | +/** The text the server STORED for one row, by a raw cast — never the driver's read path. */ |
| 65 | +async function storedText(driver: SqlDriver, cell: DialectCell, id: string, column: string): Promise<string | null> { |
| 66 | + const sql = |
| 67 | + cell.id === 'pg' |
| 68 | + ? `select "${column}"::text as t from "${TABLE}" where "id" = ?` |
| 69 | + : cell.id === 'mysql' |
| 70 | + ? `select cast(\`${column}\` as char) as t from \`${TABLE}\` where \`id\` = ?` |
| 71 | + : `select cast("${column}" as text) as t from "${TABLE}" where "id" = ?`; |
| 72 | + const rows = rowsOf(cell, await driver.execute(sql, [id])); |
| 73 | + expect(rows, `no stored row for ${id}`).toHaveLength(1); |
| 74 | + return rows[0].t ?? null; |
| 75 | +} |
| 76 | + |
| 77 | +/** Does this cell read `opened_at` of `row` back as written? Every cell but MySQL below year 100. */ |
| 78 | +const readsBackAsWritten = (cell: DialectCell, row: (typeof ROWS)[number]) => |
| 79 | + !(cell.id === 'mysql' && Number(row.opened_at.slice(0, 4)) < 100); |
| 80 | + |
| 81 | +function measure(cell: DialectCell): void { |
| 82 | + describe(`[#20264] the supported years 0001..9999 — ${cell.label}`, () => { |
| 83 | + let driver: SqlDriver; |
| 84 | + const ids = async (where: FilterCondition) => |
| 85 | + (await driver.find(TABLE, { where }, NO_AUDIT)).map((r) => r.id as string) |
| 86 | + .sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b)); |
| 87 | + |
| 88 | + beforeAll(async () => { |
| 89 | + driver = new SqlDriver(cell.config()); |
| 90 | + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); |
| 91 | + await driver.initObjects([shape(TABLE)]); |
| 92 | + for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT); |
| 93 | + }); |
| 94 | + |
| 95 | + afterAll(async () => { |
| 96 | + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); |
| 97 | + await driver.disconnect(); |
| 98 | + }); |
| 99 | + |
| 100 | + it('a date in every year of the range is stored as that day and read back as written', async () => { |
| 101 | + for (const row of ROWS) { |
| 102 | + expect(await storedText(driver, cell, row.id, 'placed_on'), `stored ${row.id}`).toBe(row.placed_on); |
| 103 | + expect((await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.placed_on, `read ${row.id}`).toBe(row.placed_on); |
| 104 | + } |
| 105 | + }); |
| 106 | + |
| 107 | + it('a datetime in every year of the range is stored as that instant, and read back as written save the MySQL 0001..0099 cell', async () => { |
| 108 | + for (const row of ROWS) { |
| 109 | + const read = (await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.opened_at; |
| 110 | + if (readsBackAsWritten(cell, row)) { |
| 111 | + expect(read, `read ${row.id}`).toBe(row.opened_at); |
| 112 | + } else { |
| 113 | + // Stored right: the misread is the client parser's (see the module note). |
| 114 | + expect(await storedText(driver, cell, row.id, 'opened_at'), `stored ${row.id}`) |
| 115 | + .toBe(`${row.opened_at.slice(0, 10)} ${row.opened_at.slice(11, 23)}`); |
| 116 | + } |
| 117 | + } |
| 118 | + }); |
| 119 | + |
| 120 | + for (const field of ['placed_on', 'opened_at'] as const) { |
| 121 | + it(`${field}: each value is found by $eq and the range orders chronologically — the edges and the 2026 control`, async () => { |
| 122 | + for (const [i, row] of ROWS.entries()) { |
| 123 | + const value = row[field]; |
| 124 | + expect(await ids({ [field]: { $eq: value } }), `${row.id} $eq`).toEqual([row.id]); |
| 125 | + expect(await ids({ [field]: { $gt: value } }), `${row.id} $gt`).toEqual(ORDER.slice(i + 1)); |
| 126 | + expect(await ids({ [field]: { $lt: value } }), `${row.id} $lt`).toEqual(ORDER.slice(0, i)); |
| 127 | + } |
| 128 | + }); |
| 129 | + } |
| 130 | + }); |
| 131 | +} |
| 132 | + |
| 133 | +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'temporal year range', measure); |
0 commit comments