also looking at this
fix(client): an empty insert is a no-op, not a stand-in query
#1107
2 files
+244
-1
| @@ -6657,6 +6657,50 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6657 | 6657 | let built: any |
| 6658 | 6658 | let sqlText = '' |
| 6659 | 6659 | const params: any[] = [] |
| 6660 | // Set when `values()` is handed an empty batch. There is no statement to | |
| 6661 | // run in that case, so every terminal below answers from here instead of | |
| 6662 | // executing a stand-in query. See #1097. | |
| 6663 | let noRows = false | |
| 6664 | ||
| 6665 | /** | |
| 6666 | * What the dialect returns for an insert that affected no rows. | |
| 6667 | * | |
| 6668 | * Postgres gives `[]` for an insert without RETURNING, so an empty batch | |
| 6669 | * is indistinguishable from a real one that inserted nothing — which is | |
| 6670 | * the point. The bun:sqlite wrapper reports `{ changes, lastInsertRowid }` | |
| 6671 | * instead, so it gets zeros. | |
| 6672 | */ | |
| 6673 | const emptyInsertResult = (): any => (isPostgres ? [] : { changes: 0, lastInsertRowid: 0 }) | |
| 6674 | ||
| 6675 | /** | |
| 6676 | * The `.returning(...)` / `.returningAll()` surface for an empty batch. | |
| 6677 | * | |
| 6678 | * Mirrors the real one's method set so `.returning('id').first()` still | |
| 6679 | * resolves rather than throwing "first is not a function". No rows were | |
| 6680 | * inserted, so the row accessors answer empty and the *OrFail pair throws | |
| 6681 | * the same message they throw when a real RETURNING comes back empty. | |
| 6682 | */ | |
| 6683 | const emptyReturningBuilder = (): any => { | |
| 6684 | const noRow = async (): Promise<any> => undefined | |
| 6685 | const noRowOrFail = async (): Promise<any> => { | |
| 6686 | throw new Error('Insert with RETURNING returned no rows') | |
| 6687 | } | |
| 6688 | return { | |
| 6689 | where: () => emptyReturningBuilder(), | |
| 6690 | andWhere: () => emptyReturningBuilder(), | |
| 6691 | orWhere: () => emptyReturningBuilder(), | |
| 6692 | orderBy: () => emptyReturningBuilder(), | |
| 6693 | limit: () => emptyReturningBuilder(), | |
| 6694 | offset: () => emptyReturningBuilder(), | |
| 6695 | toSQL: () => makeExecutableQuery(null as any, '') as any, | |
| 6696 | execute: async () => [], | |
| 6697 | get: async () => [], | |
| 6698 | first: noRow, | |
| 6699 | executeTakeFirst: noRow, | |
| 6700 | firstOrFail: noRowOrFail, | |
| 6701 | executeTakeFirstOrThrow: noRowOrFail, | |
| 6702 | } | |
| 6703 | } | |
| 6660 | 6704 | const isPostgres = config.dialect === 'postgres' |
| 6661 | 6705 | |
| 6662 | 6706 | // Quote identifier based on dialect. SQLite supports double-quoted |
| @@ -6683,9 +6727,24 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6683 | 6727 | const rows = Array.isArray(data) ? data : [data] |
| 6684 | 6728 | const rowCount = rows.length |
| 6685 | 6729 | if (rowCount === 0) { |
| 6686 | built = _sql.unsafe('SELECT 1') | |
| 6730 | // Inserting no rows is a no-op, not a query. This used to run | |
| 6731 | // `SELECT 1` as a stand-in, which meant `.execute()` resolved to a | |
| 6732 | // fabricated row — `[{ '?column?': 1 }]` on Postgres, `[{ '1': 1 }]` | |
| 6733 | // on SQLite — so a caller checking `result.length` saw 1 where the | |
| 6734 | // truth was 0, a query hook fired for an insert that never | |
| 6735 | // happened, and `.returning(...)` appended RETURNING to `SELECT 1` | |
| 6736 | // and died on a syntax error. See #1097. | |
| 6737 | // | |
| 6738 | // `values(rows)` where `rows` came back empty is ordinary calling | |
| 6739 | // code — the seeder scaffold this package generates is written that | |
| 6740 | // way — so this answers empty rather than throwing. | |
| 6741 | noRows = true | |
| 6742 | built = null | |
| 6743 | sqlText = '' | |
| 6744 | params.length = 0 | |
| 6687 | 6745 | return this |
| 6688 | 6746 | } |
| 6747 | noRows = false | |
| 6689 | 6748 | |
| 6690 | 6749 | const firstRow = rows[0] |
| 6691 | 6750 | const keys = Object.keys(firstRow) |
| @@ -6760,6 +6819,11 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6760 | 6819 | return this |
| 6761 | 6820 | }, |
| 6762 | 6821 | returning(...cols: (keyof any & string)[]) { |
| 6822 | // Nothing was inserted, so there is nothing to return. Without this | |
| 6823 | // the RETURNING clause was appended to the `SELECT 1` stand-in and | |
| 6824 | // the statement failed to parse. | |
| 6825 | if (noRows) | |
| 6826 | return emptyReturningBuilder() | |
| 6763 | 6827 | // Append RETURNING clause to the existing SQL |
| 6764 | 6828 | const returningSql = `${sqlText} RETURNING ${cols.join(', ')}` |
| 6765 | 6829 | const q = _sql.unsafe(returningSql, params) |
| @@ -6798,10 +6862,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6798 | 6862 | } |
| 6799 | 6863 | }, |
| 6800 | 6864 | toSQL() { |
| 6865 | if (noRows) | |
| 6866 | return makeExecutableQuery(null as any, '') as any | |
| 6801 | 6867 | if (!built) built = _sql.unsafe(sqlText, params) |
| 6802 | 6868 | return makeExecutableQuery(built, sqlText) as any |
| 6803 | 6869 | }, |
| 6804 | 6870 | execute() { |
| 6871 | if (noRows) | |
| 6872 | return Promise.resolve(emptyInsertResult()) | |
| 6805 | 6873 | // Ultra-fast path: use _prepareStatement to skip unsafe() and runWithHooks overhead |
| 6806 | 6874 | const hooks = activeHooks() |
| 6807 | 6875 | const hasHooks = hooks && (hooks.onQueryStart || hooks.onQueryEnd || hooks.onQueryError || hooks.startSpan || hooks.beforeCreate || hooks.afterCreate || hasSlowQueryHook(hooks)) |
| @@ -6816,11 +6884,15 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6816 | 6884 | return runWithHooks(built, 'insert') |
| 6817 | 6885 | }, |
| 6818 | 6886 | async executeTakeFirst() { |
| 6887 | if (noRows) | |
| 6888 | return emptyInsertResult() | |
| 6819 | 6889 | if (!built) built = _sql.unsafe(sqlText, params) |
| 6820 | 6890 | const result = await runWithHooks(built, 'insert') |
| 6821 | 6891 | return result |
| 6822 | 6892 | }, |
| 6823 | 6893 | async executeTakeFirstOrThrow() { |
| 6894 | if (noRows) | |
| 6895 | throw new Error('Insert failed') | |
| 6824 | 6896 | if (!built) built = _sql.unsafe(sqlText, params) |
| 6825 | 6897 | const result = await runWithHooks(built, 'insert') |
| 6826 | 6898 | if (!result) |
| @@ -6828,6 +6900,9 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6828 | 6900 | return result |
| 6829 | 6901 | }, |
| 6830 | 6902 | returningAll() { |
| 6903 | // As in returning(): no rows inserted, nothing to return. | |
| 6904 | if (noRows) | |
| 6905 | return emptyReturningBuilder() | |
| 6831 | 6906 | const returningSql = `${sqlText} RETURNING *` |
| 6832 | 6907 | const q = _sql.unsafe(returningSql, params) |
| 6833 | 6908 | const runFirst = async () => { |
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * `insertInto(t).values([])` must be a no-op, not a stand-in query. | |
| 3 | * stacksjs/bun-query-builder#1097. | |
| 4 | * | |
| 5 | * It ran `SELECT 1` when handed an empty batch, which is invisible in the | |
| 6 | * database — no row is ever written — but wrong at every other layer: | |
| 7 | * | |
| 8 | * - `.execute()` resolved to a FABRICATED row: `[{ '?column?': 1 }]` on | |
| 9 | * Postgres, `[{ '1': 1 }]` on SQLite. A caller checking `result.length` saw | |
| 10 | * 1 where the truth was 0. | |
| 11 | * - a query hook fired, labelled `insert`, for an insert that never happened. | |
| 12 | * - `.returning(...)` appended RETURNING to `SELECT 1` and died with | |
| 13 | * `near "RETURNING": syntax error`. | |
| 14 | * | |
| 15 | * It answers empty rather than throwing because `values(rows)` where `rows` | |
| 16 | * came back empty is ordinary calling code — the seeder scaffold this package | |
| 17 | * generates (`actions/seed.ts`) is written exactly that way, so a throw would | |
| 18 | * turn an empty dataset into a failed seed. | |
| 19 | */ | |
| 20 | ||
| 21 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 22 | import { tmpdir } from 'node:os' | |
| 23 | import { join } from 'node:path' | |
| 24 | import { Database } from 'bun:sqlite' | |
| 25 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 26 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 27 | import { config, setConfig } from '../src/config' | |
| 28 | import { resetConnection } from '../src/db' | |
| 29 | ||
| 30 | const MODELS = { | |
| 31 | t: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' } } }, | |
| 32 | } as any | |
| 33 | ||
| 34 | // A file rather than `:memory:`: the client opens its own connection from the | |
| 35 | // configured path, and a `:memory:` database is per-connection, so the table | |
| 36 | // created here would be invisible to it. | |
| 37 | let dir: string | |
| 38 | let dbPath: string | |
| 39 | let snapshot: { dialect: string, database: Record<string, unknown>, hooks: unknown } | |
| 40 | ||
| 41 | function qb(): any { | |
| 42 | return createQueryBuilder({ | |
| 43 | schema: buildDatabaseSchema(MODELS), | |
| 44 | meta: buildSchemaMeta(MODELS), | |
| 45 | autoMigration: { enabled: false } as any, | |
| 46 | }) | |
| 47 | } | |
| 48 | ||
| 49 | /** Row count read through a fresh connection, not the builder's. */ | |
| 50 | function rowCount(): number { | |
| 51 | const probe = new Database(dbPath) | |
| 52 | const [{ c }] = probe.query('SELECT count(*) c FROM t').all() as any[] | |
| 53 | probe.close() | |
| 54 | return c | |
| 55 | } | |
| 56 | ||
| 57 | beforeEach(() => { | |
| 58 | snapshot = { dialect: config.dialect, database: { ...config.database }, hooks: (config as any).hooks } | |
| 59 | dir = mkdtempSync(join(tmpdir(), 'qb-1097-')) | |
| 60 | dbPath = join(dir, 'empty-values.sqlite') | |
| 61 | const seed = new Database(dbPath, { create: true }) | |
| 62 | seed.run('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)') | |
| 63 | seed.close() | |
| 64 | setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any) | |
| 65 | resetConnection() | |
| 66 | }) | |
| 67 | ||
| 68 | afterEach(() => { | |
| 69 | config.dialect = snapshot.dialect as any | |
| 70 | for (const k of Object.keys(config.database)) delete (config.database as any)[k] | |
| 71 | Object.assign(config.database, snapshot.database) | |
| 72 | ;(config as any).hooks = snapshot.hooks | |
| 73 | resetConnection() | |
| 74 | rmSync(dir, { recursive: true, force: true }) | |
| 75 | }) | |
| 76 | ||
| 77 | describe('insertInto().values([]) is a no-op (#1097)', () => { | |
| 78 | it('reports nothing inserted instead of a fabricated row', async () => { | |
| 79 | const result: any = await qb().insertInto('t').values([]).execute() | |
| 80 | ||
| 81 | // The bun:sqlite wrapper reports { changes, lastInsertRowid }; zeros are | |
| 82 | // the honest answer. What must never come back is a row. | |
| 83 | expect(Array.isArray(result)).toBe(false) | |
| 84 | expect(result.changes).toBe(0) | |
| 85 | ||
| 86 | expect(rowCount()).toBe(0) | |
| 87 | }) | |
| 88 | ||
| 89 | it('does not run a query, so no hook fires', async () => { | |
| 90 | const seen: string[] = [] | |
| 91 | setConfig({ hooks: { onQueryStart: () => { seen.push('start') } } } as any) | |
| 92 | ||
| 93 | await qb().insertInto('t').values([]).execute() | |
| 94 | ||
| 95 | expect(seen).toEqual([]) | |
| 96 | }) | |
| 97 | ||
| 98 | it('emits no SQL', () => { | |
| 99 | const sql = qb().insertInto('t').values([]).toSQL() | |
| 100 | expect(String(sql?.sql ?? '')).toBe('') | |
| 101 | }) | |
| 102 | ||
| 103 | describe('returning()', () => { | |
| 104 | it('resolves empty instead of failing to parse', async () => { | |
| 105 | // Previously: `near "RETURNING": syntax error`, because RETURNING was | |
| 106 | // appended to the `SELECT 1` stand-in. | |
| 107 | const rows = await qb().insertInto('t').values([]).returning('id').execute() | |
| 108 | expect(rows).toEqual([]) | |
| 109 | }) | |
| 110 | ||
| 111 | it('keeps the row accessors present and empty', async () => { | |
| 112 | const b = qb().insertInto('t').values([]).returning('id') | |
| 113 | ||
| 114 | // `.returning(...).first()` exists on the real builder, so it has to | |
| 115 | // exist here too rather than throwing "first is not a function". | |
| 116 | expect(await b.get()).toEqual([]) | |
| 117 | expect(await b.first()).toBeUndefined() | |
| 118 | expect(await b.executeTakeFirst()).toBeUndefined() | |
| 119 | await expect(b.firstOrFail()).rejects.toThrow(/returned no rows/) | |
| 120 | }) | |
| 121 | ||
| 122 | it('behaves the same through returningAll()', async () => { | |
| 123 | const rows = await qb().insertInto('t').values([]).returningAll().execute() | |
| 124 | expect(rows).toEqual([]) | |
| 125 | }) | |
| 126 | }) | |
| 127 | ||
| 128 | it('leaves a non-empty insert alone', async () => { | |
| 129 | const seen: string[] = [] | |
| 130 | setConfig({ hooks: { onQueryStart: () => { seen.push('start') } } } as any) | |
| 131 | ||
| 132 | const result: any = await qb().insertInto('t').values([{ name: 'a' }]).execute() | |
| 133 | expect(result.changes).toBe(1) | |
| 134 | expect(seen.length).toBe(1) | |
| 135 | ||
| 136 | const rows = await qb().insertInto('t').values([{ name: 'b' }]).returning('id').execute() | |
| 137 | expect(rows).toHaveLength(1) | |
| 138 | ||
| 139 | expect(rowCount()).toBe(2) | |
| 140 | }) | |
| 141 | ||
| 142 | it('reuses the builder correctly when a later call has rows', async () => { | |
| 143 | // The empty batch sets a flag; a subsequent values() with rows has to clear | |
| 144 | // it, or the second insert would silently do nothing. | |
| 145 | const b = qb().insertInto('t') | |
| 146 | b.values([]) | |
| 147 | b.values([{ name: 'later' }]) | |
| 148 | const result: any = await b.execute() | |
| 149 | ||
| 150 | expect(result.changes).toBe(1) | |
| 151 | expect(rowCount()).toBe(1) | |
| 152 | }) | |
| 153 | }) | |
| 154 | ||
| 155 | describe('insertInto().values([]) on Postgres (#1097)', () => { | |
| 156 | it('resolves to [] — what a real insert affecting no rows returns', async () => { | |
| 157 | // No connection is opened: the empty batch short-circuits before touching | |
| 158 | // the driver, which is what makes this assertable without a live server. | |
| 159 | setConfig({ dialect: 'postgres', database: { url: 'postgres://unused:5432/none' } } as any) | |
| 160 | resetConnection() | |
| 161 | ||
| 162 | const result = await qb().insertInto('t').values([]).execute() | |
| 163 | ||
| 164 | // Postgres returns [] for an insert without RETURNING, so an empty batch is | |
| 165 | // deliberately indistinguishable from a real one that inserted nothing. | |
| 166 | expect(result).toEqual([]) | |
| 167 | }) | |
| 168 | }) | |