Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/mysql-boolean-row-read-presentation.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
}
71 changes: 47 additions & 24 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 =
Expand DownExpand Up@@ -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,
Expand All@@ -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;
}
Expand All@@ -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;
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand Down
Loading