also looking at this
fix: a multi-row insert names every column any row carries (#1189)
#1190
3 files
+384
-11
| @@ -0,0 +1,237 @@ | ||
| 1 | /** | |
| 2 | * A multi-row INSERT must name every column any row carries — | |
| 3 | * stacksjs/bun-query-builder#1189. | |
| 4 | * | |
| 5 | * The column list came from `Object.keys(rows[0])`, and each row was then read | |
| 6 | * through it as `row[keys[c]]`. A key that appeared only in a later row had | |
| 7 | * nowhere to go, so its value was dropped from the statement — from every row | |
| 8 | * that carried it — with no error and the full row count reported. | |
| 9 | * | |
| 10 | * Four builders had it independently: `insertInto().values([...])`, the | |
| 11 | * `createMany()` and `insertMany()` helpers, and `buildInsertClause()`, which | |
| 12 | * backs upsert / insertOrIgnore / insertGetId / updateOrInsert. | |
| 13 | * | |
| 14 | * The reporter lost five cross-references out of forty seeded rows and found it | |
| 15 | * only by running `count(reference_number)` afterwards. That is the shape worth | |
| 16 | * pinning: nothing throws, the count is right, the table is not empty, so a | |
| 17 | * test asserting "the insert succeeded" cannot see it. Every case below either | |
| 18 | * reads the value back or asserts the emitted SQL names the column. | |
| 19 | */ | |
| 20 | ||
| 21 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 22 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 23 | import { tmpdir } from 'node:os' | |
| 24 | import { join, resolve } from 'node:path' | |
| 25 | import process from 'node:process' | |
| 26 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 27 | import { config } from '../src/config' | |
| 28 | import { PG_URL, probePostgres } from './pg' | |
| 29 | ||
| 30 | const pgAvailable = await probePostgres() | |
| 31 | const SRC = resolve(import.meta.dir, '../src/index.ts') | |
| 32 | ||
| 33 | function qb() { | |
| 34 | const models = { | |
| 35 | entries: { | |
| 36 | columns: { | |
| 37 | id: { type: 'integer', isPrimaryKey: true }, | |
| 38 | kind: { type: 'text' }, | |
| 39 | subject_text: { type: 'text' }, | |
| 40 | previous_text: { type: 'text' }, | |
| 41 | }, | |
| 42 | }, | |
| 43 | } as any | |
| 44 | return createQueryBuilder<any>({ | |
| 45 | schema: buildDatabaseSchema(models), | |
| 46 | meta: buildSchemaMeta(models), | |
| 47 | autoMigration: { enabled: false } as any, | |
| 48 | }) | |
| 49 | } | |
| 50 | ||
| 51 | /** The issue's own rows: `previous_text` is set on the second one only. */ | |
| 52 | const ROWS = [ | |
| 53 | { kind: 'labeled', subject_text: 'first-row-only' }, | |
| 54 | { kind: 'renamed', subject_text: 'second', previous_text: 'SHOULD PERSIST' }, | |
| 55 | ] | |
| 56 | ||
| 57 | describe('a multi-row INSERT names every column any row carries (#1189)', () => { | |
| 58 | let dialect: string | |
| 59 | beforeEach(() => { dialect = config.dialect }) | |
| 60 | afterEach(() => { config.dialect = dialect as any }) | |
| 61 | ||
| 62 | it('includes a column that only a later row sets', () => { | |
| 63 | config.dialect = 'sqlite' as any | |
| 64 | const sql = String((qb() as any).insertInto('entries').values(ROWS).toSQL()) | |
| 65 | ||
| 66 | // Before the fix this read `("kind","subject_text")` and the third value | |
| 67 | // was absent from the statement entirely. | |
| 68 | expect(sql).toContain('"previous_text"') | |
| 69 | expect(sql).toContain('INSERT INTO "entries"("kind","subject_text","previous_text")') | |
| 70 | }) | |
| 71 | ||
| 72 | it('orders the union by first appearance, so existing columns keep their place', () => { | |
| 73 | config.dialect = 'sqlite' as any | |
| 74 | const sql = String((qb() as any).insertInto('entries').values([ | |
| 75 | { kind: 'a' }, | |
| 76 | { subject_text: 'b' }, | |
| 77 | { previous_text: 'c' }, | |
| 78 | ]).toSQL()) | |
| 79 | ||
| 80 | expect(sql).toContain('("kind","subject_text","previous_text")') | |
| 81 | }) | |
| 82 | ||
| 83 | it('fills the gap with DEFAULT, so a column default still applies', () => { | |
| 84 | config.dialect = 'postgres' as any | |
| 85 | const sql = String((qb() as any).insertInto('entries').values(ROWS).toSQL()) | |
| 86 | ||
| 87 | // Row 1 lacks previous_text. DEFAULT rather than a bound NULL: NULL would | |
| 88 | // override a non-null default and a NOT NULL column with a default would | |
| 89 | // reject the row. DEFAULT consumes no placeholder, hence $1..$5 for 6 cells. | |
| 90 | expect(sql).toContain('($1,$2,DEFAULT),($3,$4,$5)') | |
| 91 | }) | |
| 92 | ||
| 93 | it('binds NULL on SQLite, which has no DEFAULT inside a VALUES list', () => { | |
| 94 | config.dialect = 'sqlite' as any | |
| 95 | const sql = String((qb() as any).insertInto('entries').values(ROWS).toSQL()) | |
| 96 | ||
| 97 | // `near "DEFAULT": syntax error` — verified against bun:sqlite. So the | |
| 98 | // column default is lost here; the value the caller DID supply is not. | |
| 99 | expect(sql).toContain('(?,?,NULL),(?,?,?)') | |
| 100 | }) | |
| 101 | ||
| 102 | it('leaves a uniform batch exactly as it was', () => { | |
| 103 | config.dialect = 'postgres' as any | |
| 104 | const sql = String((qb() as any).insertInto('entries').values([ | |
| 105 | { kind: 'a', subject_text: 'x' }, | |
| 106 | { kind: 'b', subject_text: 'y' }, | |
| 107 | ]).toSQL()) | |
| 108 | ||
| 109 | // The common case: no presence check, no DEFAULT, placeholders dense. | |
| 110 | expect(sql).toContain('($1,$2),($3,$4)') | |
| 111 | expect(sql).not.toContain('DEFAULT') | |
| 112 | }) | |
| 113 | ||
| 114 | it('still emits nothing for an empty batch (#1097)', () => { | |
| 115 | config.dialect = 'sqlite' as any | |
| 116 | expect(String((qb() as any).insertInto('entries').values([]).toSQL())).not.toContain('INSERT') | |
| 117 | }) | |
| 118 | }) | |
| 119 | ||
| 120 | /** | |
| 121 | * The part that matters: the value has to be IN the table afterwards. | |
| 122 | * | |
| 123 | * In a subprocess because `config` is process-wide and other files pin the | |
| 124 | * dialect to sqlite, and because reading the row back is the only assertion | |
| 125 | * that could have failed while the bug was live — `toSQL()` does not expose | |
| 126 | * parameters, and every negative case ("this row has no previous_text") passed | |
| 127 | * throughout. | |
| 128 | */ | |
| 129 | function runProbe(body: string, env: Record<string, string> = {}): { code: number | null, output: string } { | |
| 130 | const dir = mkdtempSync(join(tmpdir(), 'qb-1189-')) | |
| 131 | try { | |
| 132 | const script = join(dir, 'probe.ts') | |
| 133 | writeFileSync(script, body) | |
| 134 | const child = Bun.spawnSync([process.execPath, script], { | |
| 135 | env: { ...process.env, ...env }, | |
| 136 | stdout: 'pipe', | |
| 137 | stderr: 'pipe', | |
| 138 | }) | |
| 139 | return { code: child.exitCode, output: `${child.stdout.toString()}${child.stderr.toString()}` } | |
| 140 | } | |
| 141 | finally { | |
| 142 | rmSync(dir, { recursive: true, force: true }) | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | describe('the value reaches the table (#1189)', () => { | |
| 147 | it('persists through every multi-row entry point on SQLite', () => { | |
| 148 | const { code, output } = runProbe(` | |
| 149 | import { Database } from 'bun:sqlite' | |
| 150 | import { setConfig, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(SRC)} | |
| 151 | import { mkdtempSync } from 'node:fs' | |
| 152 | import { tmpdir } from 'node:os' | |
| 153 | import { join } from 'node:path' | |
| 154 | ||
| 155 | const file = join(mkdtempSync(join(tmpdir(), 'qb-1189-db-')), 'r.db') | |
| 156 | new Database(file).run('CREATE TABLE entries (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, subject_text TEXT, previous_text TEXT)') | |
| 157 | setConfig({ dialect: 'sqlite', database: { database: file } }) | |
| 158 | ||
| 159 | const models = { entries: { columns: { id: { type: 'integer', isPrimaryKey: true }, kind: { type: 'text' }, subject_text: { type: 'text' }, previous_text: { type: 'text' } } } } | |
| 160 | const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } }) | |
| 161 | const ROWS = ${JSON.stringify(ROWS)} | |
| 162 | const failures = [] | |
| 163 | ||
| 164 | async function check(label, run) { | |
| 165 | new Database(file).run('DELETE FROM entries') | |
| 166 | await run() | |
| 167 | const rows = new Database(file).query('SELECT kind, previous_text FROM entries ORDER BY id').all() | |
| 168 | if (rows.length !== 2) | |
| 169 | failures.push(label + ': expected 2 rows, got ' + rows.length) | |
| 170 | // The count was always right. This is the assertion the bug survived. | |
| 171 | if (rows[1]?.previous_text !== 'SHOULD PERSIST') | |
| 172 | failures.push(label + ': previous_text = ' + JSON.stringify(rows[1]?.previous_text)) | |
| 173 | if (rows[0]?.previous_text !== null) | |
| 174 | failures.push(label + ': row 0 should be null, got ' + JSON.stringify(rows[0]?.previous_text)) | |
| 175 | } | |
| 176 | ||
| 177 | await check('values()', () => db.insertInto('entries').values(ROWS).execute()) | |
| 178 | await check('createMany()', () => db.createMany('entries', ROWS)) | |
| 179 | await check('insertMany()', () => db.insertMany('entries', ROWS)) | |
| 180 | ||
| 181 | if (failures.length) { | |
| 182 | console.error(failures.join('\\n')) | |
| 183 | process.exit(1) | |
| 184 | } | |
| 185 | console.log('OK') | |
| 186 | `) | |
| 187 | ||
| 188 | expect(output).toContain('OK') | |
| 189 | expect(code).toBe(0) | |
| 190 | }, 30_000) | |
| 191 | ||
| 192 | it.skipIf(!pgAvailable)('takes the column default, not NULL, for the gap on Postgres', () => { | |
| 193 | // Only observable against a real server: `note` is NOT NULL with a default | |
| 194 | // and is set on the second row only, so it IS in the union and row 1 has to | |
| 195 | // fill it. DEFAULT stores the default; a bound NULL would fail the NOT NULL | |
| 196 | // constraint outright. | |
| 197 | const { code, output } = runProbe(` | |
| 198 | import { SQL } from 'bun' | |
| 199 | import { setConfig, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(SRC)} | |
| 200 | ||
| 201 | const URL = ${JSON.stringify(PG_URL)} | |
| 202 | const raw = new SQL(URL) | |
| 203 | const T = 't1189_' + Date.now() | |
| 204 | await raw.unsafe('CREATE TABLE ' + T + ' (id serial primary key, kind text, note text NOT NULL DEFAULT \\'from-column-default\\')') | |
| 205 | ||
| 206 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 207 | const models = { [T]: { columns: { id: { type: 'integer', isPrimaryKey: true }, kind: { type: 'text' }, note: { type: 'text' } } } } | |
| 208 | const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } }) | |
| 209 | const failures = [] | |
| 210 | ||
| 211 | try { | |
| 212 | await db.insertInto(T).values([{ kind: 'without-note' }, { kind: 'with-note', note: 'explicit' }]).execute() | |
| 213 | const rows = await raw.unsafe('SELECT kind, note FROM ' + T + ' ORDER BY id') | |
| 214 | if (rows[0]?.note !== 'from-column-default') | |
| 215 | failures.push('row 0 note = ' + JSON.stringify(rows[0]?.note) + ' (expected the column default)') | |
| 216 | if (rows[1]?.note !== 'explicit') | |
| 217 | failures.push('row 1 note = ' + JSON.stringify(rows[1]?.note)) | |
| 218 | } | |
| 219 | catch (error) { | |
| 220 | failures.push('insert threw: ' + error.message) | |
| 221 | } | |
| 222 | finally { | |
| 223 | await raw.unsafe('DROP TABLE IF EXISTS ' + T) | |
| 224 | await raw.end() | |
| 225 | } | |
| 226 | ||
| 227 | if (failures.length) { | |
| 228 | console.error(failures.join('\\n')) | |
| 229 | process.exit(1) | |
| 230 | } | |
| 231 | console.log('OK') | |
| 232 | `) | |
| 233 | ||
| 234 | expect(output).toContain('OK') | |
| 235 | expect(code).toBe(0) | |
| 236 | }, 30_000) | |
| 237 | }) | |