Skip to content

Commit e40a28c

Browse files
os-warrenclaude
andauthored
fix(driver-sql): present declared booleans as JSON booleans on MySQL row reads (#11782 scope: find/distinct/group keys) (#12019)
formatOutput's boolean read coercion and readPresentationKind's boolean arm were gated isSqlite-only, so a declared boolean answered 1/0 on MySQL (tinyint(1) via mysql2) through find(), distinct() and aggregate group keys while SQLite and Postgres answered true/false — and after #11635 the aggregate door and the row-read door gave opposite answers on the same MySQL connection. The boolean presentation now runs on the two dialects whose stored boolean is a number; Postgres stays ungated (native boolean). Measured live before/after on MySQL 8.0.46, PG 16.13 and embedded SQLite. Pinned by a cross-door agreement suite over the live dialect matrix. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3def551 commit e40a28c

3 files changed

Lines changed: 259 additions & 24 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782)
6+
7+
`formatOutput`'s boolean read coercion — and its per-column mirror
8+
`readPresentationKind`, which `distinct()` and the aggregate group-key /
9+
`min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage
10+
is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean
11+
answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while
12+
SQLite and Postgres answered `true`/`false` — and, after #11635 presented
13+
aggregate `min`/`max` on every dialect, `max(flag) === true` and
14+
`row.flag === 1` disagreed on the same column over the same MySQL connection.
15+
16+
Measured on live MySQL 8.0.46 before the fix: `find().flag``1` (`typeof
17+
number`), `distinct('flag')``[0, 1]`, aggregate group keys → `1`/`0`. The
18+
boolean presentation now runs on the two dialects whose stored boolean is a
19+
number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real
20+
`boolean` node-pg already parses, so it deliberately stays outside the gate and
21+
its answers are byte-identical. A `NULL` boolean stays `null` on every door
22+
(absence is not `false`), and declared `number`/`string` columns are untouched.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11782] A declared `Field.boolean` answers JSON booleans on EVERY read door,
5+
* on every dialect this driver speaks — one column, one answer set, whichever
6+
* door it is read through.
7+
*
8+
* ## The measured gap this suite exists to keep closed
9+
*
10+
* Measured 2026-08-25 on live MySQL 8.0.46 through the driver boundary, on
11+
* `main` @ `d63b014360`, before the fix (the same instrument as the card:
12+
* `driver.create(...)` + the read doors, over `flag` declared
13+
* `type: 'boolean'`, stored as `tinyint(1)`):
14+
*
15+
* - `find().flag` → `1` / `0` (`typeof number`)
16+
* - `distinct('flag')` → `[0, 1]` (`typeof number`)
17+
* - `aggregate` groupBy(`flag`) → keys `1`/`0` (`typeof number`)
18+
* - `aggregate` `min`/`max` → `false`/`true` (correct since #11635/#11785)
19+
*
20+
* while SQLite and Postgres answered `true`/`false` on all four. The boolean
21+
* read coercion in `formatOutput` — and its per-column mirror
22+
* `readPresentationKind`, which `distinct()` and the aggregate group-key /
23+
* `min`/`max` tracking consume — was gated `isSqlite`-only, so MySQL's storage
24+
* form leaked. Worse than a one-dialect leak: after #11635 presented the
25+
* aggregate door everywhere, `max(flag)` answered `true` while `find()` on the
26+
* SAME column over the SAME connection answered `1` — two doors, opposite
27+
* answers, in one request cycle. The fix runs the boolean presentation on the
28+
* two dialects whose stored boolean is a number (SQLite INTEGER 0/1, MySQL
29+
* `tinyint(1)`); Postgres stores a real `boolean` node-pg parses, so its
30+
* stored form already IS the presented form and it stays ungated.
31+
*
32+
* ## Assertion conventions
33+
*
34+
* Booleans are asserted STRICTLY (`toBe(true)` / `toBe(false)`, `toEqual` on
35+
* exact values): the before-state is a WRONG VALUE, not an absence — `1` is
36+
* truthy, so a `toBeTruthy()` pin would have passed on the defect this suite
37+
* went red on. The cross-door test asserts the doors against EACH OTHER on the
38+
* same column (per the triage note on the card: a one-door pin passes on an
39+
* implementation where the doors still disagree).
40+
*
41+
* ## Controls
42+
*
43+
* A declared `number` and a declared `string` column ride the same fixture and
44+
* must come back untouched — the presentation is per declared-boolean column,
45+
* never per row. A NULL boolean stays `null` on every door: absence is not
46+
* `false`, and `Boolean(null)` would manufacture one.
47+
*/
48+
49+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
50+
import type { DriverQuery } from '@objectstack/spec/contracts';
51+
import { SqlDriver } from './sql-driver.js';
52+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
53+
54+
const TABLE = 'bool_row_read_presentation';
55+
56+
/** 1 true / 2 false / 1 null — asymmetric so a sticky constant shows. */
57+
const ROWS = [
58+
{ label: 'a', flag: true, score: 10 },
59+
{ label: 'b', flag: false, score: 20 },
60+
{ label: 'c', flag: false, score: 30 },
61+
{ label: 'd', flag: null, score: 40 },
62+
] as const;
63+
64+
function declarePresentation(cell: DialectCell): void {
65+
describe(`[#11782] driver-sql — boolean row reads answer JSON booleans (${cell.label})`, () => {
66+
let driver: SqlDriver;
67+
68+
beforeAll(async () => {
69+
driver = new SqlDriver(cell.config());
70+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
71+
await driver.initObjects([
72+
{
73+
name: TABLE,
74+
fields: {
75+
label: { type: 'string' },
76+
flag: { type: 'boolean' },
77+
score: { type: 'number' },
78+
},
79+
},
80+
]);
81+
for (const row of ROWS) {
82+
await driver.create(TABLE, { ...row }, { bypassTenantAudit: true });
83+
}
84+
});
85+
86+
afterAll(async () => {
87+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
88+
await driver.disconnect();
89+
});
90+
91+
// The fixture read back rather than trusted: four rows under their labels —
92+
// a seed that dropped or folded a row would turn every assertion below into
93+
// a test of the wrong table.
94+
it('the fixture is four rows: T, F, F, NULL', async () => {
95+
const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>;
96+
expect(rows.map((r) => r.label).sort()).toEqual(['a', 'b', 'c', 'd']);
97+
});
98+
99+
// ─── The row-read door (`find()`) — the card's own surface ───────────────
100+
101+
it('find() answers the JSON boolean true — not 1', async () => {
102+
const rows = (await driver.find(TABLE, { where: { label: 'a' } } as DriverQuery)) as any[];
103+
expect(rows).toHaveLength(1);
104+
// STRICT: `1` — the exact value this suite went red on — is truthy.
105+
expect(rows[0].flag).toBe(true);
106+
});
107+
108+
it('find() answers the JSON boolean false — not 0', async () => {
109+
const rows = (await driver.find(TABLE, { where: { label: 'b' } } as DriverQuery)) as any[];
110+
expect(rows).toHaveLength(1);
111+
expect(rows[0].flag).toBe(false);
112+
});
113+
114+
it('find() passes a NULL boolean through — absence is not false', async () => {
115+
const rows = (await driver.find(TABLE, { where: { label: 'd' } } as DriverQuery)) as any[];
116+
expect(rows).toHaveLength(1);
117+
expect(rows[0].flag).toBeNull();
118+
});
119+
120+
it('CONTROL find() leaves declared number and string columns untouched', async () => {
121+
const rows = (await driver.find(TABLE, { where: { label: 'a' } } as DriverQuery)) as any[];
122+
expect(rows[0].score).toBe(10);
123+
expect(rows[0].label).toBe('a');
124+
});
125+
126+
// ─── The values door (`distinct()`) — measured here, shares the gate ─────
127+
128+
it('distinct(flag) answers JSON booleans — not 0/1', async () => {
129+
const values = await driver.distinct(TABLE, 'flag');
130+
// Set-compare: order is the dialect's; membership is the contract.
131+
// `toEqual` does not coerce, so a `Set {0, 1, null}` fails here.
132+
expect(new Set(values)).toEqual(new Set([true, false, null]));
133+
});
134+
135+
it('CONTROL distinct(score) still answers numbers', async () => {
136+
const values = await driver.distinct(TABLE, 'score');
137+
expect(new Set(values)).toEqual(new Set([10, 20, 30, 40]));
138+
});
139+
140+
// ─── Cross-door agreement — the assertion the triage note asked for ──────
141+
142+
it('find(), distinct() and aggregate() answer the SAME JSON booleans for the same column', async () => {
143+
const found = new Set(
144+
((await driver.find(TABLE, {})) as any[]).map((r) => r.flag),
145+
);
146+
const listed = new Set(await driver.distinct(TABLE, 'flag'));
147+
const grouped = (await driver.aggregate(TABLE, {
148+
groupBy: ['flag'],
149+
aggregations: [{ function: 'count', field: 'score', alias: 'n' }],
150+
} as DriverQuery)) as any[];
151+
const groupKeys = new Set(grouped.map((g) => g.flag));
152+
const agg = (await driver.aggregate(TABLE, {
153+
aggregations: [
154+
{ function: 'min', field: 'flag', alias: 'lo' },
155+
{ function: 'max', field: 'flag', alias: 'hi' },
156+
],
157+
} as DriverQuery)) as any[];
158+
159+
const domain = new Set([true, false, null]);
160+
expect(found, 'find()').toEqual(domain);
161+
expect(listed, 'distinct()').toEqual(domain);
162+
expect(groupKeys, 'aggregate group keys').toEqual(domain);
163+
expect(agg[0].lo, 'min(flag)').toBe(false);
164+
expect(agg[0].hi, 'max(flag)').toBe(true);
165+
});
166+
167+
it('aggregate group keys carry per-group counts under the presented key', async () => {
168+
const grouped = (await driver.aggregate(TABLE, {
169+
groupBy: ['flag'],
170+
aggregations: [{ function: 'count', field: 'score', alias: 'n' }],
171+
} as DriverQuery)) as any[];
172+
const byKey = new Map(grouped.map((g) => [g.flag, Number(g.n)]));
173+
expect(byKey.get(true), 'count under key true').toBe(1);
174+
expect(byKey.get(false), 'count under key false').toBe(2);
175+
expect(byKey.get(null), 'count under key null').toBe(1);
176+
});
177+
});
178+
}
179+
180+
// A matrix that silently finds zero cells reports OK — assert the axis is real
181+
// before iterating it (the #11455 suite's own guard, kept in force here).
182+
describe('[#11782] the dialect axis this suite runs', () => {
183+
it('runs every dialect this driver speaks', () => {
184+
expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']);
185+
});
186+
});
187+
188+
for (const cell of DIALECT_CELLS) {
189+
declareDialectCell(cell, 'boolean row-read presentation', declarePresentation);
190+
}

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7987,13 +7987,15 @@ export class SqlDriver implements IDataDriver {
79877987
// only, so it is deliberately not tracked.
79887988
if ((funcName === 'min' || funcName === 'max') && agg.field) {
79897989
// [#11249/#11635] A boolean aggregand presents on EVERY dialect,
7990-
// not only under `readPresentationKind`'s SQLite gate. That gate
7991-
// mirrors `formatOutput`'s ROW reads, where the native dialects
7992-
// hand storage back as-is — but on this door the backend answers
7993-
// `min`/`max` as 1/0 on MySQL (`tinyint(1)`) and on Postgres (the
7994-
// `cast(?? as int)` above), and the ruled contract is `false` /
7995-
// `true` in JSON: order statistics return a member of the input
7996-
// domain, and SQL drivers convert at the driver boundary.
7990+
// not only under `readPresentationKind`'s dialect gate. That gate
7991+
// mirrors `formatOutput`'s ROW reads (SQLite + MySQL since #11782;
7992+
// SQLite-only when this landed), where the storage-form dialects
7993+
// hand back a number — but on this door the backend ALSO answers
7994+
// `min`/`max` as 1/0 on Postgres (the `cast(?? as int)` above,
7995+
// over a column whose row reads need no presentation), and the
7996+
// ruled contract is `false` / `true` in JSON: order statistics
7997+
// return a member of the input domain, and SQL drivers convert at
7998+
// the driver boundary. The `??` fallback is what carries Postgres.
79977999
// `presentReadValue('boolean', …)` leaves `null` (no rows / all
79988000
// NULL) untouched and is idempotent on a value already boolean.
79998001
const kind =
@@ -11590,9 +11592,13 @@ export class SqlDriver implements IDataDriver {
1159011592
* row, asked one field at a time so the paths that return raw builder output
1159111593
* can ask it too. `null` means the stored form already IS the presented form.
1159211594
*
11593-
* The boolean / numeric rules are SQLite-only because `formatOutput` gates
11594-
* them that way: SQLite is the dialect without a native boolean, and the
11595-
* numeric repair only exists for legacy TEXT-affinity columns.
11595+
* The boolean rule runs on SQLite AND MySQL — the two dialects that store a
11596+
* declared boolean as a number (INTEGER 0/1, `tinyint(1)`) — because
11597+
* `formatOutput` gates its row reads that way (#11782; SQLite-only before,
11598+
* which is how a declared boolean answered `1`/`0` on MySQL). Postgres
11599+
* stores a real `boolean` node-pg parses, so there the stored form already
11600+
* IS the presented form. The numeric repair stays SQLite-only: it exists
11601+
* for legacy TEXT-affinity columns, which no other dialect has.
1159611602
*/
1159711603
protected readPresentationKind(
1159811604
table: string | null | undefined,
@@ -11601,8 +11607,10 @@ export class SqlDriver implements IDataDriver {
1160111607
if (!table) return null;
1160211608
const temporal = this.temporalFieldKind(table, field);
1160311609
if (temporal) return temporal;
11610+
if ((this.isSqlite || this.isMysql) && this.booleanFields[table]?.includes(field)) {
11611+
return 'boolean';
11612+
}
1160411613
if (!this.isSqlite) return null;
11605-
if (this.booleanFields[table]?.includes(field)) return 'boolean';
1160611614
if (this.numericFields[table]?.includes(field)) return 'number';
1160711615
return null;
1160811616
}
@@ -11613,10 +11621,11 @@ export class SqlDriver implements IDataDriver {
1161311621
* (`aggregate`, `distinct` — #3797 for instants, #3849 for scalars).
1161411622
*
1161511623
* The dialect gating mirrors `formatOutput`: the `Field.datetime` repair and
11616-
* the boolean / numeric coercions are SQLite-only (it is the one dialect where
11617-
* storage ≠ presentation), while the `Field.date` → `YYYY-MM-DD` collapse runs
11618-
* everywhere. {@link readPresentationKind} does the SQLite gating for the
11619-
* scalar kinds, so by the time one arrives here the dialect is settled.
11624+
* the numeric coercion are SQLite-only, the boolean coercion runs on SQLite
11625+
* and MySQL (#11782 — the two dialects whose stored boolean is a number),
11626+
* and the `Field.date` → `YYYY-MM-DD` collapse runs everywhere.
11627+
* {@link readPresentationKind} does the dialect gating for the scalar kinds,
11628+
* so by the time one arrives here the dialect is settled.
1162011629
*/
1162111630
protected presentReadValue(kind: ReadPresentationKind, value: any): any {
1162211631
if (value == null) return value;
@@ -14371,15 +14380,6 @@ export class SqlDriver implements IDataDriver {
1437114380
}
1437214381
}
1437314382

14374-
const booleanFields = this.booleanFields[object];
14375-
if (booleanFields && booleanFields.length > 0) {
14376-
for (const field of booleanFields) {
14377-
if (data[field] !== undefined && data[field] !== null) {
14378-
data[field] = Boolean(data[field]);
14379-
}
14380-
}
14381-
}
14382-
1438314383
// Numeric scalars stored on a legacy TEXT-affinity column come back as
1438414384
// strings ('4'); coerce numeric-looking strings back to numbers so the
1438514385
// declared type wins regardless of when the column was created. Only
@@ -14427,6 +14427,29 @@ export class SqlDriver implements IDataDriver {
1442714427
}
1442814428
}
1442914429

14430+
// [#11782] Present a declared `Field.boolean` as a JSON boolean on the
14431+
// dialects whose STORAGE form is a number: SQLite (INTEGER 0/1) and MySQL
14432+
// (`tinyint(1)`, which mysql2 hands back as a JS number). Postgres stores a
14433+
// real `boolean` and node-pg already parses it, so its stored form IS the
14434+
// presented form and it deliberately stays outside the gate — the same
14435+
// per-dialect posture {@link readPresentationKind} takes for the read doors
14436+
// that return raw builder output (`distinct`; `aggregate` tracks its own
14437+
// result columns per #11635). Before this, the gate was SQLite-only and a
14438+
// declared boolean answered `1`/`0` on MySQL's row-read door while
14439+
// answering `true`/`false` on the other two dialects — and, once #11635
14440+
// presented `min`/`max` everywhere, `find()` and `aggregate()` gave
14441+
// OPPOSITE answers for the same column on the same MySQL connection.
14442+
if (this.isSqlite || this.isMysql) {
14443+
const booleanFields = this.booleanFields[object];
14444+
if (booleanFields && booleanFields.length > 0) {
14445+
for (const field of booleanFields) {
14446+
if (data[field] !== undefined && data[field] !== null) {
14447+
data[field] = Boolean(data[field]);
14448+
}
14449+
}
14450+
}
14451+
}
14452+
1443014453
// ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD`
1443114454
// string, slicing any stored time component. This transparently repairs
1443214455
// legacy rows written as a full timestamp before this normalization, so

0 commit comments

Comments
 (0)