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/src/client.tsmodified+120-11
Changes to packages/bun-query-builder/src/client.ts
@@ -263,11 +263,13 @@ function quoteInsertIdent(id: string): string {
263263 * stacksjs/bun-query-builder#1052.
264264 */
265265function buildInsertClause(rows: Record<string, any>[], startIndex = 1): { colsSql: string, valuesSql: string, params: any[], nextIndex: number } {
266 const cols = Object.keys(rows[0] ?? {})
266 const { keys: cols, uniform } = insertColumns(rows)
267267 const params: any[] = []
268268 let idx = startIndex
269269 const tuples = rows.map((row) => {
270270 const phs = cols.map((c) => {
271 if (!uniform && !(c in row))
272 return absentCellSql()
271273 params.push(row[c])
272274 return getPlaceholder(idx++)
273275 })
@@ -281,6 +283,73 @@ function buildInsertClause(rows: Record<string, any>[], startIndex = 1): { colsS
281283 }
282284}
283285
286/**
287 * The columns a multi-row INSERT has to name, and whether every row has them.
288 *
289 * The column list used to come from `Object.keys(rows[0])`, and every row was
290 * then read through it as `row[keys[c]]`. A key that appeared only in a later
291 * row had nowhere to go, so its value was dropped from the statement - from
292 * every row that carried it - with no error and the full row count reported.
293 *
294 * That shape is the realistic one, not a corner case: a timeline where a
295 * `renamed` entry carries two titles and a `labeled` entry carries one, an
296 * import where some records set an optional field, an audit log where only some
297 * events carry a reference. The reporter lost five cross-references out of forty
298 * seeded rows and found it only by running `count(reference_number)` afterwards,
299 * because nothing threw and the table was not empty.
300 * stacksjs/bun-query-builder#1189
301 *
302 * So the union of every row's keys, in first-appearance order. `uniform` says
303 * whether the callers' fast paths still apply: when every row has every column,
304 * which is the common case, nothing about the emitted statement changes and no
305 * per-cell presence check runs.
306 */
307function insertColumns(rows: Record<string, any>[]): { keys: string[], uniform: boolean } {
308 const keys = Object.keys(rows[0] ?? {})
309 if (rows.length < 2)
310 return { keys, uniform: true }
311
312 const seen = new Set(keys)
313 let uniform = true
314
315 for (let r = 1; r < rows.length; r++) {
316 const rowKeys = Object.keys(rows[r])
317 // Safe to compare against the union as it stands: the only way it grows is
318 // a key row 0 lacked, and that sets `uniform` false on the spot. So either
319 // this comparison is against the final list, or the flag is already false.
320 if (rowKeys.length !== keys.length)
321 uniform = false
322 for (const key of rowKeys) {
323 if (!seen.has(key)) {
324 seen.add(key)
325 keys.push(key)
326 uniform = false
327 }
328 }
329 }
330
331 return { keys, uniform }
332}
333
334/**
335 * What a row that lacks one of the batch's columns puts in its VALUES tuple.
336 *
337 * `DEFAULT` rather than a bound NULL, so the column's own default still applies
338 * - a timestamp default, a sequence, a non-null default would all be overridden
339 * by writing NULL, and a NOT NULL column with a default would reject the row
340 * outright. It consumes no placeholder, which is why callers advance their
341 * parameter index only on bound cells.
342 *
343 * SQLite has no `DEFAULT` keyword inside a VALUES list (`near "DEFAULT": syntax
344 * error`), so there it binds NULL and the column default is lost. That is a
345 * real difference between dialects and it is documented in `docs/`, but it is
346 * still the value the caller asked for reaching the right column, where before
347 * it reached nothing at all.
348 */
349function absentCellSql(): string {
350 return config.dialect === 'sqlite' || config.dialect === 'browser' ? 'NULL' : 'DEFAULT'
351}
352
284353/** Options shared by the generalized window functions (#1050). */
285354interface WindowOpts {
286355 partitionBy?: string | string[]
@@ -7642,7 +7711,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
76427711 noRows = false
76437712
76447713 const firstRow = rows[0]
7645 const keys = Object.keys(firstRow)
7714 // The union across every row, not just the first one's keys (#1189).
7715 const { keys, uniform } = insertColumns(rows)
76467716 const colCount = keys.length
76477717
76487718 // Pre-allocate params array
@@ -7713,11 +7783,22 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
77137783 for (let c = 0; c < colCount; c++) {
77147784 if (c > 0)
77157785 sqlText += ','
7786 // A row missing this column contributes DEFAULT (or NULL on
7787 // SQLite) and binds nothing, so `pidx` only advances on cells
7788 // that actually carry a parameter.
7789 if (!uniform && !(keys[c] in row)) {
7790 sqlText += absentCellSql()
7791 continue
7792 }
77167793 sqlText += getPlaceholder(pidx)
77177794 params[pidx++] = row[keys[c]]
77187795 }
77197796 }
77207797 sqlText += ')'
7798 // Pre-allocated for the uniform case; the absent cells bound no
7799 // parameter, so drop the unused tail rather than passing undefined.
7800 if (pidx !== totalParams)
7801 params.length = pidx
77217802 }
77227803
77237804 // Bound both the number of tables and each retained SQL shape. Column
@@ -9005,11 +9086,11 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
90059086 if (!rows?.length)
90069087 return
90079088
9008 const firstRow = rows[0]
9009 const keys = Object.keys(firstRow)
9089 // The union across every row, not just the first one's keys (#1189).
9090 const { keys, uniform } = insertColumns(rows as Record<string, any>[])
90109091 const colCount = keys.length
90119092 const rowCount = rows.length
9012 const params = Array.from({ length: rowCount * colCount })
9093 const params: any[] = []
90139094
90149095 // Quote table + column identifiers (#1033) — MySQL backticks, else
90159096 // double quotes (Postgres/SQLite).
@@ -9017,17 +9098,20 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
90179098 ? (id: string): string => `\`${String(id).replace(/`/g, '``')}\``
90189099 : (id: string): string => `"${String(id).replace(/"/g, '""')}"`
90199100 let sql = `INSERT INTO ${quoteId(String(table))}(${keys.map(quoteId).join(',')})VALUES`
9020 let pidx = 0
90219101 for (let r = 0; r < rowCount; r++) {
90229102 if (r > 0)
90239103 sql += ','
90249104 sql += '('
9025 const row = rows[r]
9105 const row = rows[r] as Record<string, any>
90269106 for (let c = 0; c < colCount; c++) {
90279107 if (c > 0)
90289108 sql += ','
9029 sql += getPlaceholder(pidx + 1)
9030 params[pidx++] = row[keys[c]]
9109 if (!uniform && !(keys[c] in row)) {
9110 sql += absentCellSql()
9111 continue
9112 }
9113 sql += getPlaceholder(params.length + 1)
9114 params.push(row[keys[c]])
90319115 }
90329116 sql += ')'
90339117 }
@@ -9038,8 +9122,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
90389122 if (!rows?.length)
90399123 return
90409124
9041 const firstRow = rows[0]
9042 const keys = Object.keys(firstRow)
9125 // The union across every row, not just the first one's keys (#1189).
9126 const { keys, uniform } = insertColumns(rows as Record<string, any>[])
90439127 const colCount = keys.length
90449128 const rowCount = rows.length
90459129 const totalParams = rowCount * colCount
@@ -9047,6 +9131,31 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
90479131
90489132 // Pre-build a single row placeholder template: (?,?,?,?) or ($1,$2,$3,$4)
90499133 const isPositional = config.dialect === 'postgres'
9134
9135 // Both paths below reuse one shape for every row — the `?` template
9136 // literally, the Postgres one by index arithmetic — which only holds when
9137 // every row carries every column. A heterogeneous batch emits its tuples
9138 // one at a time instead, with DEFAULT (or NULL on SQLite) where a row
9139 // lacks a column.
9140 if (!uniform) {
9141 const tuples = new Array<string>(rowCount)
9142 const bound: any[] = []
9143 for (let r = 0; r < rowCount; r++) {
9144 const row = rows[r] as Record<string, any>
9145 const cells = new Array<string>(colCount)
9146 for (let c = 0; c < colCount; c++) {
9147 if (!(keys[c] in row)) {
9148 cells[c] = absentCellSql()
9149 continue
9150 }
9151 cells[c] = isPositional ? `$${bound.length + 1}` : '?'
9152 bound.push(row[keys[c]])
9153 }
9154 tuples[r] = `(${cells.join(',')})`
9155 }
9156 return prepareQuery(`INSERT INTO ${table}(${keys.join(',')})VALUES${tuples.join(',')}`, bound).execute()
9157 }
9158
90509159 let rowTemplate: string
90519160 if (!isPositional) {
90529161 // SQLite/MySQL: all placeholders are ?, build once and reuse