diff --git a/docs/content/docs/guides/parameters-and-results.mdx b/docs/content/docs/guides/parameters-and-results.mdx index 412f0f71..1e1d763f 100644 --- a/docs/content/docs/guides/parameters-and-results.mdx +++ b/docs/content/docs/guides/parameters-and-results.mdx @@ -7,6 +7,8 @@ SQLite parameters let you write SQL with placeholders and supply its values sepa 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. +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. + For repeated executions of the same SQL, [prepare the statement once](/docs/guides/prepared-statements) and pass a new parameter array on each run. ```ts diff --git a/example/tests/unit/specs/operations/execute.spec.ts b/example/tests/unit/specs/operations/execute.spec.ts index 2438e515..868f0a2f 100644 --- a/example/tests/unit/specs/operations/execute.spec.ts +++ b/example/tests/unit/specs/operations/execute.spec.ts @@ -382,6 +382,46 @@ export default function registerExecuteUnitTests() { }) }) + describe('Bind errors', () => { + it('throws when execute receives an extra parameter without exposing it', () => { + const extraParameter = 'do-not-expose-sync-parameter' + + try { + testDb.execute('SELECT ?', [1, extraParameter]) + throw new Error('Expected execute to throw for the extra parameter') + } catch (error: unknown) { + if (!isNitroSQLiteError(error)) { + throw new Error('Should have thrown a valid NitroSQLiteError') + } + + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message).toContain('column index out of range') + expect(error.message.includes(extraParameter)).toBe(false) + } + }) + + it('rejects when executeAsync receives an extra parameter without exposing it', async () => { + const extraParameter = 'do-not-expose-async-parameter' + + try { + await testDb.executeAsync('SELECT ?', [1, extraParameter]) + throw new Error( + 'Expected executeAsync to reject for the extra parameter', + ) + } catch (error: unknown) { + if (!isNitroSQLiteError(error)) { + throw new Error('Should have thrown a valid NitroSQLiteError') + } + + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message).toContain('column index out of range') + expect(error.message.includes(extraParameter)).toBe(false) + } + }) + }) + describe('ArrayBuffer support', () => { describe('execute', () => { it('stores and reads ArrayBuffer values from BLOB columns', () => { diff --git a/example/tests/unit/specs/operations/executeBatch.spec.ts b/example/tests/unit/specs/operations/executeBatch.spec.ts index 50bab1bb..f0cbe930 100644 --- a/example/tests/unit/specs/operations/executeBatch.spec.ts +++ b/example/tests/unit/specs/operations/executeBatch.spec.ts @@ -1,4 +1,4 @@ -import { chance, expect } from '@tests/unit/common' +import { chance, expect, isNitroSQLiteError } from '@tests/unit/common' import { NitroSQLiteError, type BatchQueryCommand, @@ -362,5 +362,80 @@ export default function registerExecuteBatchUnitTests() { { value: 1 }, ]) }) + it('throws when executeBatch receives an extra parameter without exposing it', () => { + const extraParameter = 'do-not-expose-batch-parameter' + + try { + testDb.executeBatch([ + { + query: 'SELECT ?', + params: [1, extraParameter], + }, + ]) + throw new Error( + 'Expected executeBatch to throw for the extra parameter', + ) + } catch (error: unknown) { + if (!isNitroSQLiteError(error)) { + throw new Error('Should have thrown a valid NitroSQLiteError') + } + + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message).toContain('column index out of range') + expect(error.message.includes(extraParameter)).toBe(false) + } + }) + + it('rejects when executeBatchAsync receives an extra parameter without exposing it', async () => { + const extraParameter = 'do-not-expose-batch-async-parameter' + + try { + await testDb.executeBatchAsync([ + { + query: 'SELECT ?', + params: [1, extraParameter], + }, + ]) + throw new Error( + 'Expected executeBatchAsync to reject for the extra parameter', + ) + } catch (error: unknown) { + if (!isNitroSQLiteError(error)) { + throw new Error('Should have thrown a valid NitroSQLiteError') + } + + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message).toContain('column index out of range') + expect(error.message.includes(extraParameter)).toBe(false) + } + }) + it('rolls back grouped writes after a later parameter set fails to bind', async () => { + testDb.execute('CREATE TABLE BindFailureBatch (value TEXT)') + const secret = 'do-not-expose-grouped-parameter' + for (const asynchronous of [false, true]) { + const commands = [ + { + query: 'INSERT INTO BindFailureBatch VALUES (?)', + params: [['rolled back'], ['invalid', secret]], + }, + ] + try { + if (asynchronous) await testDb.executeBatchAsync(commands) + else testDb.executeBatch(commands) + throw new Error('Expected grouped binding to fail') + } catch (error) { + if (!isNitroSQLiteError(error)) throw error + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message.includes(secret)).toBe(false) + } + expect( + testDb.execute('SELECT * FROM BindFailureBatch').rows._array, + ).toEqual([]) + } + testDb.execute('DROP TABLE BindFailureBatch') + }) }) } diff --git a/example/tests/unit/specs/operations/preparedStatement.spec.ts b/example/tests/unit/specs/operations/preparedStatement.spec.ts index 5cfb5a54..ba0585ad 100644 --- a/example/tests/unit/specs/operations/preparedStatement.spec.ts +++ b/example/tests/unit/specs/operations/preparedStatement.spec.ts @@ -9,6 +9,30 @@ import { testDb } from '@tests/db' export default function registerPreparedStatementUnitTests() { describe('prepared statements', () => { + it('reports bind errors and remains reusable for sync and async execution', async () => { + const statement = testDb.prepare('SELECT ? AS value') + const secret = 'do-not-expose-prepared-parameter' + try { + for (const asynchronous of [false, true]) { + try { + if (asynchronous) await statement.executeAsync([1, secret]) + else statement.execute([1, secret]) + throw new Error('Expected prepared binding to fail') + } catch (error) { + if (!isNitroSQLiteError(error)) throw error + expect(error.message).toContain('parameter 2') + expect(error.message).toContain('25') + expect(error.message).toContain('column index out of range') + expect(error.message.includes(secret)).toBe(false) + } + expect(statement.execute([7]).rows._array).toEqual([{ value: 7 }]) + expect(statement.execute([]).rows._array).toEqual([{ value: null }]) + } + } finally { + statement.finalize() + } + }) + it('binds undefined on synchronous and asynchronous execution', async () => { const statement = testDb.prepare('SELECT ? AS missing, ? AS value') diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index e5c6dc34..3b324a1d 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -157,7 +157,8 @@ void bindStatement(sqlite3_stmt* statement, const SQLiteQueryParams& values) { } if (bindStatus != SQLITE_OK) { - throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(sqlite3_db_handle(statement))); + throw NitroSQLiteException::SqlExecution("Failed to bind parameter " + std::to_string(sqliteIndex) + " (SQLite error " + + std::to_string(bindStatus) + "): " + sqlite3_errstr(bindStatus)); } } } diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 04f1d2ec..5ff2b483 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -114,7 +114,12 @@ export type SQLiteValue = | null | undefined -/** Positional values for SQL placeholders. */ +/** Positional values for SQL placeholders. + * Omitted placeholders bind as SQL NULL; an exact count is not required. + * Extra values and other binding failures throw or reject with the one-based + * parameter index and SQLite error code/text, without including parameter values. + * This applies to regular, batch, and prepared statement execution. + */ export type SQLiteQueryParams = SQLiteValue[] /** A row keyed by result column names. */