ReviewOS

also looking at this

stacks/bun-query-builder

fix: a multi-row insert names every column any row carries (#1189)

#1190
Merged glennmichael123 wants to merge fix/multi-row-insert-column-union into main
3 files +384 -11
packages/bun-query-builder/test/client.insert-heterogeneous-rows.test.tsadded+237-0
Changes to packages/bun-query-builder/test/client.insert-heterogeneous-rows.test.ts
@@ -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
21import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
22import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
23import { tmpdir } from 'node:os'
24import { join, resolve } from 'node:path'
25import process from 'node:process'
26import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src'
27import { config } from '../src/config'
28import { PG_URL, probePostgres } from './pg'
29
30const pgAvailable = await probePostgres()
31const SRC = resolve(import.meta.dir, '../src/index.ts')
32
33function 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. */
52const ROWS = [
53 { kind: 'labeled', subject_text: 'first-row-only' },
54 { kind: 'renamed', subject_text: 'second', previous_text: 'SHOULD PERSIST' },
55]
56
57describe('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 */
129function 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
146describe('the value reaches the table (#1189)', () => {
147 it('persists through every multi-row entry point on SQLite', () => {
148 const { code, output } = runProbe(`
149import { Database } from 'bun:sqlite'
150import { setConfig, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(SRC)}
151import { mkdtempSync } from 'node:fs'
152import { tmpdir } from 'node:os'
153import { join } from 'node:path'
154
155const file = join(mkdtempSync(join(tmpdir(), 'qb-1189-db-')), 'r.db')
156new Database(file).run('CREATE TABLE entries (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, subject_text TEXT, previous_text TEXT)')
157setConfig({ dialect: 'sqlite', database: { database: file } })
158
159const models = { entries: { columns: { id: { type: 'integer', isPrimaryKey: true }, kind: { type: 'text' }, subject_text: { type: 'text' }, previous_text: { type: 'text' } } } }
160const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } })
161const ROWS = ${JSON.stringify(ROWS)}
162const failures = []
163
164async 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
177await check('values()', () => db.insertInto('entries').values(ROWS).execute())
178await check('createMany()', () => db.createMany('entries', ROWS))
179await check('insertMany()', () => db.insertMany('entries', ROWS))
180
181if (failures.length) {
182 console.error(failures.join('\\n'))
183 process.exit(1)
184}
185console.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(`
198import { SQL } from 'bun'
199import { setConfig, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(SRC)}
200
201const URL = ${JSON.stringify(PG_URL)}
202const raw = new SQL(URL)
203const T = 't1189_' + Date.now()
204await raw.unsafe('CREATE TABLE ' + T + ' (id serial primary key, kind text, note text NOT NULL DEFAULT \\'from-column-default\\')')
205
206setConfig({ dialect: 'postgres', database: { url: URL } })
207const models = { [T]: { columns: { id: { type: 'integer', isPrimaryKey: true }, kind: { type: 'text' }, note: { type: 'text' } } } }
208const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } })
209const failures = []
210
211try {
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}
219catch (error) {
220 failures.push('insert threw: ' + error.message)
221}
222finally {
223 await raw.unsafe('DROP TABLE IF EXISTS ' + T)
224 await raw.end()
225}
226
227if (failures.length) {
228 console.error(failures.join('\\n'))
229 process.exit(1)
230}
231console.log('OK')
232`)
233
234 expect(output).toContain('OK')
235 expect(code).toBe(0)
236 }, 30_000)
237})