|
| 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 | +} |
0 commit comments