From 5c1f2da65f91ae209f49fac2ecdd537e20fa6185 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 10:49:57 +0200 Subject: [PATCH 01/11] feat: split big queries into multiple to avoid 'too many sql variables' error --- lib/storage/providers/SQLiteProvider.ts | 42 +++++++++++++++++++------ lib/utils.ts | 20 ++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index d3b6fee67..c4e11cd3e 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -10,6 +10,8 @@ import utils from '../../utils'; import type StorageProvider from './types'; import type {StorageKeyList, StorageKeyValuePair} from './types'; +const SQLITE_MAX_VARIABLE_NUMBER = 32766; + /** * The type of the key-value pair stored in the SQLite database * @property record_key - the key of the record @@ -100,12 +102,24 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - const placeholders = keys.map(() => '?').join(','); - const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; - return provider.store.executeAsync(command, keys).then(({rows}) => { - // eslint-disable-next-line no-underscore-dangle - const result = rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]); - return (result ?? []) as StorageKeyValuePair[]; + if (keys.length === 0) { + return Promise.resolve([]); + } + + const keyChunks = utils.chunkArray(keys, SQLITE_MAX_VARIABLE_NUMBER); + + return Promise.all( + keyChunks.map((keyChunk) => { + const placeholders = keyChunk.map(() => '?').join(','); + const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + return provider.store!.executeAsync(command, keyChunk); + }), + ).then((results) => { + const result = results.flatMap(({rows}) => + // eslint-disable-next-line no-underscore-dangle + rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]) ?? [], + ); + return result as StorageKeyValuePair[]; }); }, setItem(key, value) { @@ -214,9 +228,19 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - const placeholders = keys.map(() => '?').join(','); - const query = `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; - return provider.store.executeAsync(query, keys).then(() => undefined); + if (keys.length === 0) { + return Promise.resolve(); + } + + const keyChunks = utils.chunkArray(keys, SQLITE_MAX_VARIABLE_NUMBER); + + return Promise.all( + keyChunks.map((keyChunk) => { + const placeholders = keyChunk.map(() => '?').join(','); + const query = `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + return provider.store!.executeAsync(query, keyChunk); + }), + ).then(() => undefined); }, clear() { if (!provider.store) { diff --git a/lib/utils.ts b/lib/utils.ts index b469c1acd..287f4cf26 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -322,6 +322,25 @@ function omit(obj: Record, condition: string | string[] return filterObject(obj, condition, false); } +/** + * Splits an array into chunks no larger than maxChunkSize. + */ +function chunkArray(items: readonly T[], maxChunkSize: number): T[][] { + if (items.length === 0) { + return []; + } + + if (items.length <= maxChunkSize) { + return [items as T[]]; + } + + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += maxChunkSize) { + chunks.push(items.slice(i, i + maxChunkSize)); + } + return chunks; +} + export default { fastMerge, isEmptyObject, @@ -330,6 +349,7 @@ export default { checkCompatibilityWithExistingValue, pick, omit, + chunkArray, ONYX_INTERNALS__REPLACE_OBJECT_MARK, }; export type {FastMergeResult, FastMergeReplaceNullPatch, FastMergeOptions}; From 3f81fbd11ce361ea6b21f460cb4c013a2dbffdf9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 11:32:08 +0200 Subject: [PATCH 02/11] feat: dynamically adjust maximum SQL variables based on SQLite compile options --- lib/storage/providers/SQLiteProvider.ts | 41 +++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index c4e11cd3e..500a4e7be 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -10,7 +10,12 @@ import utils from '../../utils'; import type StorageProvider from './types'; import type {StorageKeyList, StorageKeyValuePair} from './types'; -const SQLITE_MAX_VARIABLE_NUMBER = 32766; +/** + * The result of the `PRAGMA compile_options`, which lists SQLite compile-time options + */ +type CompileOptionsResult = { + compile_options: string; +}; /** * The type of the key-value pair stored in the SQLite database @@ -37,6 +42,33 @@ type PageCountResult = { }; const DB_NAME = 'OnyxDB'; +const SQLITE_MAX_VARIABLE_NUMBER = 32766; + +/** SQLite's maximum number of bound parameters per statement, read once from PRAGMA compile_options in init(). */ +let sqliteMaxVariableNumber = SQLITE_MAX_VARIABLE_NUMBER; + +/** + * Parses MAX_VARIABLE_NUMBER from the rows returned by `PRAGMA compile_options`. + */ +function parseMaxVariableNumber(compileOptionsResult: {rows?: {length: number; item: (index: number) => CompileOptionsResult | undefined}}): number { + const rowCount = compileOptionsResult.rows?.length ?? 0; + + for (let index = 0; index < rowCount; index++) { + const compileOption = compileOptionsResult.rows?.item(index)?.compile_options; + + if (!compileOption?.startsWith('MAX_VARIABLE_NUMBER=')) { + continue; + } + + const maxVariableNumber = Number(compileOption.split('=')[1]); + + if (maxVariableNumber > 0) { + return maxVariableNumber; + } + } + + return SQLITE_MAX_VARIABLE_NUMBER; +} /** * Prevents the stringifying of the object markers. @@ -73,6 +105,9 @@ const provider: StorageProvider = { provider.store.execute('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY , valueJSON JSON NOT NULL) WITHOUT ROWID;'); + const compileOptionsResult = provider.store.execute('PRAGMA compile_options;'); + sqliteMaxVariableNumber = parseMaxVariableNumber(compileOptionsResult); + // All of the 3 pragmas below were suggested by SQLite team. // You can find more info about them here: https://www.sqlite.org/pragma.html provider.store.execute('PRAGMA CACHE_SIZE=-20000;'); @@ -106,7 +141,7 @@ const provider: StorageProvider = { return Promise.resolve([]); } - const keyChunks = utils.chunkArray(keys, SQLITE_MAX_VARIABLE_NUMBER); + const keyChunks = utils.chunkArray(keys, sqliteMaxVariableNumber); return Promise.all( keyChunks.map((keyChunk) => { @@ -232,7 +267,7 @@ const provider: StorageProvider = { return Promise.resolve(); } - const keyChunks = utils.chunkArray(keys, SQLITE_MAX_VARIABLE_NUMBER); + const keyChunks = utils.chunkArray(keys, sqliteMaxVariableNumber); return Promise.all( keyChunks.map((keyChunk) => { From b6ea41a34f3c39e38f315164a15e6cd73a8ff0d5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 11:37:00 +0200 Subject: [PATCH 03/11] feat: get `MAX_VARIABLE_NUMBER` dynamically at startup --- lib/storage/providers/SQLiteProvider.ts | 40 ++++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 500a4e7be..19278560c 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -2,7 +2,7 @@ * The SQLiteStorage provider stores everything in a key/value store by * converting the value to a JSON string */ -import type {BatchQueryCommand, NitroSQLiteConnection} from 'react-native-nitro-sqlite'; +import type {BatchQueryCommand, NitroSQLiteConnection, QueryResult} from 'react-native-nitro-sqlite'; import {open} from 'react-native-nitro-sqlite'; import {getFreeDiskStorage} from 'react-native-device-info'; import type {FastMergeReplaceNullPatch} from '../../utils'; @@ -43,31 +43,38 @@ type PageCountResult = { const DB_NAME = 'OnyxDB'; const SQLITE_MAX_VARIABLE_NUMBER = 32766; +const COMPILE_OPTIONS = { + MAX_VARIABLE_NUMBER: 'MAX_VARIABLE_NUMBER', +} as const; /** SQLite's maximum number of bound parameters per statement, read once from PRAGMA compile_options in init(). */ let sqliteMaxVariableNumber = SQLITE_MAX_VARIABLE_NUMBER; /** - * Parses MAX_VARIABLE_NUMBER from the rows returned by `PRAGMA compile_options`. + * Returns the value of a compile option from the rows returned by `PRAGMA compile_options`. + * For flag-only options (e.g. `ENABLE_FTS3`), returns an empty string when the option is present. */ -function parseMaxVariableNumber(compileOptionsResult: {rows?: {length: number; item: (index: number) => CompileOptionsResult | undefined}}): number { +function getCompileOptionValue(compileOptionsResult: QueryResult, optionName: string): string | undefined { + const optionPrefix = `${optionName}=`; const rowCount = compileOptionsResult.rows?.length ?? 0; for (let index = 0; index < rowCount; index++) { const compileOption = compileOptionsResult.rows?.item(index)?.compile_options; - if (!compileOption?.startsWith('MAX_VARIABLE_NUMBER=')) { + if (!compileOption) { continue; } - const maxVariableNumber = Number(compileOption.split('=')[1]); + if (compileOption === optionName) { + return ''; + } - if (maxVariableNumber > 0) { - return maxVariableNumber; + if (compileOption.startsWith(optionPrefix)) { + return compileOption.slice(optionPrefix.length); } } - return SQLITE_MAX_VARIABLE_NUMBER; + return undefined; } /** @@ -105,14 +112,18 @@ const provider: StorageProvider = { provider.store.execute('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY , valueJSON JSON NOT NULL) WITHOUT ROWID;'); - const compileOptionsResult = provider.store.execute('PRAGMA compile_options;'); - sqliteMaxVariableNumber = parseMaxVariableNumber(compileOptionsResult); - // All of the 3 pragmas below were suggested by SQLite team. // You can find more info about them here: https://www.sqlite.org/pragma.html provider.store.execute('PRAGMA CACHE_SIZE=-20000;'); provider.store.execute('PRAGMA synchronous=NORMAL;'); provider.store.execute('PRAGMA journal_mode=WAL;'); + + const compileOptionsResult = provider.store.execute('PRAGMA compile_options;'); + + // Get the value of MAX_VARIABLE_NUMBER from the compile options and + // stores it in a global variable, that is going to be used during runtime. + const maxVariableNumber = Number(getCompileOptionValue(compileOptionsResult, COMPILE_OPTIONS.MAX_VARIABLE_NUMBER)); + sqliteMaxVariableNumber = maxVariableNumber > 0 ? maxVariableNumber : SQLITE_MAX_VARIABLE_NUMBER; }, getItem(key) { if (!provider.store) { @@ -150,9 +161,10 @@ const provider: StorageProvider = { return provider.store!.executeAsync(command, keyChunk); }), ).then((results) => { - const result = results.flatMap(({rows}) => - // eslint-disable-next-line no-underscore-dangle - rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]) ?? [], + const result = results.flatMap( + ({rows}) => + // eslint-disable-next-line no-underscore-dangle + rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]) ?? [], ); return result as StorageKeyValuePair[]; }); From 2023d77aa92adb2738f80de08b506f5061336e3a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 12:39:53 +0200 Subject: [PATCH 04/11] refactor: optimize batch deletion of key-value pairs in SQLiteProvider --- lib/storage/providers/SQLiteProvider.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 19278560c..c6c384f46 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -281,13 +281,22 @@ const provider: StorageProvider = { const keyChunks = utils.chunkArray(keys, sqliteMaxVariableNumber); - return Promise.all( - keyChunks.map((keyChunk) => { - const placeholders = keyChunk.map(() => '?').join(','); - const query = `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; - return provider.store!.executeAsync(query, keyChunk); - }), - ).then(() => undefined); + const buildDeleteQuery = (keyChunk: readonly string[]) => { + const placeholders = keyChunk.map(() => '?').join(','); + return `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + }; + + if (keyChunks.length === 1) { + const keyChunk = keyChunks[0]; + return provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined); + } + + const commands: BatchQueryCommand[] = keyChunks.map((keyChunk) => ({ + query: buildDeleteQuery(keyChunk), + params: [keyChunk], + })); + + return provider.store.executeBatchAsync(commands).then(() => undefined); }, clear() { if (!provider.store) { From e760085b47c5faf827dd20c3fb0a7a8c5998d963 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 13:20:36 +0200 Subject: [PATCH 05/11] fix: pass keys chunk correctly to delete batch query --- lib/storage/providers/SQLiteProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index c6c384f46..a6b21f4d9 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -293,7 +293,7 @@ const provider: StorageProvider = { const commands: BatchQueryCommand[] = keyChunks.map((keyChunk) => ({ query: buildDeleteQuery(keyChunk), - params: [keyChunk], + params: keyChunk, })); return provider.store.executeBatchAsync(commands).then(() => undefined); From b450c3dafadbf388fce122bf9a8a216a7db05f3e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 17:43:02 +0200 Subject: [PATCH 06/11] fix: don't use non-null-assertion --- lib/storage/providers/SQLiteProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index a6b21f4d9..eb9adc07d 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -156,9 +156,13 @@ const provider: StorageProvider = { return Promise.all( keyChunks.map((keyChunk) => { + if (!provider.store) { + throw new Error('Store is not initialized!'); + } + const placeholders = keyChunk.map(() => '?').join(','); const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; - return provider.store!.executeAsync(command, keyChunk); + return provider.store.executeAsync(command, keyChunk); }), ).then((results) => { const result = results.flatMap( From 5a03bad7f1bc171ab00c3e4de8f7d6186f04a245 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 17:45:41 +0200 Subject: [PATCH 07/11] test: add unit tests for chunkArray utility function --- tests/unit/utilsTest.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/utilsTest.ts b/tests/unit/utilsTest.ts index 48e51d598..6d25dabaf 100644 --- a/tests/unit/utilsTest.ts +++ b/tests/unit/utilsTest.ts @@ -390,6 +390,53 @@ describe('utils', () => { }); }); + describe('chunkArray', () => { + it('should return an empty array when given an empty array', () => { + expect(utils.chunkArray([], 3)).toEqual([]); + }); + + it('should return a single chunk when the array length is less than maxChunkSize', () => { + const items = [1, 2]; + const result = utils.chunkArray(items, 3); + + expect(result).toEqual([[1, 2]]); + expect(result[0]).toBe(items); + }); + + it('should return a single chunk when the array length equals maxChunkSize', () => { + const items = [1, 2, 3]; + const result = utils.chunkArray(items, 3); + + expect(result).toEqual([[1, 2, 3]]); + expect(result[0]).toBe(items); + }); + + it('should split the array into evenly sized chunks', () => { + expect(utils.chunkArray([1, 2, 3, 4, 5, 6], 3)).toEqual([ + [1, 2, 3], + [4, 5, 6], + ]); + }); + + it('should include a smaller final chunk when the array length is not divisible by maxChunkSize', () => { + expect(utils.chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it('should create one item per chunk when maxChunkSize is 1', () => { + expect(utils.chunkArray(['a', 'b', 'c'], 1)).toEqual([['a'], ['b'], ['c']]); + }); + + it('should work with readonly arrays and preserve element types', () => { + const items = Object.freeze(['x', 'y', 'z', 'w']) as readonly string[]; + const result = utils.chunkArray(items, 2); + + expect(result).toEqual([ + ['x', 'y'], + ['z', 'w'], + ]); + }); + }); + describe('isEmptyObject', () => { it('should return true for an empty object', () => { expect(utils.isEmptyObject({})).toBe(true); From 912e73576c2a634d519decfa69750b2553935d7b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 18:46:13 +0200 Subject: [PATCH 08/11] test: add unit tests for new `query splitting` logic --- .../storage/providers/SQLiteProviderTest.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index bc0cac00a..321ccf0dd 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -312,6 +312,99 @@ describe('SQLiteProvider', () => { }); }); + describe('query splitting', () => { + const CHUNK_SIZE = 2; + const originalChunkArray = utils.chunkArray; + + const createKeyValueEntries = (count: number): Array<[string, unknown]> => Array.from({length: count}, (_, index) => [`chunk_key_${index}`, index]); + + beforeEach(() => { + resetAllDatabases(); + SQLiteProvider.init(); + + jest.spyOn(utils, 'chunkArray').mockImplementation((items, _maxChunkSize) => originalChunkArray(items, CHUNK_SIZE)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('multiGet', () => { + it('should return all values when keys exceed MAX_VARIABLE_NUMBER', async () => { + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + + const keys = entries.map(([key]) => key); + const result = await SQLiteProvider.multiGet(keys); + + expect(result).toEqual(expect.arrayContaining(entries)); + expect(result).toHaveLength(entries.length); + }); + + it('should issue one IN query per chunk', async () => { + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + + const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync'); + executeAsyncSpy.mockClear(); + + const keys = entries.map(([key]) => key); + await SQLiteProvider.multiGet(keys); + + const inQueries = executeAsyncSpy.mock.calls.filter(([sql]) => typeof sql === 'string' && sql.includes('WHERE record_key IN')); + expect(inQueries).toHaveLength(3); + }); + }); + + describe('removeItems', () => { + it('should remove all keys when keys exceed MAX_VARIABLE_NUMBER', async () => { + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + + const keys = entries.map(([key]) => key); + await SQLiteProvider.removeItems(keys); + + expect(await SQLiteProvider.getAllKeys()).toEqual([]); + }); + + it('should use executeAsync when keys fit in a single chunk', async () => { + const entries = createKeyValueEntries(2); + await SQLiteProvider.multiSet(entries); + + const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync'); + const executeBatchAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeBatchAsync'); + executeAsyncSpy.mockClear(); + executeBatchAsyncSpy.mockClear(); + + const keys = entries.map(([key]) => key); + await SQLiteProvider.removeItems(keys); + + expect(executeAsyncSpy).toHaveBeenCalledTimes(1); + expect(executeBatchAsyncSpy).not.toHaveBeenCalled(); + }); + + it('should use executeBatchAsync when keys span multiple chunks', async () => { + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + + const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync'); + const executeBatchAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeBatchAsync'); + executeAsyncSpy.mockClear(); + executeBatchAsyncSpy.mockClear(); + + const keys = entries.map(([key]) => key); + await SQLiteProvider.removeItems(keys); + + expect(executeAsyncSpy).not.toHaveBeenCalled(); + expect(executeBatchAsyncSpy).toHaveBeenCalledTimes(1); + + const batchCommands = executeBatchAsyncSpy.mock.calls[0][0]; + expect(batchCommands).toHaveLength(3); + expect(batchCommands.every((command) => command.query.includes('DELETE FROM keyvaluepairs WHERE record_key IN'))).toBe(true); + }); + }); + }); + // SQLite-specific: the IN-list is parameterised, so a key containing SQL // fragments must be treated as a literal record_key. describe('SQL-injection safety', () => { From 1f280dbec7b33a0df36fb036d9fe6f3024ed9123 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 19:12:34 +0200 Subject: [PATCH 09/11] fix: improve readability of `multiSet` --- lib/storage/providers/SQLiteProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index eb9adc07d..7b12b7f3f 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -186,7 +186,7 @@ const provider: StorageProvider = { } const query = 'REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'; - const params = pairs.map((pair) => [pair[0], JSON.stringify(pair[1] === undefined ? null : pair[1])]); + const params = pairs.map(([key, value]) => [key, JSON.stringify(value === undefined ? null : value)]); if (utils.isEmptyObject(params)) { return Promise.resolve(); } From a836a7a9993670c2d84802fcb5ab2037a37fd10b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 30 Jun 2026 19:14:44 +0200 Subject: [PATCH 10/11] fix: sqlite mock does not respect single row params in `executeBatch` --- tests/unit/mocks/sqliteMock.ts | 66 +++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 8b5cd1d45..1b95f4c22 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -7,13 +7,13 @@ * - open({name}) * - connection.execute(sql) * - connection.executeAsync(sql, params?) - * - connection.executeBatchAsync([{query, params}]) + * - connection.executeBatchAsync([{query, params}, ...]) * * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. */ import BetterSqlite3 from 'better-sqlite3'; import type {Database} from 'better-sqlite3'; -import type {NitroSQLiteConnection, NitroSQLiteQueryResultRows, QueryResult, QueryResultRow, SQLiteQueryParams} from 'react-native-nitro-sqlite'; +import type {BatchQueryCommand, NitroSQLiteConnection, NitroSQLiteQueryResultRows, QueryResult, QueryResultRow, SQLiteQueryParams} from 'react-native-nitro-sqlite'; // `better-sqlite3` is declared as `export = Database` (CommonJS), so the type is // derived from the default import's namespace rather than via a named type import. @@ -71,6 +71,40 @@ function prepareAndBind(database: Database, sql: string, parameters?: SQLiteQuer return {statement: database.prepare(sql), boundArguments: parameters ?? []}; } +type BatchQuery = { + query: string; + params?: SQLiteQueryParams; +}; + +/** + * Expands batch commands the same way NitroSQLite does in `batchParamsToCommands`: + * `params` is either one binding set for the query, or an array of binding sets + * (same query executed once per row). + */ +function batchParamsToCommands(commands: BatchQueryCommand[]): BatchQuery[] { + const expanded: BatchQuery[] = []; + + for (const command of commands) { + const {query, params} = command; + + if (!params) { + expanded.push({query}); + continue; + } + + if (Array.isArray(params[0])) { + for (const rowParams of params as SQLiteQueryParams[]) { + expanded.push({query, params: rowParams}); + } + continue; + } + + expanded.push({query, params: params as SQLiteQueryParams}); + } + + return expanded; +} + function runOne(database: Database, sql: string, parameters?: SQLiteQueryParams): QueryResult { // Multi-statement (CREATE TABLE; SELECT ...; etc.) — better-sqlite3 cannot // prepare more than one statement at a time. SQLiteProvider's init() issues @@ -120,29 +154,13 @@ function makeConnection(name: string): Pick { - for (const command of commands) { - const namedOrder = extractNamedParameterOrder(command.query); - const statement = connection.prepare(command.query); - const parameterRows = (command.params ?? []) as SQLiteQueryParams[]; - if (parameterRows.length === 0) { - const info = statement.run(); - total += info.changes; - continue; - } - for (const row of parameterRows) { - if (namedOrder) { - const bindings: Record = {}; - for (let index = 0; index < namedOrder.length; index++) { - bindings[namedOrder[index]] = row[index]; - } - const info = statement.run(bindings); - total += info.changes; - } else { - const info = statement.run(...row); - total += info.changes; - } - } + for (const command of expandedCommands) { + const {statement, boundArguments} = prepareAndBind(connection, command.query, command.params); + const info = statement.run(...(boundArguments as unknown[])); + total += info.changes; } })(); return Promise.resolve({rowsAffected: total}); From 33471571097ecc7f2d7354f228d95689c457310f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 2 Jul 2026 12:38:53 +0200 Subject: [PATCH 11/11] refactor: use NitroSQLite types instead of custom ones --- tests/unit/mocks/sqliteMock.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 1b95f4c22..d7282e80b 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -54,7 +54,7 @@ function wrapRows(rowsArray: TRow[]): NitroSQLiteQu }; } -function prepareAndBind(database: Database, sql: string, parameters?: SQLiteQueryParams) { +function prepareAndBind(database: Database, sql: BatchQueryCommand['query'], parameters?: BatchQueryCommand['params']) { const namedOrder = extractNamedParameterOrder(sql); if (namedOrder) { // Map positional parameters array to named bindings object — NitroSQLite's @@ -71,18 +71,13 @@ function prepareAndBind(database: Database, sql: string, parameters?: SQLiteQuer return {statement: database.prepare(sql), boundArguments: parameters ?? []}; } -type BatchQuery = { - query: string; - params?: SQLiteQueryParams; -}; - /** * Expands batch commands the same way NitroSQLite does in `batchParamsToCommands`: * `params` is either one binding set for the query, or an array of binding sets * (same query executed once per row). */ -function batchParamsToCommands(commands: BatchQueryCommand[]): BatchQuery[] { - const expanded: BatchQuery[] = []; +function batchParamsToCommands(commands: BatchQueryCommand[]): BatchQueryCommand[] { + const expanded: BatchQueryCommand[] = []; for (const command of commands) { const {query, params} = command;