also looking at this
fix: a multi-row insert names every column any row carries (#1189)
#1190
3 files
+384
-11
| @@ -611,6 +611,33 @@ Two consequences worth knowing: | ||
| 611 | 611 | |
| 612 | 612 | If you want the server to enforce the instant and your dates stay before 2038, declare the column `TIMESTAMP` in a hand-written migration rather than through a model. |
| 613 | 613 | |
| 614 | ### A multi-row insert with uneven rows loses column defaults on SQLite | |
| 615 | ||
| 616 | A multi-row insert names the union of every row's keys, so a column only some rows set is still written for the rows that set it: | |
| 617 | ||
| 618 | ```ts | |
| 619 | await db.insertInto('timeline_entries').values([ | |
| 620 | { kind: 'labeled', subject_text: 'first' }, | |
| 621 | { kind: 'renamed', subject_text: 'second', previous_text: 'kept' }, | |
| 622 | ]).execute() | |
| 623 | ``` | |
| 624 | ||
| 625 | A row that lacks one of those columns has to put *something* in its tuple, and that is where the dialects differ: | |
| 626 | ||
| 627 | | dialect | emitted for the gap | effect | | |
| 628 | | --- | --- | --- | | |
| 629 | | Postgres, MySQL | `DEFAULT` | the column's own default applies | | |
| 630 | | SQLite | `NULL` | the column default is **bypassed** | | |
| 631 | ||
| 632 | SQLite has no `DEFAULT` keyword inside a `VALUES` list — it answers `near "DEFAULT": syntax error` — so there is nothing to emit that means "use the default". A column with a non-null default therefore stores `NULL` for the rows that omitted it, and a `NOT NULL` column with a default fails the insert. | |
| 633 | ||
| 634 | This only bites when the rows are uneven *and* the missing column has a default. Two ways out, both explicit: | |
| 635 | ||
| 636 | - set the key on every row, so the batch is uniform and nothing has to be filled in | |
| 637 | - group the rows by shape and insert each group separately, so a column no row sets is simply not named | |
| 638 | ||
| 639 | Uniform batches are unaffected on every dialect, and so is the single-row form. | |
| 640 | ||
| 614 | 641 | ## Performance Tips Per Dialect |
| 615 | 642 | |
| 616 | 643 | - Postgres: leverage `EXPLAIN (ANALYZE, BUFFERS)` during tuning; create appropriate indexes for JSON paths if needed |
| @@ -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 |
| @@ -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 | }) | |