diff --git a/.changeset/mysql-boolean-row-read-presentation.md b/.changeset/mysql-boolean-row-read-presentation.md new file mode 100644 index 0000000000..e41af7172c --- /dev/null +++ b/.changeset/mysql-boolean-row-read-presentation.md @@ -0,0 +1,22 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782) + +`formatOutput`'s boolean read coercion — and its per-column mirror +`readPresentationKind`, which `distinct()` and the aggregate group-key / +`min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage +is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean +answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while +SQLite and Postgres answered `true`/`false` — and, after #11635 presented +aggregate `min`/`max` on every dialect, `max(flag) === true` and +`row.flag === 1` disagreed on the same column over the same MySQL connection. + +Measured on live MySQL 8.0.46 before the fix: `find().flag` → `1` (`typeof +number`), `distinct('flag')` → `[0, 1]`, aggregate group keys → `1`/`0`. The +boolean presentation now runs on the two dialects whose stored boolean is a +number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real +`boolean` node-pg already parses, so it deliberately stays outside the gate and +its answers are byte-identical. A `NULL` boolean stays `null` on every door +(absence is not `false`), and declared `number`/`string` columns are untouched. diff --git a/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts b/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts new file mode 100644 index 0000000000..f75ccb3526 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11782] A declared `Field.boolean` answers JSON booleans on EVERY read door, + * on every dialect this driver speaks — one column, one answer set, whichever + * door it is read through. + * + * ## The measured gap this suite exists to keep closed + * + * Measured 2026-08-25 on live MySQL 8.0.46 through the driver boundary, on + * `main` @ `d63b014360`, before the fix (the same instrument as the card: + * `driver.create(...)` + the read doors, over `flag` declared + * `type: 'boolean'`, stored as `tinyint(1)`): + * + * - `find().flag` → `1` / `0` (`typeof number`) + * - `distinct('flag')` → `[0, 1]` (`typeof number`) + * - `aggregate` groupBy(`flag`) → keys `1`/`0` (`typeof number`) + * - `aggregate` `min`/`max` → `false`/`true` (correct since #11635/#11785) + * + * while SQLite and Postgres answered `true`/`false` on all four. The boolean + * read coercion in `formatOutput` — and its per-column mirror + * `readPresentationKind`, which `distinct()` and the aggregate group-key / + * `min`/`max` tracking consume — was gated `isSqlite`-only, so MySQL's storage + * form leaked. Worse than a one-dialect leak: after #11635 presented the + * aggregate door everywhere, `max(flag)` answered `true` while `find()` on the + * SAME column over the SAME connection answered `1` — two doors, opposite + * answers, in one request cycle. The fix runs the boolean presentation on the + * two dialects whose stored boolean is a number (SQLite INTEGER 0/1, MySQL + * `tinyint(1)`); Postgres stores a real `boolean` node-pg parses, so its + * stored form already IS the presented form and it stays ungated. + * + * ## Assertion conventions + * + * Booleans are asserted STRICTLY (`toBe(true)` / `toBe(false)`, `toEqual` on + * exact values): the before-state is a WRONG VALUE, not an absence — `1` is + * truthy, so a `toBeTruthy()` pin would have passed on the defect this suite + * went red on. The cross-door test asserts the doors against EACH OTHER on the + * same column (per the triage note on the card: a one-door pin passes on an + * implementation where the doors still disagree). + * + * ## Controls + * + * A declared `number` and a declared `string` column ride the same fixture and + * must come back untouched — the presentation is per declared-boolean column, + * never per row. A NULL boolean stays `null` on every door: absence is not + * `false`, and `Boolean(null)` would manufacture one. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'bool_row_read_presentation'; + +/** 1 true / 2 false / 1 null — asymmetric so a sticky constant shows. */ +const ROWS = [ + { label: 'a', flag: true, score: 10 }, + { label: 'b', flag: false, score: 20 }, + { label: 'c', flag: false, score: 30 }, + { label: 'd', flag: null, score: 40 }, +] as const; + +function declarePresentation(cell: DialectCell): void { +describe(`[#11782] driver-sql — boolean row reads answer JSON booleans (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { + name: TABLE, + fields: { + label: { type: 'string' }, + flag: { type: 'boolean' }, + score: { type: 'number' }, + }, + }, + ]); + for (const row of ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + // The fixture read back rather than trusted: four rows under their labels — + // a seed that dropped or folded a row would turn every assertion below into + // a test of the wrong table. + it('the fixture is four rows: T, F, F, NULL', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>; + expect(rows.map((r) => r.label).sort()).toEqual(['a', 'b', 'c', 'd']); + }); + + // ─── The row-read door (`find()`) — the card's own surface ─────────────── + + it('find() answers the JSON boolean true — not 1', async () => { + const rows = (await driver.find(TABLE, { where: { label: 'a' } } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + // STRICT: `1` — the exact value this suite went red on — is truthy. + expect(rows[0].flag).toBe(true); + }); + + it('find() answers the JSON boolean false — not 0', async () => { + const rows = (await driver.find(TABLE, { where: { label: 'b' } } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + expect(rows[0].flag).toBe(false); + }); + + it('find() passes a NULL boolean through — absence is not false', async () => { + const rows = (await driver.find(TABLE, { where: { label: 'd' } } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + expect(rows[0].flag).toBeNull(); + }); + + it('CONTROL find() leaves declared number and string columns untouched', async () => { + const rows = (await driver.find(TABLE, { where: { label: 'a' } } as DriverQuery)) as any[]; + expect(rows[0].score).toBe(10); + expect(rows[0].label).toBe('a'); + }); + + // ─── The values door (`distinct()`) — measured here, shares the gate ───── + + it('distinct(flag) answers JSON booleans — not 0/1', async () => { + const values = await driver.distinct(TABLE, 'flag'); + // Set-compare: order is the dialect's; membership is the contract. + // `toEqual` does not coerce, so a `Set {0, 1, null}` fails here. + expect(new Set(values)).toEqual(new Set([true, false, null])); + }); + + it('CONTROL distinct(score) still answers numbers', async () => { + const values = await driver.distinct(TABLE, 'score'); + expect(new Set(values)).toEqual(new Set([10, 20, 30, 40])); + }); + + // ─── Cross-door agreement — the assertion the triage note asked for ────── + + it('find(), distinct() and aggregate() answer the SAME JSON booleans for the same column', async () => { + const found = new Set( + ((await driver.find(TABLE, {})) as any[]).map((r) => r.flag), + ); + const listed = new Set(await driver.distinct(TABLE, 'flag')); + const grouped = (await driver.aggregate(TABLE, { + groupBy: ['flag'], + aggregations: [{ function: 'count', field: 'score', alias: 'n' }], + } as DriverQuery)) as any[]; + const groupKeys = new Set(grouped.map((g) => g.flag)); + const agg = (await driver.aggregate(TABLE, { + aggregations: [ + { function: 'min', field: 'flag', alias: 'lo' }, + { function: 'max', field: 'flag', alias: 'hi' }, + ], + } as DriverQuery)) as any[]; + + const domain = new Set([true, false, null]); + expect(found, 'find()').toEqual(domain); + expect(listed, 'distinct()').toEqual(domain); + expect(groupKeys, 'aggregate group keys').toEqual(domain); + expect(agg[0].lo, 'min(flag)').toBe(false); + expect(agg[0].hi, 'max(flag)').toBe(true); + }); + + it('aggregate group keys carry per-group counts under the presented key', async () => { + const grouped = (await driver.aggregate(TABLE, { + groupBy: ['flag'], + aggregations: [{ function: 'count', field: 'score', alias: 'n' }], + } as DriverQuery)) as any[]; + const byKey = new Map(grouped.map((g) => [g.flag, Number(g.n)])); + expect(byKey.get(true), 'count under key true').toBe(1); + expect(byKey.get(false), 'count under key false').toBe(2); + expect(byKey.get(null), 'count under key null').toBe(1); + }); +}); +} + +// A matrix that silently finds zero cells reports OK — assert the axis is real +// before iterating it (the #11455 suite's own guard, kept in force here). +describe('[#11782] the dialect axis this suite runs', () => { + it('runs every dialect this driver speaks', () => { + expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']); + }); +}); + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'boolean row-read presentation', declarePresentation); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6cdf61bef5..b5462ff6dc 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7987,13 +7987,15 @@ export class SqlDriver implements IDataDriver { // only, so it is deliberately not tracked. if ((funcName === 'min' || funcName === 'max') && agg.field) { // [#11249/#11635] A boolean aggregand presents on EVERY dialect, - // not only under `readPresentationKind`'s SQLite gate. That gate - // mirrors `formatOutput`'s ROW reads, where the native dialects - // hand storage back as-is — but on this door the backend answers - // `min`/`max` as 1/0 on MySQL (`tinyint(1)`) and on Postgres (the - // `cast(?? as int)` above), and the ruled contract is `false` / - // `true` in JSON: order statistics return a member of the input - // domain, and SQL drivers convert at the driver boundary. + // not only under `readPresentationKind`'s dialect gate. That gate + // mirrors `formatOutput`'s ROW reads (SQLite + MySQL since #11782; + // SQLite-only when this landed), where the storage-form dialects + // hand back a number — but on this door the backend ALSO answers + // `min`/`max` as 1/0 on Postgres (the `cast(?? as int)` above, + // over a column whose row reads need no presentation), and the + // ruled contract is `false` / `true` in JSON: order statistics + // return a member of the input domain, and SQL drivers convert at + // the driver boundary. The `??` fallback is what carries Postgres. // `presentReadValue('boolean', …)` leaves `null` (no rows / all // NULL) untouched and is idempotent on a value already boolean. const kind = @@ -11590,9 +11592,13 @@ export class SqlDriver implements IDataDriver { * row, asked one field at a time so the paths that return raw builder output * can ask it too. `null` means the stored form already IS the presented form. * - * The boolean / numeric rules are SQLite-only because `formatOutput` gates - * them that way: SQLite is the dialect without a native boolean, and the - * numeric repair only exists for legacy TEXT-affinity columns. + * The boolean rule runs on SQLite AND MySQL — the two dialects that store a + * declared boolean as a number (INTEGER 0/1, `tinyint(1)`) — because + * `formatOutput` gates its row reads that way (#11782; SQLite-only before, + * which is how a declared boolean answered `1`/`0` on MySQL). Postgres + * stores a real `boolean` node-pg parses, so there the stored form already + * IS the presented form. The numeric repair stays SQLite-only: it exists + * for legacy TEXT-affinity columns, which no other dialect has. */ protected readPresentationKind( table: string | null | undefined, @@ -11601,8 +11607,10 @@ export class SqlDriver implements IDataDriver { if (!table) return null; const temporal = this.temporalFieldKind(table, field); if (temporal) return temporal; + if ((this.isSqlite || this.isMysql) && this.booleanFields[table]?.includes(field)) { + return 'boolean'; + } if (!this.isSqlite) return null; - if (this.booleanFields[table]?.includes(field)) return 'boolean'; if (this.numericFields[table]?.includes(field)) return 'number'; return null; } @@ -11613,10 +11621,11 @@ export class SqlDriver implements IDataDriver { * (`aggregate`, `distinct` — #3797 for instants, #3849 for scalars). * * The dialect gating mirrors `formatOutput`: the `Field.datetime` repair and - * the boolean / numeric coercions are SQLite-only (it is the one dialect where - * storage ≠ presentation), while the `Field.date` → `YYYY-MM-DD` collapse runs - * everywhere. {@link readPresentationKind} does the SQLite gating for the - * scalar kinds, so by the time one arrives here the dialect is settled. + * the numeric coercion are SQLite-only, the boolean coercion runs on SQLite + * and MySQL (#11782 — the two dialects whose stored boolean is a number), + * and the `Field.date` → `YYYY-MM-DD` collapse runs everywhere. + * {@link readPresentationKind} does the dialect gating for the scalar kinds, + * so by the time one arrives here the dialect is settled. */ protected presentReadValue(kind: ReadPresentationKind, value: any): any { if (value == null) return value; @@ -14309,15 +14318,6 @@ export class SqlDriver implements IDataDriver { } } - const booleanFields = this.booleanFields[object]; - if (booleanFields && booleanFields.length > 0) { - for (const field of booleanFields) { - if (data[field] !== undefined && data[field] !== null) { - data[field] = Boolean(data[field]); - } - } - } - // Numeric scalars stored on a legacy TEXT-affinity column come back as // strings ('4'); coerce numeric-looking strings back to numbers so the // declared type wins regardless of when the column was created. Only @@ -14365,6 +14365,29 @@ export class SqlDriver implements IDataDriver { } } + // [#11782] Present a declared `Field.boolean` as a JSON boolean on the + // dialects whose STORAGE form is a number: SQLite (INTEGER 0/1) and MySQL + // (`tinyint(1)`, which mysql2 hands back as a JS number). Postgres stores a + // real `boolean` and node-pg already parses it, so its stored form IS the + // presented form and it deliberately stays outside the gate — the same + // per-dialect posture {@link readPresentationKind} takes for the read doors + // that return raw builder output (`distinct`; `aggregate` tracks its own + // result columns per #11635). Before this, the gate was SQLite-only and a + // declared boolean answered `1`/`0` on MySQL's row-read door while + // answering `true`/`false` on the other two dialects — and, once #11635 + // presented `min`/`max` everywhere, `find()` and `aggregate()` gave + // OPPOSITE answers for the same column on the same MySQL connection. + if (this.isSqlite || this.isMysql) { + const booleanFields = this.booleanFields[object]; + if (booleanFields && booleanFields.length > 0) { + for (const field of booleanFields) { + if (data[field] !== undefined && data[field] !== null) { + data[field] = Boolean(data[field]); + } + } + } + } + // ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD` // string, slicing any stored time component. This transparently repairs // legacy rows written as a full timestamp before this normalization, so