diff --git a/docs/api-package-intros.mjs b/docs/api-package-intros.mjs index 1926ba87..9afbea98 100644 --- a/docs/api-package-intros.mjs +++ b/docs/api-package-intros.mjs @@ -31,6 +31,8 @@ db.close() \`\`\` Use [Getting Started](/docs) for native setup and a fuller first query. The [concepts](/docs/concepts/databases-and-connections) and [guides](/docs/guides/parameters-and-results) explain database lifetime, query parameters, and results. For the Node-only test export and its API, see [Test SQLite in Node](/docs/guides/node-test-mock). +`, + 'react-native-nitro-sqlite/mock': `This Node-only export runs SQLite tests through \`better-sqlite3\` without loading the React Native module. Install \`better-sqlite3\` as a development dependency and import this module in your test setup. See [Test SQLite in Node](/docs/guides/node-test-mock) for Jest setup, shared connections, prepared statements, transactions, and cleanup. `, 'react-native-nitro-sqlite-vec': `This optional package adds [sqlite-vec](https://github.com/asg017/sqlite-vec) vector search to Nitro SQLite. Its native code is compiled into the core package's SQLite build. The functions and types below cover availability checks, vector tables, and nearest-neighbor searches. diff --git a/docs/content/docs/guides/node-test-mock.mdx b/docs/content/docs/guides/node-test-mock.mdx index 63b18f86..89a29fa7 100644 --- a/docs/content/docs/guides/node-test-mock.mdx +++ b/docs/content/docs/guides/node-test-mock.mdx @@ -1,9 +1,11 @@ --- title: Test SQLite in Node -description: Run SQL in Jest tests with the in-memory SQLite mock. +description: Test shared SQLite connections, statements, and transactions in Node. --- -Use `react-native-nitro-sqlite/mock` to run queries and batches in Node tests without loading the React Native module. Install `better-sqlite3` as a development dependency in your test project. The mock keeps each named database in memory, so close connections or reset the databases between tests. Use device tests for file storage or native scheduling. +SQLite connections are handles to a database file. Separate handles can share committed data, and a transaction can keep a consistent snapshot while another connection writes under [WAL](/docs/guides/multiple-connections). + +Use `react-native-nitro-sqlite/mock` to test these behaviors in Node without loading the React Native module. Install `better-sqlite3` as a development dependency in your test project. The mock creates databases in a temporary directory and supports queries, atomic batches, prepared statements, and callback transactions. ## Set up Jest @@ -38,20 +40,83 @@ test('stores a note', () => { }) ``` -The mock returns query rows through both `results` and `rows`, and supports synchronous and asynchronous queries and [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`. +The mock returns query rows through both `results` and `rows`, and supports [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`. + +## Share a database between connections + +Connections with the same `name` and `location` open the same temporary file. A default connection reserves its name until you close it. Set `connection: 'independent'` to open another handle, and `readOnly: true` to prevent writes. Read-only opens fail if the file does not exist. + +```ts +const writer = open({ name: 'notes.sqlite' }) +writer.execute('PRAGMA journal_mode = WAL') +writer.execute('CREATE TABLE notes (title TEXT)') +writer.execute('INSERT INTO notes VALUES (?)', ['Draft']) + +const reader = open({ + name: 'notes.sqlite', + connection: 'independent', + readOnly: true, +}) +expect(reader.execute('SELECT title FROM notes').rows.item(0)?.title).toBe( + 'Draft', +) + +reader.close() +writer.close() +``` + +`location` selects a relative directory inside the mock's temporary directory. A name must be a file name; paths that escape the temporary directory throw. + +Closing a connection preserves its data for reopening. `delete()` closes the connection and removes its database and sidecar files. It throws for a read-only connection or if another open connection uses the file. Close those connections before deleting it. + +## Reuse a prepared statement + +Preparing SQL once lets you execute it repeatedly with new parameter values. Each execution replaces the previous bindings. Omitted values bind as SQL NULL. Finalize statements before closing the connection. + +```ts +const db = open({ name: 'notes.sqlite' }) +db.execute('CREATE TABLE notes (title TEXT)') +const insert = db.prepare('INSERT INTO notes VALUES (?)') + +await insert.executeAsync(['Draft']) +await insert.executeAsync(['Published']) +insert.finalize() +expect(insert.isFinalized).toBe(true) + +db.close() +``` + +`finalize()` is safe to repeat while the connection is open and idle. Executing a finalized statement or using a statement after its connection closes throws or rejects. + +## Commit or roll back a transaction + +A transaction groups operations into one commit. The mock keeps a callback transaction exclusive on its connection, including across `await`. Use the supplied `tx` for all database work inside the callback: + +```ts +const db = open({ name: 'notes.sqlite' }) +db.execute('CREATE TABLE notes (title TEXT)') + +const count = await db.transaction(async (tx) => { + await tx.executeAsync('INSERT INTO notes VALUES (?)', ['Draft']) + return tx.execute('SELECT title FROM notes').rows.length +}) +expect(count).toBe(1) + +db.close() +``` + +The callback's result resolves after commit. A callback error rolls back its changes. You can call `tx.commit()` or `tx.rollback()` explicitly; subsequent operations on that transaction fail. Await all `tx.executeAsync()` calls before a synchronous transaction operation or before returning from the callback. + +Async connection calls run in call order and wait for callback transactions. Synchronous queries, preparation, statement finalization, close, and delete throw while their connection is busy. Awaiting a queued connection call inside its transaction callback deadlocks; use `tx` instead. Independent connections have separate queues. -## Mock API +## Clean up between tests -Import these exports from `react-native-nitro-sqlite/mock`: +Await all pending operations before calling `resetAllDatabases()`. It closes every handle and removes all temporary databases and sidecar files, including files whose connections you already closed. Connections and statements from before the reset cannot execute again. -| Export | Behavior | -| --- | --- | -| `open({ name })` | Opens a named in-memory database and returns a connection. Opening the same name twice before closing it throws. | -| `NitroSQLite.open({ name })` | Calls the same `open()` function. | -| `resetAllDatabases()` | Closes every open mock database and removes its data. | +The reset throws if any connection is busy. Await that work and retry. No connections close when this check fails. -The returned connection implements `execute()`, `executeAsync()`, `executeBatch()`, `executeBatchAsync()`, `close()`, and `delete()`. `delete()` closes the in-memory database. Query methods return the public data of [`QueryResult`](/api/react-native-nitro-sqlite/type-aliases/QueryResult); batch methods return a `rowsAffected` count. Batch commands use the [`BatchQueryCommand`](/api/react-native-nitro-sqlite/interfaces/BatchQueryCommand) shape. +## API reference and limits -## Limits +The generated [mock API reference](/api/react-native-nitro-sqlite/mock) documents `open()`, `NitroSQLite.open()`, `MockConnection`, and `resetAllDatabases()`. Connection methods use the same parameter and result types as the native API. -The mock does not implement independent or read-only connections, files, attachments, prepared statements, callback transactions, or other native methods. Its async methods return promises but execute SQL synchronously in Node. +The mock does not implement attachments, SQL file imports, or direct `NitroSQLite.native` methods. Its SQL runs synchronously on Node's thread; async methods schedule that work and return promises. It uses the SQLite version bundled with `better-sqlite3`, which can differ from the library's native SQLite build. Use device tests for native integration, thread behavior, lock contention, and performance. diff --git a/docs/mock-api/package.json b/docs/mock-api/package.json new file mode 100644 index 00000000..94b09ec6 --- /dev/null +++ b/docs/mock-api/package.json @@ -0,0 +1,8 @@ +{ + "name": "react-native-nitro-sqlite/mock", + "private": true, + "typedocOptions": { + "entryPoints": ["../../packages/react-native-nitro-sqlite/src/mock.ts"], + "tsconfig": "../tsconfig.typedoc.json" + } +} diff --git a/docs/scripts/check-api.mjs b/docs/scripts/check-api.mjs index 760957cf..eadb8f8d 100644 --- a/docs/scripts/check-api.mjs +++ b/docs/scripts/check-api.mjs @@ -25,6 +25,12 @@ const requiredExports = { 'TypeOrmNitroSQLiteConnection', 'typeORMDriver', ], + 'react-native-nitro-sqlite/mock': [ + 'open', + 'NitroSQLite', + 'MockConnection', + 'resetAllDatabases', + ], 'react-native-nitro-sqlite-vec': [ 'VectorColumnType', 'VectorDistanceMetric', diff --git a/docs/tsconfig.typedoc.json b/docs/tsconfig.typedoc.json index 74b702b6..85341fd1 100644 --- a/docs/tsconfig.typedoc.json +++ b/docs/tsconfig.typedoc.json @@ -11,6 +11,7 @@ }, "files": [ "../packages/react-native-nitro-sqlite/src/index.ts", + "../packages/react-native-nitro-sqlite/src/mock.ts", "../packages/react-native-nitro-sqlite-vec/src/index.ts" ] } diff --git a/docs/typedoc.json b/docs/typedoc.json index e2f9dda2..58f712bc 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -1,7 +1,8 @@ { "entryPoints": [ "../packages/react-native-nitro-sqlite", - "../packages/react-native-nitro-sqlite-vec" + "../packages/react-native-nitro-sqlite-vec", + "./mock-api" ], "entryPointStrategy": "packages", "name": "API Reference", diff --git a/packages/react-native-nitro-sqlite/package.json b/packages/react-native-nitro-sqlite/package.json index 5bb9dfb8..ddebb4e8 100644 --- a/packages/react-native-nitro-sqlite/package.json +++ b/packages/react-native-nitro-sqlite/package.json @@ -117,6 +117,7 @@ "!src/**/__tests__/**", "!src/**/__mocks__/**", "!src/mock.ts", + "!src/mock/**", "!src/specs/**", "!src/types.ts" ], diff --git a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts index 2c093262..24e14d71 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts @@ -1,4 +1,5 @@ import { NitroSQLite, open, resetAllDatabases } from '../mock' +import type { Transaction } from '../types' afterEach(resetAllDatabases) @@ -107,4 +108,259 @@ describe('Node SQLite mock', () => { const fresh = open({ name: 'first' }) expect(() => fresh.execute('SELECT * FROM items')).toThrow() }) + + it('shares files across independent handles and preserves data after close', () => { + const writer = open({ name: 'shared.sqlite' }) + writer.execute('CREATE TABLE notes (title TEXT)') + writer.execute('INSERT INTO notes VALUES (?)', ['Draft']) + const reader = open({ + name: 'shared.sqlite', + connection: 'independent', + readOnly: true, + }) + expect(reader.execute('SELECT title FROM notes').results).toEqual([ + { title: 'Draft' }, + ]) + expect(() => + reader.execute('INSERT INTO notes VALUES (?)', ['Forbidden']), + ).toThrow() + expect(() => reader.delete()).toThrow('read-only') + expect(() => writer.delete()).toThrow('another connection') + reader.close() + writer.close() + const reopened = open({ name: 'shared.sqlite' }) + expect(reopened.execute('SELECT title FROM notes').rows.length).toBe(1) + expect(() => writer.execute('SELECT 1')).toThrow('not open') + expect(() => writer.delete()).toThrow('reopened') + reopened.delete() + const fresh = open({ name: 'shared.sqlite' }) + expect(() => fresh.execute('SELECT * FROM notes')).toThrow() + }) + + it('requires existing files for read-only opens without reserving a failed name', () => { + expect(() => open({ name: 'missing.sqlite', readOnly: true })).toThrow() + const writer = open({ name: 'missing.sqlite' }) + writer.execute('CREATE TABLE notes (title TEXT)') + writer.close() + const reader = open({ name: 'missing.sqlite', readOnly: true }) + expect(reader.execute('SELECT * FROM notes').rows.length).toBe(0) + }) + + it('isolates locations and rejects paths outside the temporary directory', () => { + const first = open({ name: 'notes.sqlite', location: 'account-a' }) + first.execute('CREATE TABLE notes (title TEXT)') + first.close() + const second = open({ name: 'notes.sqlite', location: 'account-b' }) + expect(() => second.execute('SELECT * FROM notes')).toThrow() + expect(() => open({ name: '../notes.sqlite' })).toThrow('file name') + expect(() => + open({ name: 'escape.sqlite', location: '../outside' }), + ).toThrow('temporary directory') + }) + + it('reuses prepared statements, replaces bindings, and finalizes idempotently', async () => { + const database = open({ name: 'prepared.sqlite' }) + const statement = database.prepare( + 'SELECT :enabled AS enabled, :bytes AS bytes, :missing AS missing', + ) + const bytes = Uint8Array.from([7, 8]).buffer + expect(statement.execute([true, bytes, undefined]).rows.item(0)).toEqual({ + enabled: 1, + bytes, + missing: null, + }) + expect(await statement.executeAsync([false, null, 'value'])).toMatchObject({ + results: [{ enabled: 0, bytes: null, missing: 'value' }], + }) + expect(statement.execute().results).toEqual([ + { enabled: null, bytes: null, missing: null }, + ]) + const positional = database.prepare('SELECT ? AS first, ? AS second') + expect(positional.execute(['Draft']).results).toEqual([ + { first: 'Draft', second: null }, + ]) + expect(positional.execute().results).toEqual([ + { first: null, second: null }, + ]) + positional.finalize() + expect(statement.isFinalized).toBe(false) + statement.finalize() + statement.finalize() + expect(statement.isFinalized).toBe(true) + expect(() => statement.execute()).toThrow('finalized') + await expect(statement.executeAsync()).rejects.toThrow('finalized') + expect(() => database.prepare('INVALID SQL')).toThrow() + }) + + it('invalidates connections and statements on reset', async () => { + const database = open({ name: 'reset.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + const statement = database.prepare('SELECT * FROM notes') + resetAllDatabases() + expect(() => statement.execute()).toThrow('not open') + await expect(database.executeAsync('SELECT 1')).rejects.toThrow('not open') + await expect(statement.executeAsync()).rejects.toThrow('not open') + const fresh = open({ name: 'reset.sqlite' }) + expect(() => fresh.execute('SELECT * FROM notes')).toThrow() + }) + + it('commits callback results and rolls back failures', async () => { + const database = open({ name: 'transactions.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + expect( + await database.transaction(async (tx) => { + await tx.executeAsync('INSERT INTO notes VALUES (?)', ['Draft']) + return tx.execute('SELECT title FROM notes').rows.item(0)?.title + }), + ).toBe('Draft') + await expect( + database.transaction(async (tx) => { + tx.execute('INSERT INTO notes VALUES (?)', ['Discard']) + throw new Error('cancel') + }), + ).rejects.toThrow('cancel') + expect(database.execute('SELECT title FROM notes').results).toEqual([ + { title: 'Draft' }, + ]) + }) + + it('supports explicit commit and rollback and expires transaction handles', async () => { + const database = open({ name: 'explicit.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + let saved: Transaction | undefined + await database.transaction(async (tx) => { + saved = tx + tx.execute('INSERT INTO notes VALUES (?)', ['Discard']) + tx.rollback() + expect(() => tx.commit()).toThrow('finalized') + }) + expect(database.execute('SELECT * FROM notes').rows.length).toBe(0) + await expect( + database.transaction(async (tx) => { + tx.execute('INSERT INTO notes VALUES (?)', ['Keep']) + tx.commit() + throw new Error('after commit') + }), + ).rejects.toThrow('after commit') + expect(database.execute('SELECT title FROM notes').results).toEqual([ + { title: 'Keep' }, + ]) + if (!saved) throw new Error('Expected a transaction handle') + const finalized = saved + expect(() => finalized.execute('SELECT 1')).toThrow('finalized') + await expect(finalized.executeAsync('SELECT 1')).rejects.toThrow( + 'finalized', + ) + }) + + it('keeps transactions exclusive across await and queues later work after rollback', async () => { + const database = open({ name: 'exclusive.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + const statement = database.prepare('INSERT INTO notes VALUES (?)') + const gate = createGate() + const transaction = database.transaction(async (tx) => { + tx.execute('INSERT INTO notes VALUES (?)', ['Discard']) + await gate.promise + throw new Error('rollback') + }) + const rejected = transaction.catch((error: unknown) => error) + const insert = statement.executeAsync(['Keep']) + const read = database.executeAsync('SELECT title FROM notes') + expect(() => database.execute('SELECT 1')).toThrow('busy') + expect(() => database.prepare('SELECT 1')).toThrow('busy') + expect(() => statement.finalize()).toThrow('busy') + expect(() => database.close()).toThrow('busy') + expect(() => resetAllDatabases()).toThrow('busy') + gate.release() + expect(await rejected).toMatchObject({ message: 'rollback' }) + await insert + expect((await read).results).toEqual([{ title: 'Keep' }]) + statement.finalize() + database.close() + }) + + it('queues transactions and batches after pending statements in call order', async () => { + const database = open({ name: 'fifo.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + const first = database.executeAsync('INSERT INTO notes VALUES (?)', [ + 'First', + ]) + const transaction = database.transaction(async (tx) => { + expect(tx.execute('SELECT title FROM notes').results).toEqual([ + { title: 'First' }, + ]) + await tx.executeAsync('INSERT INTO notes VALUES (?)', ['Second']) + }) + const batch = database.executeBatchAsync([ + { query: 'INSERT INTO notes VALUES (?)', params: ['Third'] }, + ]) + await Promise.all([first, transaction, batch]) + expect( + database.execute('SELECT title FROM notes ORDER BY rowid').results, + ).toEqual([{ title: 'First' }, { title: 'Second' }, { title: 'Third' }]) + }) + + it('guards synchronous transaction calls while async work is pending', async () => { + const database = open({ name: 'pending.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + await database.transaction(async (tx) => { + const insert = tx.executeAsync('INSERT INTO notes VALUES (?)', ['Draft']) + expect(() => tx.commit()).toThrow('Await all') + expect(() => tx.rollback()).toThrow('Await all') + expect(() => tx.execute('SELECT 1')).toThrow('Await all') + await insert + }) + expect(database.execute('SELECT * FROM notes').rows.length).toBe(1) + }) + + it('waits for pending transaction statements before rolling back a callback error', async () => { + const database = open({ name: 'pending-error.sqlite' }) + database.execute('CREATE TABLE notes (title TEXT)') + await expect( + database.transaction(async (tx) => { + const insert = tx.executeAsync('INSERT INTO notes VALUES (?)', [ + 'Discard', + ]) + insert.catch(() => undefined) + throw new Error('cancel pending') + }), + ).rejects.toThrow('cancel pending') + expect(database.execute('SELECT * FROM notes').rows.length).toBe(0) + }) + + it('reads one WAL snapshot while an independent connection commits a write', async () => { + const writer = open({ name: 'snapshot.sqlite' }) + writer.execute('PRAGMA journal_mode = WAL') + writer.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT)') + writer.execute('INSERT INTO notes VALUES (1, ?)', ['Draft']) + const reader = open({ + name: 'snapshot.sqlite', + connection: 'independent', + readOnly: true, + }) + const started = createGate() + const resume = createGate() + const snapshot = reader.transaction(async (tx) => { + const before = await tx.executeAsync('SELECT title FROM notes') + started.release() + await resume.promise + const after = await tx.executeAsync('SELECT title FROM notes') + return [before.results, after.results] + }) + await started.promise + await writer.executeAsync('UPDATE notes SET title = ?', ['Published']) + resume.release() + expect(await snapshot).toEqual([[{ title: 'Draft' }], [{ title: 'Draft' }]]) + expect(reader.execute('SELECT title FROM notes').results).toEqual([ + { title: 'Published' }, + ]) + }) }) + +function createGate() { + let release = () => {} + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} diff --git a/packages/react-native-nitro-sqlite/src/mock.ts b/packages/react-native-nitro-sqlite/src/mock.ts index 6cc8c7ef..0971a4a7 100644 --- a/packages/react-native-nitro-sqlite/src/mock.ts +++ b/packages/react-native-nitro-sqlite/src/mock.ts @@ -3,264 +3,190 @@ * https://github.com/Expensify/react-native-onyx/blob/main/tests/unit/mocks/sqliteMock.ts * See THIRD_PARTY_NOTICES.md for the original MIT license notice. */ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' import BetterSqlite3 from 'better-sqlite3' -import type { - BatchQueryCommand, - NitroSQLiteConnection, - QueryResult, - QueryResultRow, - SQLiteQueryParams, - SQLiteValue, -} from './types' +import { + closeDatabaseQueue, + getDatabaseQueue, + openDatabaseQueue, + queueOperationAsync, + queueStatementAsync, + startOperationSync, +} from './DatabaseQueue' +import NitroSQLiteError from './NitroSQLiteError' +import type { NitroSQLiteConnectionOptions, PreparedStatement } from './types' +import type { MockConnection } from './mock/MockConnection' +import { executeBatch, executePrepared, executeQuery } from './mock/query' +import { runTransaction } from './mock/transaction' + +export type { MockConnection } from './mock/MockConnection' -type MockConnection = Pick< - NitroSQLiteConnection, - | 'close' - | 'delete' - | 'execute' - | 'executeAsync' - | 'executeBatch' - | 'executeBatchAsync' -> type Database = InstanceType -type BoundValue = string | number | Buffer | null -type ExpandedBatchCommand = { query: string; params?: SQLiteQueryParams } - -const databases = new Map() +type Statement = BetterSqlite3.Statement> +type ConnectionRecord = { + database: Database + path: string + queueKey: symbol + close: () => void +} +const connections = new Set() +const defaultConnections = new Map() +let databaseDirectory: string | undefined -/** Open a named in-memory database for Node tests. */ -export function open({ name }: { name: string }): MockConnection { - if (databases.has(name)) { - throw new Error(`Database ${name} is already open.`) +/** + * Open a SQLite database in a temporary directory for Node tests. + * Connections with the same name and location share a file. Opening a second + * default connection with the same name throws; independent connections have + * separate handles and queues. Read-only opens require an existing database. + * Closing preserves data until deletion or {@linkcode resetAllDatabases}. + * @param options Name, relative location, connection mode, and read-only setting. + * @returns A connection supporting queries, batches, prepared statements, and transactions. + * @throws If the name or location escapes the temporary directory, the database + * cannot be opened, or a default connection already uses the name. + * @see {@linkcode MockConnection} + */ +export function open(options: NitroSQLiteConnectionOptions): MockConnection { + const { name, connection, readOnly = false } = options + if (connection !== 'independent' && defaultConnections.has(name)) { + throw new NitroSQLiteError(`Database ${name} is already open.`) } - - const database = new BetterSqlite3(':memory:') - databases.set(name, database) - - const close = () => { - database.close() - databases.delete(name) + const path = getDatabasePath(options) + if (!readOnly) mkdirSync(dirname(path), { recursive: true }) + const database = new BetterSqlite3(path, { + readonly: readOnly, + fileMustExist: readOnly, + }) + const queueKey = Symbol(name) + openDatabaseQueue(queueKey) + + const runSync = (callback: () => Result) => + startOperationSync(queueKey, callback) + const runAsync = async ( + callback: () => Result, + exclusive = false, + ) => { + const enqueue = exclusive ? queueOperationAsync : queueStatementAsync + return enqueue(queueKey, () => Promise.resolve().then(callback)) } - - const executeBatch: MockConnection['executeBatch'] = (commands) => { - const expandedCommands = expandBatchCommands(commands) - if (expandedCommands.length === 0) { - throw new Error('No SQL batch commands provided') - } - - let rowsAffected = 0 - database - .transaction(() => { - for (const command of expandedCommands) { - const { statement, bindings } = prepareAndBind( - database, - command.query, - command.params, - ) - if (statement.reader) { - statement.all(...bindings) - } else { - statement.run(...bindings) - } - if (!statement.readonly) { - rowsAffected += getChangeCount(database) - } - } - }) - .exclusive() - - return { rowsAffected } + const close = () => { + runSync(() => database.close()) + closeDatabaseQueue(queueKey) + connections.delete(record) + if (defaultConnections.get(name) === record) defaultConnections.delete(name) } + const record: ConnectionRecord = { database, path, queueKey, close } + connections.add(record) + if (connection !== 'independent') defaultConnections.set(name, record) return { close, - delete: close, - execute: (query, params) => executeQuery(database, query, params), - executeAsync: async (query, params) => - executeQuery(database, query, params), - executeBatch, - executeBatchAsync: async (commands) => executeBatch(commands), - } -} - -export const NitroSQLite = { open } - -/** Close all databases so each test can start with empty storage. */ -export function resetAllDatabases(): void { - for (const database of databases.values()) { - database.close() - } - databases.clear() -} - -function executeQuery( - database: Database, - query: string, - params?: SQLiteQueryParams, -): QueryResult { - const { statement, bindings } = prepareAndBind(database, query, params) - const results: QueryResultRow[] = [] - if (statement.reader) { - results.push(...statement.all(...bindings).map(normalizeRow)) - } else { - statement.run(...bindings) - } - const { rowsAffected, insertId } = getDatabaseChanges(database) - - // Native results are HybridObjects. The mock supplies their public data only. - return { - results, - rowsAffected, - insertId, - rows: { - _array: results, - length: results.length, - item: (index: number) => results[index], + delete: () => { + const replacement = defaultConnections.get(name) + if ( + connection !== 'independent' && + replacement && + replacement !== record + ) { + throw new NitroSQLiteError( + 'Database has been reopened with another connection.', + ) + } + if (readOnly) + throw new NitroSQLiteError(`Cannot delete read-only database ${name}.`) + if ( + [...connections].some( + (other) => other !== record && other.path === path, + ) + ) { + throw new NitroSQLiteError('Database is in use by another connection.') + } + if (connections.has(record)) close() + for (const suffix of ['', '-wal', '-shm', '-journal']) + rmSync(`${path}${suffix}`, { force: true }) }, - } as QueryResult -} - -function prepareAndBind( - database: Database, - query: string, - params?: SQLiteQueryParams, -) { - const statement = database.prepare>(query) - const names = extractNamedParameterOrder(query) - if (names.length === 0) { - return { statement, bindings: params?.map(toBoundValue) ?? [] } - } - - const values: Record = {} - for (const [index, name] of names.entries()) { - values[name] = toBoundValue(params?.[index] ?? null) - } - return { statement, bindings: [values] } -} - -function extractNamedParameterOrder(query: string): string[] { - const names = new Set() - let quote: string | undefined - let lineComment = false - let blockComment = false - - for (let index = 0; index < query.length; index++) { - const char = query[index] - const next = query[index + 1] - - if (lineComment) { - if (char === '\n') lineComment = false - continue - } - if (blockComment) { - if (char === '*' && next === '/') { - blockComment = false - index++ + execute: (query, params) => + runSync(() => executeQuery(database, query, params)), + executeAsync: (query, params) => + runAsync(() => executeQuery(database, query, params)), + executeBatch: (commands) => runSync(() => executeBatch(database, commands)), + executeBatchAsync: (commands) => + runAsync(() => executeBatch(database, commands), true), + prepare: (query): PreparedStatement => { + let statement: Statement | undefined = runSync(() => + database.prepare(query), + ) + const execute: PreparedStatement['execute'] = (params) => { + if (!statement) + throw new NitroSQLiteError('Prepared statement is finalized.') + return executePrepared(database, statement, params) } - continue - } - if (quote) { - if (char === quote) { - if (next === quote) index++ - else quote = undefined + return { + get isFinalized() { + return statement === undefined + }, + execute: (params) => runSync(() => execute(params)), + executeAsync: (params) => runAsync(() => execute(params), true), + finalize: () => + runSync(() => { + // better-sqlite3 releases statement resources when the object is collected. + statement = undefined + }), } - continue - } - if (char === '-' && next === '-') { - lineComment = true - index++ - continue - } - if (char === '/' && next === '*') { - blockComment = true - index++ - continue - } - if (char === "'" || char === '"' || char === '`' || char === '[') { - quote = char === '[' ? ']' : char - continue - } - if (!char || !':@$'.includes(char) || !/[A-Za-z_]/.test(next ?? '')) { - continue - } - - const start = index + 1 - index = start - while (/[A-Za-z0-9_]/.test(query[index + 1] ?? '')) { - index++ - } - names.add(query.slice(start, index + 1)) + }, + transaction: async (callback) => + queueOperationAsync(queueKey, () => runTransaction(database, callback)), } - return [...names] } -function expandBatchCommands( - commands: BatchQueryCommand[], -): ExpandedBatchCommand[] { - const expanded: ExpandedBatchCommand[] = [] - for (const { query, params } of commands) { - if (params && isNestedParams(params)) { - for (const rowParams of params) { - expanded.push({ query, params: rowParams }) - } - } else { - expanded.push({ query, params }) +/** Node test entry point. {@linkcode NitroSQLite.open} is the same factory as {@linkcode open}. */ +export const NitroSQLite = { open } + +/** + * Close all mock connections and remove every temporary database and sidecar file. + * Call after awaiting all async work, for example in a test's `afterEach` hook. + * Existing connections and prepared statements cannot execute after a reset. + * @throws If any connection still has running or queued work. No connections are + * closed when this check fails, so await that work and retry the reset. + * @see {@linkcode open} + */ +export function resetAllDatabases(): void { + for (const { queueKey } of connections) { + const queue = getDatabaseQueue(queueKey) + if (queue.inProgress || queue.queue.length > 0) { + throw new NitroSQLiteError( + 'Cannot reset mock databases while a connection is busy.', + ) } } - return expanded + for (const record of connections) record.close() + if (databaseDirectory) + rmSync(databaseDirectory, { recursive: true, force: true }) + databaseDirectory = undefined } -function isNestedParams( - params: SQLiteQueryParams | SQLiteQueryParams[], -): params is SQLiteQueryParams[] { - return params.length > 0 && params.every(Array.isArray) -} - -function toBoundValue(value: SQLiteValue): BoundValue { - if (value === undefined) { - return null +function getDatabasePath({ + name, + location = '', +}: NitroSQLiteConnectionOptions): string { + if ( + !name || + name === '.' || + name === '..' || + name.includes('/') || + name.includes('\\') + ) { + throw new NitroSQLiteError('Database name must be a file name.') } - if (typeof value === 'boolean') { - return Number(value) + if (!databaseDirectory) + databaseDirectory = mkdtempSync(join(tmpdir(), 'nitro-sqlite-test-')) + const directory = resolve(databaseDirectory, location) + const relativeDirectory = relative(databaseDirectory, directory) + if (relativeDirectory === '..' || relativeDirectory.startsWith(`..${sep}`)) { + throw new NitroSQLiteError( + 'Database location must stay inside the temporary directory.', + ) } - if (value instanceof ArrayBuffer) { - return Buffer.from(value) - } - return value -} - -function normalizeRow(row: Record): QueryResultRow { - const result: QueryResultRow = {} - for (const [key, value] of Object.entries(row)) { - if (Buffer.isBuffer(value)) { - const bytes = new ArrayBuffer(value.byteLength) - new Uint8Array(bytes).set(value) - result[key] = bytes - } else if ( - value === null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - result[key] = value - } else { - throw new Error(`Unsupported SQLite value in column ${key}.`) - } - } - return result -} - -function getChangeCount(database: Database): number { - return getDatabaseChanges(database).rowsAffected -} - -function getDatabaseChanges(database: Database): { - rowsAffected: number - insertId: number -} { - const statement = database.prepare< - [], - { rowsAffected: number; insertId: number } - >('SELECT changes() AS rowsAffected, last_insert_rowid() AS insertId') - return statement.get() ?? { rowsAffected: 0, insertId: 0 } + return join(directory, name) } diff --git a/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts new file mode 100644 index 00000000..fbfc449a --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts @@ -0,0 +1,20 @@ +import type { NitroSQLiteConnection } from '../types' + +/** + * Connection returned by the Node mock's `open()` factory. + * Supports the corresponding managed connection methods and their queue and + * transaction lifecycle. Files persist across close/reopen until deletion or + * reset. Attachments and SQL file imports are not supported. + * @see {@linkcode NitroSQLiteConnection} + */ +export type MockConnection = Pick< + NitroSQLiteConnection, + | 'close' + | 'delete' + | 'execute' + | 'executeAsync' + | 'executeBatch' + | 'executeBatchAsync' + | 'prepare' + | 'transaction' +> diff --git a/packages/react-native-nitro-sqlite/src/mock/query.ts b/packages/react-native-nitro-sqlite/src/mock/query.ts new file mode 100644 index 00000000..05bf33b6 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/mock/query.ts @@ -0,0 +1,212 @@ +import type BetterSqlite3 from 'better-sqlite3' +import type { + BatchQueryCommand, + QueryResult, + QueryResultRow, + SQLiteQueryParams, + SQLiteValue, +} from '../types' + +type Database = InstanceType +type Statement = BetterSqlite3.Statement> +type BoundValue = string | number | Buffer | null +type ExpandedBatchCommand = { query: string; params?: SQLiteQueryParams } + +/** @internal Execute an already prepared statement with fresh parameter bindings. */ +export function executePrepared( + database: Database, + statement: Statement, + params?: SQLiteQueryParams, +): QueryResult { + const bindings = getBindings(statement.source, params) + const results: QueryResultRow[] = [] + if (statement.reader) + results.push(...statement.all(...bindings).map(normalizeRow)) + else statement.run(...bindings) + const { rowsAffected, insertId } = getDatabaseChanges(database) + // Native results are HybridObjects. The mock supplies their public data only. + return { + results, + rowsAffected, + insertId, + rows: { + _array: results, + length: results.length, + item: (index: number) => results[index], + }, + } as QueryResult +} + +/** @internal Execute one SQL statement and return Nitro-shaped data. */ +export function executeQuery( + database: Database, + query: string, + params?: SQLiteQueryParams, +): QueryResult { + return executePrepared(database, database.prepare(query), params) +} + +/** @internal Execute a batch atomically and count affected rows. */ +export function executeBatch( + database: Database, + commands: BatchQueryCommand[], +) { + const expanded = expandBatchCommands(commands) + if (expanded.length === 0) throw new Error('No SQL batch commands provided') + let rowsAffected = 0 + database + .transaction(() => { + for (const { query, params } of expanded) { + const statement = database.prepare>( + query, + ) + executePrepared(database, statement, params) + if (!statement.readonly) rowsAffected += getChangeCount(database) + } + }) + .exclusive() + return { rowsAffected } +} + +function getBindings(query: string, params?: SQLiteQueryParams) { + const { names, positionalCount } = extractParameters(query) + if (names.length === 0) { + const values = params?.map(toBoundValue) ?? [] + while (values.length < positionalCount) values.push(null) + return values + } + const values: Record = {} + for (const [index, name] of names.entries()) { + values[name] = toBoundValue(params?.[index] ?? null) + } + return [values] +} + +function extractParameters(query: string) { + const names = new Set() + let positionalCount = 0 + let quote: string | undefined + let lineComment = false + let blockComment = false + + for (let index = 0; index < query.length; index++) { + const char = query[index] + const next = query[index + 1] + + if (lineComment) { + if (char === '\n') lineComment = false + continue + } + if (blockComment) { + if (char === '*' && next === '/') { + blockComment = false + index++ + } + continue + } + if (quote) { + if (char === quote) { + if (next === quote) index++ + else quote = undefined + } + continue + } + if (char === '-' && next === '-') { + lineComment = true + index++ + continue + } + if (char === '/' && next === '*') { + blockComment = true + index++ + continue + } + if (char === "'" || char === '"' || char === '`' || char === '[') { + quote = char === '[' ? ']' : char + continue + } + if (char === '?') positionalCount++ + if (!char || !':@$'.includes(char) || !/[A-Za-z_]/.test(next ?? '')) { + continue + } + + const start = index + 1 + index = start + while (/[A-Za-z0-9_]/.test(query[index + 1] ?? '')) { + index++ + } + names.add(query.slice(start, index + 1)) + } + return { names: [...names], positionalCount } +} + +function expandBatchCommands( + commands: BatchQueryCommand[], +): ExpandedBatchCommand[] { + const expanded: ExpandedBatchCommand[] = [] + for (const { query, params } of commands) { + if (params && isNestedParams(params)) { + for (const rowParams of params) { + expanded.push({ query, params: rowParams }) + } + } else { + expanded.push({ query, params }) + } + } + return expanded +} + +function isNestedParams( + params: SQLiteQueryParams | SQLiteQueryParams[], +): params is SQLiteQueryParams[] { + return params.length > 0 && params.every(Array.isArray) +} + +function toBoundValue(value: SQLiteValue): BoundValue { + if (value === undefined) { + return null + } + if (typeof value === 'boolean') { + return Number(value) + } + if (value instanceof ArrayBuffer) { + return Buffer.from(value) + } + return value +} + +function normalizeRow(row: Record): QueryResultRow { + const result: QueryResultRow = {} + for (const [key, value] of Object.entries(row)) { + if (Buffer.isBuffer(value)) { + const bytes = new ArrayBuffer(value.byteLength) + new Uint8Array(bytes).set(value) + result[key] = bytes + } else if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + result[key] = value + } else { + throw new Error(`Unsupported SQLite value in column ${key}.`) + } + } + return result +} + +function getChangeCount(database: Database): number { + return getDatabaseChanges(database).rowsAffected +} + +function getDatabaseChanges(database: Database): { + rowsAffected: number + insertId: number +} { + const statement = database.prepare< + [], + { rowsAffected: number; insertId: number } + >('SELECT changes() AS rowsAffected, last_insert_rowid() AS insertId') + return statement.get() ?? { rowsAffected: 0, insertId: 0 } +} diff --git a/packages/react-native-nitro-sqlite/src/mock/transaction.ts b/packages/react-native-nitro-sqlite/src/mock/transaction.ts new file mode 100644 index 00000000..a9099b62 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/mock/transaction.ts @@ -0,0 +1,67 @@ +import type BetterSqlite3 from 'better-sqlite3' +import NitroSQLiteError from '../NitroSQLiteError' +import type { QueryResultRow, SQLiteQueryParams, Transaction } from '../types' +import { executeQuery } from './query' + +/** @internal Run a callback while the connection's exclusive queue item owns it. */ +export async function runTransaction( + database: InstanceType, + callback: (tx: Transaction) => Promise, +): Promise { + let finished = false + const pending = new Set>() + const assertActive = () => { + if (finished) throw new NitroSQLiteError('Transaction is finalized.') + } + const assertSync = () => { + assertActive() + if (pending.size > 0) { + throw new NitroSQLiteError( + 'Await all tx.executeAsync calls before a synchronous transaction operation.', + ) + } + } + const finish = (query: 'COMMIT' | 'ROLLBACK') => { + assertSync() + const result = executeQuery(database, query) + finished = true + return result + } + const tx: Transaction = { + execute: (query, params) => { + assertSync() + return executeQuery(database, query, params) + }, + executeAsync: async ( + query: string, + params?: SQLiteQueryParams, + ) => { + assertActive() + const operation = Promise.resolve().then(() => + executeQuery(database, query, params), + ) + pending.add(operation) + operation.then( + () => pending.delete(operation), + () => pending.delete(operation), + ) + return operation + }, + commit: () => finish('COMMIT'), + rollback: () => finish('ROLLBACK'), + } + + database.exec('BEGIN TRANSACTION') + try { + const result = await callback(tx) + if (!finished) finish('COMMIT') + return result + } catch (error) { + if (!finished) { + finished = true + await Promise.allSettled(pending) + if (database.inTransaction) database.exec('ROLLBACK') + } + throw error + } +}