|
22 | 22 | * |
23 | 23 | * The alias carve-out is pinned here too — see the collision block at the |
24 | 24 | * bottom, which is the design question the card left to the implementer. |
| 25 | + * |
| 26 | + * ## Two arms, and why the SQLite one is not the whole file |
| 27 | + * |
| 28 | + * The first `describe` below is the SQLite agreement arm. It cannot measure two |
| 29 | + * halves of this door's contract, and says so rather than letting a green stand |
| 30 | + * in for them: |
| 31 | + * |
| 32 | + * - the BOOLEAN rule fires under `isSqlite || isMysql` (`sql-driver.ts` |
| 33 | + * `formatOutput`), so its MySQL half is invisible on SQLite; |
| 34 | + * - the INSTANT classes are stored canonically on SQLite since #3912, so |
| 35 | + * removing the read presentation moves nothing for them there — the five |
| 36 | + * instant agreements CANNOT fail on this dialect. On Postgres and MySQL the |
| 37 | + * client library hands the driver a `Date`, so the fold is real work. |
| 38 | + * |
| 39 | + * The `measure(cell)` arm at the bottom is those two halves, over `DIALECT_CELLS` |
| 40 | + * — the ADR-0053 D-A3 driver axis, declared through `declareDialectCell` so an |
| 41 | + * unprovisioned cell is a NAMED SKIP and never a silent pass. Locally (no |
| 42 | + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`) the two live cells report as |
| 43 | + * skips and this door's MySQL boolean half and PG/MySQL instant fold are NOT |
| 44 | + * MEASURED; the `Temporal Conformance (live PG + MySQL)` job provisions both and |
| 45 | + * measures them there. |
25 | 46 | */ |
26 | 47 |
|
27 | | -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 48 | +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; |
28 | 49 | import { SqlDriver } from '../src/index.js'; |
| 50 | +import { |
| 51 | + DIALECT_CELLS, |
| 52 | + assertThreeWayZoneSkew, |
| 53 | + declareDialectCell, |
| 54 | + readServerZone, |
| 55 | + type DialectCell, |
| 56 | +} from './live-dialect-matrix.testkit.js'; |
29 | 57 |
|
30 | 58 | const TABLE = 'window_row'; |
31 | 59 |
|
@@ -195,3 +223,158 @@ describe('rows leaving findWithWindowFunctions() (#16609)', () => { |
195 | 223 | }); |
196 | 224 | }); |
197 | 225 | }); |
| 226 | + |
| 227 | +// ── THE LIVE-DIALECT ARM ───────────────────────────────────────────────────── |
| 228 | +// |
| 229 | +// The two halves the SQLite arm above cannot measure, on the ADR-0053 D-A3 |
| 230 | +// driver axis. Everything here is asserted ABSOLUTELY rather than as a |
| 231 | +// door-to-door agreement where the absolute shape is the point: an agreement |
| 232 | +// between two doors that are both wrong is green, and the MySQL boolean half |
| 233 | +// and the PG/MySQL instant fold are exactly the places this door had never been |
| 234 | +// measured at all. |
| 235 | + |
| 236 | +const LIVE_TABLE = 'os16609_window_row'; |
| 237 | + |
| 238 | +/** The canonical instant text — the ONE shape every read door presents. */ |
| 239 | +const LIVE_ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 240 | + |
| 241 | +/** The instant classes this door now folds: the audit stamps + a `Field.datetime`. */ |
| 242 | +const LIVE_INSTANT_COLUMNS = ['closed_at', 'created_at', 'updated_at'] as const; |
| 243 | + |
| 244 | +const LIVE_CLOSED_AT = ['2026-01-10T09:00:00.123Z', '2026-02-14T21:30:45.678Z'] as const; |
| 245 | + |
| 246 | +/** Spelled once, the same assertion `sql-driver-13973-…` makes for the other doors. */ |
| 247 | +function expectCanonicalInstant(value: unknown, label: string): void { |
| 248 | + expect(value, `${label}: the window door did not return the column`).toBeDefined(); |
| 249 | + expect(value, `${label}: null`).not.toBeNull(); |
| 250 | + expect( |
| 251 | + value instanceof Date, |
| 252 | + `${label}: findWithWindowFunctions handed out a JS Date (${String(value)}) — ADR-0053 D-F1 ` + |
| 253 | + `rules the canonical text on every dialect, and this door runs the same formatOutput pass`, |
| 254 | + ).toBe(false); |
| 255 | + expect(typeof value, `${label}: type`).toBe('string'); |
| 256 | + expect(value, `${label}: shape`).toMatch(LIVE_ISO_Z); |
| 257 | +} |
| 258 | + |
| 259 | +function measure(cell: DialectCell): void { |
| 260 | + describe(`#16609 — findWithWindowFunctions presents on every dialect (${cell.label})`, () => { |
| 261 | + let driver: SqlDriver; |
| 262 | + let windowed: any[] = []; |
| 263 | + let found: any[] = []; |
| 264 | + |
| 265 | + beforeAll(async () => { |
| 266 | + driver = new SqlDriver(cell.config()); |
| 267 | + // A live cell proves nothing unless server, process and UTC disagree — |
| 268 | + // the same guard every other matrix consumer runs. |
| 269 | + if (cell.live) assertThreeWayZoneSkew(cell, await readServerZone(cell, driver)); |
| 270 | + await driver.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {}); |
| 271 | + await driver.initObjects([{ name: LIVE_TABLE, fields: FIELDS as any }] as any); |
| 272 | + for (const [i, iso] of LIVE_CLOSED_AT.entries()) { |
| 273 | + await driver.create( |
| 274 | + LIVE_TABLE, |
| 275 | + { |
| 276 | + id: `w${i}`, |
| 277 | + // Row 0 declares `true`, row 1 `false` — a rule that folded every |
| 278 | + // value one way could not pass both. |
| 279 | + ok: i === 0, |
| 280 | + meta: { k: i }, |
| 281 | + amount: 10 + i, |
| 282 | + region: i === 0 ? 'east' : 'west', |
| 283 | + // Bound as a JS `Date`, which is the shape whose fold this measures. |
| 284 | + closed_at: new Date(iso), |
| 285 | + closed_on: '2026-01-10', |
| 286 | + starts_at: '09:30:00.500', |
| 287 | + }, |
| 288 | + { bypassTenantAudit: true }, |
| 289 | + ); |
| 290 | + } |
| 291 | + const query = { windowFunctions: [ROW_NUMBER], orderBy: [{ field: 'id', order: 'asc' as const }] }; |
| 292 | + windowed = await driver.findWithWindowFunctions(LIVE_TABLE, query as any, { bypassTenantAudit: true }); |
| 293 | + found = await driver.find( |
| 294 | + LIVE_TABLE, |
| 295 | + { orderBy: [{ field: 'id', order: 'asc' }] }, |
| 296 | + { bypassTenantAudit: true }, |
| 297 | + ); |
| 298 | + }, 60_000); |
| 299 | + |
| 300 | + afterAll(async () => { |
| 301 | + await driver?.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {}); |
| 302 | + await driver?.disconnect(); |
| 303 | + }); |
| 304 | + |
| 305 | + it('§L0 the fixture is non-vacuous: the window door returned both rows carrying every column under test', () => { |
| 306 | + // Every assertion below reads these keys off these rows; a door that did |
| 307 | + // not select a column would let them all pass having checked nothing. |
| 308 | + expect(windowed).toHaveLength(LIVE_CLOSED_AT.length); |
| 309 | + for (const row of windowed) { |
| 310 | + for (const col of [...LIVE_INSTANT_COLUMNS, 'ok', 'rn'] as const) { |
| 311 | + expect(row[col], `${row.id}.${col} missing from the window row`).toBeDefined(); |
| 312 | + expect(row[col], `${row.id}.${col} is null`).not.toBeNull(); |
| 313 | + } |
| 314 | + } |
| 315 | + }); |
| 316 | + |
| 317 | + it('§L1 a declared Field.boolean answers a boolean, not 1/0', () => { |
| 318 | + // The MySQL half of the `isSqlite || isMysql` gate in `formatOutput` — |
| 319 | + // `tinyint(1)`, which mysql2 hands back as a JS number. Unmeasurable on |
| 320 | + // the SQLite-only arm above, and the reason this cell exists. |
| 321 | + for (const row of windowed) { |
| 322 | + expect(typeof row.ok, `${row.id}.ok on ${cell.label}`).toBe('boolean'); |
| 323 | + } |
| 324 | + expect(windowed.map((r) => r.ok)).toEqual([true, false]); |
| 325 | + }); |
| 326 | + |
| 327 | + it('§L2 the instant classes are canonical ISO-Z text, never a Date', () => { |
| 328 | + // ADR-0053 D-F1 through this door: the audit stamps and every declared |
| 329 | + // `Field.datetime`. On PG/MySQL the client hands the driver a `Date`, so |
| 330 | + // this is the fold doing real work — see §L4. |
| 331 | + for (const row of windowed) { |
| 332 | + for (const col of LIVE_INSTANT_COLUMNS) expectCanonicalInstant(row[col], `${row.id}.${col}`); |
| 333 | + } |
| 334 | + expect(windowed.map((r) => r.closed_at)).toEqual([...LIVE_CLOSED_AT]); |
| 335 | + }); |
| 336 | + |
| 337 | + it('§L3 the window row equals the find() row on every declared column', () => { |
| 338 | + expect(found).toHaveLength(windowed.length); |
| 339 | + for (let i = 0; i < found.length; i++) { |
| 340 | + for (const column of DECLARED_COLUMNS) { |
| 341 | + expect(typeof windowed[i][column], `typeof ${column} on row ${i} (${cell.label})`).toBe( |
| 342 | + typeof found[i][column], |
| 343 | + ); |
| 344 | + expect(windowed[i][column], `${column} on row ${i} (${cell.label})`).toEqual(found[i][column]); |
| 345 | + } |
| 346 | + } |
| 347 | + }); |
| 348 | + |
| 349 | + it("§L4 the fold is the driver's, not the client's: raw knex still materialises the dialect's own shape", async () => { |
| 350 | + const raw: any = await (driver as any).knex(LIVE_TABLE).where('id', 'w0').first(); |
| 351 | + expect(raw, 'raw read returned nothing').toBeTruthy(); |
| 352 | + if (cell.live) { |
| 353 | + // Postgres (`timestamptz`) and MySQL (`DATETIME(3)`) hand a `Date` to |
| 354 | + // the driver — D-F2: the client parser is untouched, the driver folds at |
| 355 | + // its own read boundary. This is what makes §L2 a measurement rather |
| 356 | + // than a restatement of the client's behaviour. |
| 357 | + for (const col of LIVE_INSTANT_COLUMNS) { |
| 358 | + expect( |
| 359 | + raw[col] instanceof Date, |
| 360 | + `${cell.label} raw ${col} is ${typeof raw[col]} (${String(raw[col])}) — the client ` + |
| 361 | + `parser was changed, which the #13973 ruling forbids`, |
| 362 | + ).toBe(true); |
| 363 | + } |
| 364 | + expect((raw.closed_at as Date).toISOString()).toBe(windowed[0].closed_at); |
| 365 | + } |
| 366 | + if (cell.id === 'mysql') { |
| 367 | + // Same for the boolean: `tinyint(1)` off the raw client is a number, so |
| 368 | + // §L1's `boolean` on this cell was produced by `formatOutput`. |
| 369 | + expect(typeof raw.ok, 'mysql raw ok').toBe('number'); |
| 370 | + } |
| 371 | + }); |
| 372 | + }); |
| 373 | +} |
| 374 | + |
| 375 | +// A matrix that silently finds zero cells reports OK — every cell is declared |
| 376 | +// EITHER WAY, measured when it is provisioned and a NAMED SKIP when it is not |
| 377 | +// (a named RED under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). |
| 378 | +for (const cell of DIALECT_CELLS) { |
| 379 | + declareDialectCell(cell, 'window-function row presentation (#16609)', measure); |
| 380 | +} |
0 commit comments