also looking at this
fix: a multi-row insert names every column any row carries (#1189)
#1190
3 files
+384
-11
| @@ -263,11 +263,13 @@ function quoteInsertIdent(id: string): string { | ||
| 263 | 263 | * stacksjs/bun-query-builder#1052. |
| 264 | 264 | */ |
| 265 | 265 | function 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) | |
| 267 | 267 | const params: any[] = [] |
| 268 | 268 | let idx = startIndex |
| 269 | 269 | const tuples = rows.map((row) => { |
| 270 | 270 | const phs = cols.map((c) => { |
| 271 | if (!uniform && !(c in row)) | |
| 272 | return absentCellSql() | |
| 271 | 273 | params.push(row[c]) |
| 272 | 274 | return getPlaceholder(idx++) |
| 273 | 275 | }) |
| @@ -281,6 +283,73 @@ function buildInsertClause(rows: Record<string, any>[], startIndex = 1): { colsS | ||
| 281 | 283 | } |
| 282 | 284 | } |
| 283 | 285 | |
| 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 | */ | |
| 307 | function 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 | */ | |
| 349 | function absentCellSql(): string { | |
| 350 | return config.dialect === 'sqlite' || config.dialect === 'browser' ? 'NULL' : 'DEFAULT' | |
| 351 | } | |
| 352 | ||
| 284 | 353 | /** Options shared by the generalized window functions (#1050). */ |
| 285 | 354 | interface WindowOpts { |
| 286 | 355 | partitionBy?: string | string[] |
| @@ -7642,7 +7711,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7642 | 7711 | noRows = false |
| 7643 | 7712 | |
| 7644 | 7713 | 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) | |
| 7646 | 7716 | const colCount = keys.length |
| 7647 | 7717 | |
| 7648 | 7718 | // Pre-allocate params array |
| @@ -7713,11 +7783,22 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7713 | 7783 | for (let c = 0; c < colCount; c++) { |
| 7714 | 7784 | if (c > 0) |
| 7715 | 7785 | 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 | } | |
| 7716 | 7793 | sqlText += getPlaceholder(pidx) |
| 7717 | 7794 | params[pidx++] = row[keys[c]] |
| 7718 | 7795 | } |
| 7719 | 7796 | } |
| 7720 | 7797 | 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 | |
| 7721 | 7802 | } |
| 7722 | 7803 | |
| 7723 | 7804 | // Bound both the number of tables and each retained SQL shape. Column |
| @@ -9005,11 +9086,11 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 9005 | 9086 | if (!rows?.length) |
| 9006 | 9087 | return |
| 9007 | 9088 | |
| 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>[]) | |
| 9010 | 9091 | const colCount = keys.length |
| 9011 | 9092 | const rowCount = rows.length |
| 9012 | const params = Array.from({ length: rowCount * colCount }) | |
| 9093 | const params: any[] = [] | |
| 9013 | 9094 | |
| 9014 | 9095 | // Quote table + column identifiers (#1033) — MySQL backticks, else |
| 9015 | 9096 | // double quotes (Postgres/SQLite). |
| @@ -9017,17 +9098,20 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 9017 | 9098 | ? (id: string): string => `\`${String(id).replace(/`/g, '``')}\`` |
| 9018 | 9099 | : (id: string): string => `"${String(id).replace(/"/g, '""')}"` |
| 9019 | 9100 | let sql = `INSERT INTO ${quoteId(String(table))}(${keys.map(quoteId).join(',')})VALUES` |
| 9020 | let pidx = 0 | |
| 9021 | 9101 | for (let r = 0; r < rowCount; r++) { |
| 9022 | 9102 | if (r > 0) |
| 9023 | 9103 | sql += ',' |
| 9024 | 9104 | sql += '(' |
| 9025 | const row = rows[r] | |
| 9105 | const row = rows[r] as Record<string, any> | |
| 9026 | 9106 | for (let c = 0; c < colCount; c++) { |
| 9027 | 9107 | if (c > 0) |
| 9028 | 9108 | 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]]) | |
| 9031 | 9115 | } |
| 9032 | 9116 | sql += ')' |
| 9033 | 9117 | } |
| @@ -9038,8 +9122,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 9038 | 9122 | if (!rows?.length) |
| 9039 | 9123 | return |
| 9040 | 9124 | |
| 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>[]) | |
| 9043 | 9127 | const colCount = keys.length |
| 9044 | 9128 | const rowCount = rows.length |
| 9045 | 9129 | const totalParams = rowCount * colCount |
| @@ -9047,6 +9131,31 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 9047 | 9131 | |
| 9048 | 9132 | // Pre-build a single row placeholder template: (?,?,?,?) or ($1,$2,$3,$4) |
| 9049 | 9133 | 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 | ||
| 9050 | 9159 | let rowTemplate: string |
| 9051 | 9160 | if (!isPositional) { |
| 9052 | 9161 | // SQLite/MySQL: all placeholders are ?, build once and reuse |