Skip to content

Commit 2cc28d5

Browse files
fix: include parameter context in SQLite binding errors (#314)
Check every sqlite3_bind result and reject invalid parameter indexes across sync, async, and batch APIs. Closes #309. Co-authored-by: Christoph Pader <chris@margelo.com>
1 parent 46b6e7d commit 2cc28d5

6 files changed

Lines changed: 150 additions & 3 deletions

File tree

‎docs/content/docs/guides/parameters-and-results.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ SQLite parameters let you write SQL with placeholders and supply its values sepa
77

88
In NitroSQLite, pass positional values in an array for `?` placeholders. The public `SQLiteValue` type permits `boolean`, `number`, `string`, `ArrayBuffer`, `null`, and `undefined`. Both `null` and `undefined` bind as SQL NULL. Keep table and column names in your own SQL; parameters bind values, not identifiers.
99

10+
SQLite leaves omitted placeholders as SQL NULL, so NitroSQLite does not require an exact parameter count. Extra values and other binding failures throw or reject with the one-based parameter index and SQLite error code and text. The error does not include parameter values. This behavior applies to synchronous, asynchronous, batch, and prepared statement execution.
11+
1012
For repeated executions of the same SQL, [prepare the statement once](/docs/guides/prepared-statements) and pass a new parameter array on each run.
1113

1214
```ts

‎example/tests/unit/specs/operations/execute.spec.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,46 @@ export default function registerExecuteUnitTests() {
382382
})
383383
})
384384

385+
describe('Bind errors', () => {
386+
it('throws when execute receives an extra parameter without exposing it', () => {
387+
const extraParameter = 'do-not-expose-sync-parameter'
388+
389+
try {
390+
testDb.execute('SELECT ?', [1, extraParameter])
391+
throw new Error('Expected execute to throw for the extra parameter')
392+
} catch (error: unknown) {
393+
if (!isNitroSQLiteError(error)) {
394+
throw new Error('Should have thrown a valid NitroSQLiteError')
395+
}
396+
397+
expect(error.message).toContain('parameter 2')
398+
expect(error.message).toContain('25')
399+
expect(error.message).toContain('column index out of range')
400+
expect(error.message.includes(extraParameter)).toBe(false)
401+
}
402+
})
403+
404+
it('rejects when executeAsync receives an extra parameter without exposing it', async () => {
405+
const extraParameter = 'do-not-expose-async-parameter'
406+
407+
try {
408+
await testDb.executeAsync('SELECT ?', [1, extraParameter])
409+
throw new Error(
410+
'Expected executeAsync to reject for the extra parameter',
411+
)
412+
} catch (error: unknown) {
413+
if (!isNitroSQLiteError(error)) {
414+
throw new Error('Should have thrown a valid NitroSQLiteError')
415+
}
416+
417+
expect(error.message).toContain('parameter 2')
418+
expect(error.message).toContain('25')
419+
expect(error.message).toContain('column index out of range')
420+
expect(error.message.includes(extraParameter)).toBe(false)
421+
}
422+
})
423+
})
424+
385425
describe('ArrayBuffer support', () => {
386426
describe('execute', () => {
387427
it('stores and reads ArrayBuffer values from BLOB columns', () => {

‎example/tests/unit/specs/operations/executeBatch.spec.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chance, expect } from '@tests/unit/common'
1+
import { chance, expect, isNitroSQLiteError } from '@tests/unit/common'
22
import {
33
NitroSQLiteError,
44
type BatchQueryCommand,
@@ -362,5 +362,80 @@ export default function registerExecuteBatchUnitTests() {
362362
{ value: 1 },
363363
])
364364
})
365+
it('throws when executeBatch receives an extra parameter without exposing it', () => {
366+
const extraParameter = 'do-not-expose-batch-parameter'
367+
368+
try {
369+
testDb.executeBatch([
370+
{
371+
query: 'SELECT ?',
372+
params: [1, extraParameter],
373+
},
374+
])
375+
throw new Error(
376+
'Expected executeBatch to throw for the extra parameter',
377+
)
378+
} catch (error: unknown) {
379+
if (!isNitroSQLiteError(error)) {
380+
throw new Error('Should have thrown a valid NitroSQLiteError')
381+
}
382+
383+
expect(error.message).toContain('parameter 2')
384+
expect(error.message).toContain('25')
385+
expect(error.message).toContain('column index out of range')
386+
expect(error.message.includes(extraParameter)).toBe(false)
387+
}
388+
})
389+
390+
it('rejects when executeBatchAsync receives an extra parameter without exposing it', async () => {
391+
const extraParameter = 'do-not-expose-batch-async-parameter'
392+
393+
try {
394+
await testDb.executeBatchAsync([
395+
{
396+
query: 'SELECT ?',
397+
params: [1, extraParameter],
398+
},
399+
])
400+
throw new Error(
401+
'Expected executeBatchAsync to reject for the extra parameter',
402+
)
403+
} catch (error: unknown) {
404+
if (!isNitroSQLiteError(error)) {
405+
throw new Error('Should have thrown a valid NitroSQLiteError')
406+
}
407+
408+
expect(error.message).toContain('parameter 2')
409+
expect(error.message).toContain('25')
410+
expect(error.message).toContain('column index out of range')
411+
expect(error.message.includes(extraParameter)).toBe(false)
412+
}
413+
})
414+
it('rolls back grouped writes after a later parameter set fails to bind', async () => {
415+
testDb.execute('CREATE TABLE BindFailureBatch (value TEXT)')
416+
const secret = 'do-not-expose-grouped-parameter'
417+
for (const asynchronous of [false, true]) {
418+
const commands = [
419+
{
420+
query: 'INSERT INTO BindFailureBatch VALUES (?)',
421+
params: [['rolled back'], ['invalid', secret]],
422+
},
423+
]
424+
try {
425+
if (asynchronous) await testDb.executeBatchAsync(commands)
426+
else testDb.executeBatch(commands)
427+
throw new Error('Expected grouped binding to fail')
428+
} catch (error) {
429+
if (!isNitroSQLiteError(error)) throw error
430+
expect(error.message).toContain('parameter 2')
431+
expect(error.message).toContain('25')
432+
expect(error.message.includes(secret)).toBe(false)
433+
}
434+
expect(
435+
testDb.execute('SELECT * FROM BindFailureBatch').rows._array,
436+
).toEqual([])
437+
}
438+
testDb.execute('DROP TABLE BindFailureBatch')
439+
})
365440
})
366441
}

‎example/tests/unit/specs/operations/preparedStatement.spec.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ import { testDb } from '@tests/db'
99

1010
export default function registerPreparedStatementUnitTests() {
1111
describe('prepared statements', () => {
12+
it('reports bind errors and remains reusable for sync and async execution', async () => {
13+
const statement = testDb.prepare('SELECT ? AS value')
14+
const secret = 'do-not-expose-prepared-parameter'
15+
try {
16+
for (const asynchronous of [false, true]) {
17+
try {
18+
if (asynchronous) await statement.executeAsync([1, secret])
19+
else statement.execute([1, secret])
20+
throw new Error('Expected prepared binding to fail')
21+
} catch (error) {
22+
if (!isNitroSQLiteError(error)) throw error
23+
expect(error.message).toContain('parameter 2')
24+
expect(error.message).toContain('25')
25+
expect(error.message).toContain('column index out of range')
26+
expect(error.message.includes(secret)).toBe(false)
27+
}
28+
expect(statement.execute([7]).rows._array).toEqual([{ value: 7 }])
29+
expect(statement.execute([]).rows._array).toEqual([{ value: null }])
30+
}
31+
} finally {
32+
statement.finalize()
33+
}
34+
})
35+
1236
it('binds undefined on synchronous and asynchronous execution', async () => {
1337
const statement = testDb.prepare('SELECT ? AS missing, ? AS value')
1438

‎packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ void bindStatement(sqlite3_stmt* statement, const SQLiteQueryParams& values) {
144144
}
145145

146146
if (bindStatus != SQLITE_OK) {
147-
throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(sqlite3_db_handle(statement)));
147+
throw NitroSQLiteException::SqlExecution("Failed to bind parameter " + std::to_string(sqliteIndex) + " (SQLite error " +
148+
std::to_string(bindStatus) + "): " + sqlite3_errstr(bindStatus));
148149
}
149150
}
150151
}

‎packages/react-native-nitro-sqlite/src/types.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ export type SQLiteValue =
116116
| null
117117
| undefined
118118

119-
/** Positional values for SQL placeholders. */
119+
/** Positional values for SQL placeholders.
120+
* Omitted placeholders bind as SQL NULL; an exact count is not required.
121+
* Extra values and other binding failures throw or reject with the one-based
122+
* parameter index and SQLite error code/text, without including parameter values.
123+
* This applies to regular, batch, and prepared statement execution.
124+
*/
120125
export type SQLiteQueryParams = SQLiteValue[]
121126

122127
/** A row keyed by result column names. */

0 commit comments

Comments
 (0)