diff --git a/docs/content/docs/guides/node-test-mock.mdx b/docs/content/docs/guides/node-test-mock.mdx index 41a2acbb..cfd1140f 100644 --- a/docs/content/docs/guides/node-test-mock.mdx +++ b/docs/content/docs/guides/node-test-mock.mdx @@ -38,7 +38,7 @@ test('stores a note', () => { }) ``` -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()`. +The mock returns query rows through both `results` and `rows`, supports [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays) through `executeRaw()` and `executeRawAsync()`, 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 diff --git a/docs/content/docs/guides/parameters-and-results.mdx b/docs/content/docs/guides/parameters-and-results.mdx index 9bf62830..a95bafd3 100644 --- a/docs/content/docs/guides/parameters-and-results.mdx +++ b/docs/content/docs/guides/parameters-and-results.mdx @@ -31,6 +31,21 @@ for (const person of result.rows._array) { SQLite INTEGER and REAL result values both arrive as JavaScript numbers. BLOB values arrive as `ArrayBuffer`; NULL values arrive as `null`, even if the parameter was `undefined`. A bound boolean is stored through SQLite's integer binding, so read it as a number and convert it in application code if needed. JavaScript numbers cannot represent every 64-bit SQLite integer exactly; choose a storage representation appropriate for identifiers that exceed the safe integer range. +## Rows as arrays + +A row object holds one value per column name, so when two result columns share a name, the later value replaces the earlier one. This happens in joins such as `SELECT users.id, pets.id ...`. `db.executeRaw()` and `db.executeRawAsync()` take the same arguments as `execute()` and `executeAsync()` but return each row as an array of values in column order, so every column is kept. + +```ts +const { columnNames, rows } = db.executeRaw( + 'SELECT users.id, users.name, pets.id, pets.name FROM users LEFT JOIN pets ON pets.owner_id = users.id', +) + +// columnNames: ['id', 'name', 'id', 'name'] +// rows: [[1, 'Ada', 7, 'Rex'], [2, 'Grace', null, null]] +``` + +The result has `columnNames`, `rows`, `rowsAffected`, and `insertId`. It has no `rows._array` container and no `metadata`. `columnNames` is filled even when the query returns no rows. Transactions and prepared statements have the same two methods. The exact shape is in [`NitroSQLiteRawQueryResult`](/api/react-native-nitro-sqlite/interfaces/NitroSQLiteRawQueryResult). + ## Column metadata For a query with result columns, `metadata` maps column names to `{ name, type, index }`. `index` is the zero-based result column position. The [`ColumnType` values](/api/react-native-nitro-sqlite/enumerations/ColumnType) are `BOOLEAN = 0`, `NUMBER = 1`, `INT64 = 2`, `TEXT = 3`, `ARRAY_BUFFER = 4`, and `NULL_VALUE = 5`. The package exports `ColumnType` as a TypeScript type, not a runtime enum object. In the current native implementation, `metadata.type` is unreliable for detecting a column's declared SQL type. Use your schema for that decision. An expression without a declared type maps to `NULL_VALUE`, which does not mean the result itself is null. The exact shape is in [`NitroSQLiteQueryColumnMetadata`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryColumnMetadata). diff --git a/docs/content/docs/guides/prepared-statements.mdx b/docs/content/docs/guides/prepared-statements.mdx index 28b20b8d..ed1e8fe0 100644 --- a/docs/content/docs/guides/prepared-statements.mdx +++ b/docs/content/docs/guides/prepared-statements.mdx @@ -3,7 +3,7 @@ title: Prepared statements description: Reuse a compiled SQL statement with different bound values. --- -Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`. Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept. +Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`, plus `executeRaw(params)` and `executeRawAsync(params)` for [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays). Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept. ```ts import { open } from 'react-native-nitro-sqlite' diff --git a/docs/content/docs/guides/sync-and-async.mdx b/docs/content/docs/guides/sync-and-async.mdx index 46e96b6e..02428332 100644 --- a/docs/content/docs/guides/sync-and-async.mdx +++ b/docs/content/docs/guides/sync-and-async.mdx @@ -5,7 +5,7 @@ description: Pick the query form that fits your workload and understand connecti Use `db.execute()` for a small query that needs an immediate result, and `await db.executeAsync()` for work that may take longer. A synchronous call blocks its JavaScript caller until SQLite finishes. An asynchronous call returns a promise and lets JavaScript continue while the database work runs in the background. -`executeBatch` and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL. +`executeRaw`, `executeBatch`, and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL. ```ts import { open } from 'react-native-nitro-sqlite' @@ -45,7 +45,11 @@ See [prepared statements](/docs/guides/prepared-statements#connection-ordering-a ## Global helpers and native access -The [`NitroSQLite` export](/api/react-native-nitro-sqlite/variables/NitroSQLite) also has `execute`, `executeAsync`, `prepare`, `executeBatch`, `executeBatchAsync`, and `transaction` helpers that take a database name. `NitroSQLite.open(options)` is the same helper as the named [`open()` export](/api/react-native-nitro-sqlite/functions/open). Global queries join the default connection's JavaScript queue when the name was opened through `open()`. They do not address independent connections; use the object returned by `open()` for those. Without a default managed connection, global `execute` and `executeAsync` call the native methods directly and still need an open native database handle. Global `prepare`, batches, and transactions need a default connection opened through `open()`. +The [`NitroSQLite` export](/api/react-native-nitro-sqlite/variables/NitroSQLite) also has `execute`, `executeAsync`, `executeRaw`, `executeRawAsync`, `prepare`, `executeBatch`, `executeBatchAsync`, and `transaction` helpers that take a database name. `NitroSQLite.open(options)` is the same helper as the named [`open()` export](/api/react-native-nitro-sqlite/functions/open). + +Global queries join the default connection's JavaScript queue when the name was opened through `open()`. This includes `NitroSQLite.executeRaw(dbName, query, params)` and `NitroSQLite.executeRawAsync(dbName, query, params)`. A synchronous query throws while that connection is busy; an asynchronous query waits for an active transaction to finish. Global helpers do not address independent connections; use the object returned by `open()` for those. + +Without a default managed connection, global `execute`, `executeAsync`, `executeRaw`, and `executeRawAsync` call the native methods directly and still need an open native database handle. Global `prepare`, batches, and transactions need a default connection opened through `open()`. `NitroSQLite.native` exposes the underlying [`NitroSQLiteNative` hybrid object](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteNative). Its methods take a database name and return raw results without the connection helper's `rows` container. They also throw native errors without converting them to `NitroSQLiteError`. Its `executeAsync()` accepts an optional `onRows` callback that receives leading rows in batches, in order; the resolved `results` then holds only the rows after the last batch. diff --git a/docs/content/docs/guides/transactions.mdx b/docs/content/docs/guides/transactions.mdx index a2395503..efd3a9d5 100644 --- a/docs/content/docs/guides/transactions.mdx +++ b/docs/content/docs/guides/transactions.mdx @@ -29,7 +29,7 @@ Throw from the callback when an application check fails. Use `tx` for those chec ## Commit and rollback behavior -When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.commit`, or `tx.rollback` after finalization throw. +When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.executeRaw`, `tx.executeRawAsync`, `tx.commit`, or `tx.rollback` after finalization throw. A failed COMMIT can leave SQLite's transaction active, for example when a deferred foreign-key constraint fails. NitroSQLite marks a commit complete only after SQLite accepts it. A failed manual or automatic commit triggers rollback and rejects with the commit error. This also happens if the callback catches a failed `tx.commit()` and returns without explicitly rolling back. After the failure, only `tx.rollback()` is allowed on that transaction handle. @@ -39,7 +39,7 @@ If rollback also fails, the rejection preserves the original message and appends -The connection queue is occupied for the whole callback. Use `tx.execute` or `tx.executeAsync` for every statement on this database during that callback, including statements in helpers. +The connection queue is occupied for the whole callback. Use the `tx` methods (`execute`, `executeAsync`, `executeRaw`, or `executeRawAsync`) for every statement on this database during that callback, including statements in helpers. > [!TIP] > Pass `tx` to any helper that runs statements during the transaction. @@ -57,7 +57,7 @@ await db.transaction(async (tx) => { ## Await pending statements -Await every `tx.executeAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction. +Await every `tx.executeAsync()` and `tx.executeRawAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.executeRaw()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction. `tx.commit()` and `tx.rollback()` are synchronous. Their declared return type exposes the native query result fields. The JavaScript helper also adds `rows` at runtime, but that field is not in their declared type. Use them to finalize the transaction, not to retrieve rows. The transaction starts with `BEGIN TRANSACTION`; the global [`NitroSQLite.transaction`](/api/react-native-nitro-sqlite/variables/NitroSQLite) helper also accepts `isExclusive` to use `BEGIN EXCLUSIVE TRANSACTION`. See [transaction modes](/docs/concepts/transactions#transaction-modes) for the SQLite differences. diff --git a/docs/scripts/check-api.mjs b/docs/scripts/check-api.mjs index 96fc4c3e..d094e1d7 100644 --- a/docs/scripts/check-api.mjs +++ b/docs/scripts/check-api.mjs @@ -22,6 +22,7 @@ const requiredExports = { 'NitroSQLiteNative', 'NitroSQLiteQueryResult', 'NitroSQLiteQueryColumnMetadata', + 'NitroSQLiteRawQueryResult', 'TypeOrmNitroSQLiteConnection', 'typeORMDriver', ], @@ -66,6 +67,8 @@ const requiredMembers = { 'transaction', 'execute', 'executeAsync', + 'executeRaw', + 'executeRawAsync', 'executeBatch', 'executeBatchAsync', 'close', diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index 30d0a01d..977ca3cf 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -2,6 +2,7 @@ import { beforeEach, describe } from '../TestApi' import { setupTestDb } from './common' import registerLoadFileUnitTests from './specs/operations/loadFile.spec' import registerExecuteUnitTests from './specs/operations/execute.spec' +import registerExecuteRawUnitTests from './specs/operations/executeRaw.spec' import registerTransactionUnitTests from './specs/operations/transaction.spec' import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' import registerPreparedStatementUnitTests from './specs/operations/preparedStatement.spec' @@ -15,6 +16,7 @@ export function registerUnitTests() { beforeEach(setupTestDb) describe('operations/execute.spec.ts', registerExecuteUnitTests) + describe('operations/executeRaw.spec.ts', registerExecuteRawUnitTests) describe('operations/transaction.spec.ts', registerTransactionUnitTests) describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests) describe('operations/loadFile.spec.ts', registerLoadFileUnitTests) diff --git a/example/tests/unit/specs/operations/executeRaw.spec.ts b/example/tests/unit/specs/operations/executeRaw.spec.ts new file mode 100644 index 00000000..715feb9f --- /dev/null +++ b/example/tests/unit/specs/operations/executeRaw.spec.ts @@ -0,0 +1,145 @@ +import { + expect, + isNitroSQLiteError, + TEST_ERROR_CODES, +} from '@tests/unit/common' +import { describe, it } from '@tests/TestApi' +import { testDb } from '@tests/db' + +const valuesQuery = + 'SELECT 1 AS duplicate, 2 AS duplicate, 3.5 AS "café", NULL AS nullable, zeroblob(2) AS payload' + +const joinQuery = ` + SELECT owners.id, owners.name, pets.id, pets.name + FROM owners LEFT JOIN pets ON pets.owner_id = owners.id + ORDER BY owners.id +` + +function createJoinTables() { + testDb.execute('CREATE TABLE owners (id INTEGER PRIMARY KEY, name TEXT)') + testDb.execute( + 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT, owner_id INTEGER)', + ) + testDb.execute("INSERT INTO owners VALUES (1, 'Dave'), (2, 'Ritesh')") + testDb.execute("INSERT INTO pets VALUES (7, 'Rex', 1)") +} + +function expectValuesRow(row: unknown[] | undefined) { + expect(row?.slice(0, 4)).toEqual([1, 2, 3.5, null]) + expect(Array.from(new Uint8Array(row?.[4] as ArrayBuffer))).toEqual([0, 0]) +} + +export default function registerExecuteRawUnitTests() { + describe('executeRaw', () => { + it('returns every value type and keeps repeated column names', async () => { + const result = testDb.executeRaw(valuesQuery) + expect(result.columnNames).toEqual([ + 'duplicate', + 'duplicate', + 'café', + 'nullable', + 'payload', + ]) + expect(result.rows).toHaveLength(1) + expectValuesRow(result.rows[0]) + + const asyncResult = await testDb.executeRawAsync(valuesQuery) + expect(asyncResult.columnNames).toEqual(result.columnNames) + expectValuesRow(asyncResult.rows[0]) + }) + + it('keeps both sides of a join with matching column names', async () => { + createJoinTables() + const expected = [ + [1, 'Dave', 7, 'Rex'], + [2, 'Ritesh', null, null], + ] + + const result = testDb.executeRaw(joinQuery) + expect(result.columnNames).toEqual(['id', 'name', 'id', 'name']) + expect(result.rows).toEqual(expected) + expect((await testDb.executeRawAsync(joinQuery)).rows).toEqual(expected) + }) + + it('binds parameters and reports changes for writes', async () => { + const inserted = testDb.executeRaw( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + [1, 'Ada', 36, 1.5], + ) + expect(inserted.rowsAffected).toBe(1) + expect(inserted.insertId).toBe(1) + expect(inserted.columnNames).toEqual([]) + expect(inserted.rows).toEqual([]) + + const selected = await testDb.executeRawAsync( + 'SELECT name, age FROM User WHERE id = ?', + [1], + ) + expect(selected.rows).toEqual([['Ada', 36]]) + }) + + it('returns column names for an empty result', () => { + const result = testDb.executeRaw('SELECT id, name FROM User WHERE 0') + expect(result.columnNames).toEqual(['id', 'name']) + expect(result.rows).toEqual([]) + }) + + it('returns large results in order', async () => { + const query = ` + WITH RECURSIVE counter(value) AS ( + SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < 10000 + ) + SELECT value, 'row-' || value FROM counter + ` + for (const result of [ + testDb.executeRaw(query), + await testDb.executeRawAsync(query), + ]) { + expect(result.rows).toHaveLength(10_000) + expect(result.rows[0]).toEqual([1, 'row-1']) + expect(result.rows[9_999]).toEqual([10_000, 'row-10000']) + } + }) + + it('runs inside a transaction', async () => { + createJoinTables() + await testDb.transaction(async (tx) => { + tx.executeRaw("INSERT INTO pets VALUES (8, 'Tom', 2)") + expect((await tx.executeRawAsync(joinQuery)).rows).toEqual([ + [1, 'Dave', 7, 'Rex'], + [2, 'Ritesh', 8, 'Tom'], + ]) + }) + }) + + it('runs prepared statements and switches between raw and object rows', async () => { + createJoinTables() + const statement = testDb.prepare(`${joinQuery} LIMIT ?`) + try { + expect(statement.executeRaw([1]).rows).toEqual([[1, 'Dave', 7, 'Rex']]) + expect(statement.execute([1]).results).toEqual([{ id: 7, name: 'Rex' }]) + const asyncResult = await statement.executeRawAsync([2]) + expect(asyncResult.columnNames).toEqual(['id', 'name', 'id', 'name']) + expect(asyncResult.rows).toHaveLength(2) + } finally { + statement.finalize() + } + }) + + it('throws NitroSQLiteError for invalid SQL', async () => { + try { + testDb.executeRaw('SELECT * FROM missing_table') + throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + } + + try { + await testDb.executeRawAsync('SELECT * FROM missing_table') + throw new Error(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + } + }) + }) +} diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index 2c8c49fd..798bce81 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -217,7 +217,9 @@ namespace { // and a smaller final batch shortens the conversion left after the statement completes. constexpr size_t kRowBatchSize = 256; - NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) { + // Reads every row of a bound statement. With onRows, full batches go to it and only the rows + // after the last batch are returned. + SQLiteQueryResultData readStatementRows(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows) { int columnCount = 0; std::vector columnNames; std::vector rows; @@ -292,8 +294,14 @@ namespace { captureColumns(); } + return {std::move(columnNames), std::move(rows)}; + } + + NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) { + auto [columnNames, rows] = readStatementRows(db, statement, onRows); + std::optional metadata = std::nullopt; - for (int i = 0; i < columnCount; i++) { + for (int i = 0; i < static_cast(columnNames.size()); i++) { const std::string& columnName = columnNames[i]; ColumnType columnDeclaredType = mapSQLiteTypeToColumnType(sqlite3_column_decltype(statement, i)); auto columnMeta = NitroSQLiteQueryColumnMetadata(columnName, std::move(columnDeclaredType), i); @@ -312,21 +320,45 @@ namespace { return result; } + NitroSQLiteRawQueryResult executeStatementRaw(sqlite3* db, sqlite3_stmt* statement) { + auto [columnNames, rows] = readStatementRows(db, statement, nullptr); + + NitroSQLiteRawQueryResult result; + result.rowsAffected = sqlite3_changes(db); + result.insertId = static_cast(sqlite3_last_insert_rowid(db)); + result.columnNames = columnNames; + result.rows = SQLiteRawQueryResults(SQLiteQueryResults(std::move(columnNames), std::move(rows))); + return result; + } + + // Runs a query on the connection's cached statement for it, bound to params. + template + auto executeCached(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, + Execute&& execute) { + std::lock_guard lock(connection->mutex); + sqlite3* db = connection->database; + if (db == nullptr) { + throw NitroSQLiteException::DatabaseNotOpen(connection->name); + } + + CachedStatement statement(*connection, query); + if (params) { + bindStatement(statement.get(), *params); + } + return execute(db, statement.get()); + } + } // namespace NitroSQLiteQueryResult sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, const SQLiteRowBatchHandler& onRows) { - std::lock_guard lock(connection->mutex); - sqlite3* db = connection->database; - if (db == nullptr) { - throw NitroSQLiteException::DatabaseNotOpen(connection->name); - } + return executeCached(connection, query, params, + [&](sqlite3* db, sqlite3_stmt* statement) { return executeStatement(db, statement, onRows); }); +} - CachedStatement statement(*connection, query); - if (params) { - bindStatement(statement.get(), *params); - } - return executeStatement(db, statement.get(), onRows); +NitroSQLiteRawQueryResult sqliteExecuteRaw(const SQLiteConnectionPtr& connection, const std::string& query, + const std::optional& params) { + return executeCached(connection, query, params, executeStatementRaw); } SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, @@ -379,7 +411,8 @@ SQLitePreparedStatement::~SQLitePreparedStatement() { finalize(); } -NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional& params) { +template +auto SQLitePreparedStatement::executeBound(const std::optional& params, Execute&& execute) { std::lock_guard lock(_state->mutex); std::lock_guard connectionLock(_state->connection->mutex); @@ -407,13 +440,21 @@ NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optionalstatement.get()); + return execute(database, _state->statement.get()); } catch (...) { sqlite3_reset(_state->statement.get()); throw; } } +NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional& params) { + return executeBound(params, [](sqlite3* database, sqlite3_stmt* statement) { return executeStatement(database, statement); }); +} + +NitroSQLiteRawQueryResult SQLitePreparedStatement::executeRaw(const std::optional& params) { + return executeBound(params, executeStatementRaw); +} + void SQLitePreparedStatement::finalize() { std::lock_guard lock(_state->mutex); std::lock_guard connectionLock(_state->connection->mutex); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp index 1bbd578a..9631c34e 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp @@ -2,6 +2,7 @@ #include "NitroSQLiteDatabaseConnections.hpp" #include "NitroSQLiteQueryResult.hpp" +#include "NitroSQLiteRawQueryResult.hpp" #include "sqlite/sqlite3.h" #include #include @@ -26,6 +27,8 @@ class SQLitePreparedStatement { /** Reset and execute with new bindings. Throws after finalization or connection closure. */ NitroSQLiteQueryResult execute(const std::optional& params); + /** Reset and execute with new bindings, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::optional& params); /** Release the native statement. Repeated calls are safe. */ void finalize(); /** Check whether the native statement has been released. */ @@ -38,6 +41,9 @@ class SQLitePreparedStatement { explicit SQLitePreparedStatement(std::shared_ptr state); + template + auto executeBound(const std::optional& params, Execute&& execute); + std::shared_ptr _state; friend std::shared_ptr sqlitePrepare(const SQLiteConnectionPtr& connection, const std::string& query); @@ -59,6 +65,10 @@ void sqliteDetachDb(const SQLiteConnectionPtr& connection, const std::string& al NitroSQLiteQueryResult sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, const SQLiteRowBatchHandler& onRows = nullptr); +/** Execute one statement, returning rows as arrays of column values. */ +NitroSQLiteRawQueryResult sqliteExecuteRaw(const SQLiteConnectionPtr& connection, const std::string& query, + const std::optional& params); + SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params = std::nullopt); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp index e12712c9..9d1a0342 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp @@ -28,6 +28,14 @@ struct SQLiteQueryResults { std::shared_ptr data; }; +// Rows converted to JavaScript arrays of column values, which keep repeated column names apart. +struct SQLiteRawQueryResults { + SQLiteRawQueryResults() : data(std::make_shared()) {} + explicit SQLiteRawQueryResults(SQLiteQueryResults results) : data(std::move(results.data)) {} + + std::shared_ptr data; +}; + /** Receives leading result rows in batches while the statement still runs. */ using SQLiteRowBatchHandler = std::function; @@ -68,4 +76,30 @@ struct JSIConverter final { } }; +template <> +struct JSIConverter final { + static jsi::Value toJSI(jsi::Runtime& runtime, const rnnitrosqlite::SQLiteRawQueryResults& results) { + const auto& data = *results.data; + jsi::Array array(runtime, data.rows.size()); + for (size_t rowIndex = 0; rowIndex < data.rows.size(); rowIndex++) { + const auto& row = data.rows[rowIndex]; + jsi::Array values(runtime, row.size()); + for (size_t columnIndex = 0; columnIndex < row.size(); columnIndex++) { + values.setValueAtIndex(runtime, columnIndex, JSIConverter::toJSI(runtime, row[columnIndex])); + } + array.setValueAtIndex(runtime, rowIndex, std::move(values)); + } + return array; + } + + // Query results only travel from native code to JavaScript. + static rnnitrosqlite::SQLiteRawQueryResults fromJSI(jsi::Runtime&, const jsi::Value&) { + throw std::logic_error("SQLite query results cannot be passed from JavaScript"); + } + + static bool canConvert(jsi::Runtime&, const jsi::Value&) { + return false; + } +}; + } // namespace margelo::nitro diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index df6f21d9..fd2c3c5d 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -229,6 +229,26 @@ std::shared_ptr> HybridNitroSQLite::executeAsync }); }; +NitroSQLiteRawQueryResult HybridNitroSQLite::executeRaw(const std::string& dbName, const std::string& query, + const std::optional& params) { + return sqliteExecuteRaw(_connections.get(dbName), query, params); +} + +std::shared_ptr> HybridNitroSQLite::executeRawAsync(const std::string& dbName, const std::string& query, + const std::optional& params) { + const auto copiedParams = copyArrayBufferParamsForBackground(params); + SQLiteConnectionPtr connection; + try { + connection = _connections.get(dbName); + } catch (...) { + return Promise::rejected(std::current_exception()); + } + + return enqueueConnectionOperation( + connection, + [connection, query, copiedParams]() -> NitroSQLiteRawQueryResult { return sqliteExecuteRaw(connection, query, copiedParams); }); +} + std::shared_ptr HybridNitroSQLite::prepare(const std::string& dbName, const std::string& query) { return std::make_shared(sqlitePrepare(_connections.get(dbName), query)); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index aca8e47d..72c9f6f2 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -73,6 +73,14 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { const std::optional& params, const std::optional& onRows) override; + /** Execute one SQL statement on the calling thread, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::string& dbName, const std::string& query, + const std::optional& params) override; + + /** Execute one SQL statement on a background thread, returning rows as arrays of column values. */ + std::shared_ptr> executeRawAsync(const std::string& dbName, const std::string& query, + const std::optional& params) override; + /** Prepare one SQL statement for repeated execution on an open connection. */ std::shared_ptr prepare(const std::string& dbName, const std::string& query) override; diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp index 11caae01..70003b71 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp @@ -42,6 +42,19 @@ HybridNitroSQLitePreparedStatement::executeAsync(const std::optional NitroSQLiteQueryResult { return statement->execute(copiedParams); }); } +NitroSQLiteRawQueryResult HybridNitroSQLitePreparedStatement::executeRaw(const std::optional& params) { + return _statement->executeRaw(params); +} + +std::shared_ptr> +HybridNitroSQLitePreparedStatement::executeRawAsync(const std::optional& params) { + const auto copiedParams = copyArrayBufferParamsForBackground(params); + const auto statement = _statement; + + return Promise::async( + [statement, copiedParams]() -> NitroSQLiteRawQueryResult { return statement->executeRaw(copiedParams); }); +} + void HybridNitroSQLitePreparedStatement::finalize() { _statement->finalize(); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp index 966acfab..c5e65822 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp @@ -17,6 +17,10 @@ class HybridNitroSQLitePreparedStatement : public HybridNitroSQLitePreparedState NitroSQLiteQueryResult execute(const std::optional& params) override; /** Execute with new bindings on a background thread. */ std::shared_ptr> executeAsync(const std::optional& params) override; + /** Execute with new bindings on the calling thread, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::optional& params) override; + /** Execute with new bindings on a background thread, returning rows as arrays of column values. */ + std::shared_ptr> executeRawAsync(const std::optional& params) override; /** Release the native statement. */ void finalize() override; /** Check whether the native statement has been released. */ diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp index 906479d3..fefa6ac9 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp @@ -17,6 +17,8 @@ namespace margelo::nitro::rnnitrosqlite { prototype.registerHybridGetter("isFinalized", &HybridNitroSQLitePreparedStatementSpec::getIsFinalized); prototype.registerHybridMethod("execute", &HybridNitroSQLitePreparedStatementSpec::execute); prototype.registerHybridMethod("executeAsync", &HybridNitroSQLitePreparedStatementSpec::executeAsync); + prototype.registerHybridMethod("executeRaw", &HybridNitroSQLitePreparedStatementSpec::executeRaw); + prototype.registerHybridMethod("executeRawAsync", &HybridNitroSQLitePreparedStatementSpec::executeRawAsync); prototype.registerHybridMethod("finalize", &HybridNitroSQLitePreparedStatementSpec::finalize); }); } diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp index 5ede22ad..9fde1d9f 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp @@ -15,6 +15,8 @@ // Forward declaration of `NitroSQLiteQueryResult` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } +// Forward declaration of `NitroSQLiteRawQueryResult` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteRawQueryResult; } #include "NitroSQLiteQueryResult.hpp" #include @@ -24,6 +26,7 @@ namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } #include #include #include +#include "NitroSQLiteRawQueryResult.hpp" namespace margelo::nitro::rnnitrosqlite { @@ -58,6 +61,8 @@ namespace margelo::nitro::rnnitrosqlite { // Methods virtual NitroSQLiteQueryResult execute(const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr> executeAsync(const std::optional, std::string, double>>>>& params) = 0; + virtual NitroSQLiteRawQueryResult executeRaw(const std::optional, std::string, double>>>>& params) = 0; + virtual std::shared_ptr> executeRawAsync(const std::optional, std::string, double>>>>& params) = 0; virtual void finalize() = 0; protected: diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp index 1297822c..a5cf0e27 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp @@ -23,6 +23,8 @@ namespace margelo::nitro::rnnitrosqlite { prototype.registerHybridMethod("detach", &HybridNitroSQLiteSpec::detach); prototype.registerHybridMethod("execute", &HybridNitroSQLiteSpec::execute); prototype.registerHybridMethod("executeAsync", &HybridNitroSQLiteSpec::executeAsync); + prototype.registerHybridMethod("executeRaw", &HybridNitroSQLiteSpec::executeRaw); + prototype.registerHybridMethod("executeRawAsync", &HybridNitroSQLiteSpec::executeRawAsync); prototype.registerHybridMethod("prepare", &HybridNitroSQLiteSpec::prepare); prototype.registerHybridMethod("executeBatch", &HybridNitroSQLiteSpec::executeBatch); prototype.registerHybridMethod("executeBatchAsync", &HybridNitroSQLiteSpec::executeBatchAsync); diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp index 87856cee..b49be09b 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp @@ -15,6 +15,8 @@ // Forward declaration of `NitroSQLiteQueryResult` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } +// Forward declaration of `NitroSQLiteRawQueryResult` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteRawQueryResult; } // Forward declaration of `HybridNitroSQLitePreparedStatementSpec` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { class HybridNitroSQLitePreparedStatementSpec; } // Forward declaration of `BatchQueryResult` to properly resolve imports. @@ -34,6 +36,7 @@ namespace margelo::nitro::rnnitrosqlite { struct FileLoadResult; } #include #include "NitroSQLiteQueryResults.hpp" #include +#include "NitroSQLiteRawQueryResult.hpp" #include #include "HybridNitroSQLitePreparedStatementSpec.hpp" #include "BatchQueryResult.hpp" @@ -80,6 +83,8 @@ namespace margelo::nitro::rnnitrosqlite { virtual void detach(const std::string& mainDbName, const std::string& alias) = 0; virtual NitroSQLiteQueryResult execute(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr> executeAsync(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params, const std::optional>& onRows) = 0; + virtual NitroSQLiteRawQueryResult executeRaw(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; + virtual std::shared_ptr> executeRawAsync(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr prepare(const std::string& dbName, const std::string& query) = 0; virtual BatchQueryResult executeBatch(const std::string& dbName, const std::vector& commands) = 0; virtual std::shared_ptr> executeBatchAsync(const std::string& dbName, const std::vector& commands) = 0; diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp new file mode 100644 index 00000000..dfed87c6 --- /dev/null +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp @@ -0,0 +1,98 @@ +/// +/// NitroSQLiteRawQueryResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include +#include "NitroSQLiteQueryResults.hpp" + +namespace margelo::nitro::rnnitrosqlite { + + /** + * A struct which can be represented as a JavaScript object (NitroSQLiteRawQueryResult). + */ + struct NitroSQLiteRawQueryResult final { + public: + double rowsAffected SWIFT_PRIVATE; + std::optional insertId SWIFT_PRIVATE; + std::vector columnNames SWIFT_PRIVATE; + margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults rows SWIFT_PRIVATE; + + public: + NitroSQLiteRawQueryResult() = default; + explicit NitroSQLiteRawQueryResult(double rowsAffected, std::optional insertId, std::vector columnNames, margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults rows): rowsAffected(rowsAffected), insertId(insertId), columnNames(columnNames), rows(rows) {} + + public: + // NitroSQLiteRawQueryResult is not equatable because these properties are not equatable: rows + }; + +} // namespace margelo::nitro::rnnitrosqlite + +namespace margelo::nitro { + + // C++ NitroSQLiteRawQueryResult <> JS NitroSQLiteRawQueryResult (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "insertId"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "columnNames"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rows"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected"), JSIConverter::toJSI(runtime, arg.rowsAffected)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "insertId"), JSIConverter>::toJSI(runtime, arg.insertId)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "columnNames"), JSIConverter>::toJSI(runtime, arg.columnNames)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rows"), JSIConverter::toJSI(runtime, arg.rows)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "insertId")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "columnNames")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rows")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts index 3c52ddf1..9309d54f 100644 --- a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts +++ b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts @@ -8,6 +8,8 @@ export const HybridNitroSQLite = { detach: jest.fn(), execute: jest.fn(), executeAsync: jest.fn(), + executeRaw: jest.fn(), + executeRawAsync: jest.fn(), prepare: jest.fn(), executeBatch: jest.fn(), executeBatchAsync: jest.fn(), diff --git a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts index 1cfe6048..299207ae 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts @@ -18,6 +18,7 @@ import { NitroSQLiteExceptionType as ExceptionTypeFromErrors, } from '../errors' import { HybridNitroSQLite } from '../nitro' +import { executeRaw, executeRawAsync } from '../operations/execute' import { init as androidInit } from '../OnLoad.android' import { init as iosInit } from '../OnLoad' @@ -32,6 +33,8 @@ describe('entrypoints', () => { expect(NitroSQLite.native).toBe(HybridNitroSQLite) expect(NitroSQLite.open).toBe(open) expect(NitroSQLite.open).not.toBe(HybridNitroSQLite.open) + expect(NitroSQLite.executeRaw).toBe(executeRaw) + expect(NitroSQLite.executeRawAsync).toBe(executeRawAsync) expect(iosInit()).toBeUndefined() }) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts new file mode 100644 index 00000000..0982390e --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts @@ -0,0 +1,112 @@ +jest.mock('../nitro') + +import { NitroSQLite } from '../index' +import { HybridNitroSQLite } from '../nitro' +import { + closeDatabaseQueue, + isDatabaseOpen, + openDatabaseQueue, +} from '../DatabaseQueue' +import NitroSQLiteError from '../NitroSQLiteError' +import { deferred, nativeRawResult, nativeResult } from './testUtils' + +const dbName = 'execute-raw-test' +const query = 'SELECT ? AS value' +const raw = nativeRawResult(['value'], [[7]]) + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest.mocked(HybridNitroSQLite.executeRawAsync).mockResolvedValue(raw) + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + jest.mocked(HybridNitroSQLite.executeAsync).mockResolvedValue(nativeResult()) +}) + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +it('forwards unmanaged raw queries and parameters without adapting their rows', async () => { + expect(NitroSQLite.executeRaw(dbName, query, [7])).toBe(raw) + await expect(NitroSQLite.executeRawAsync(dbName, query, [7])).resolves.toBe( + raw, + ) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith(dbName, query, [7]) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + query, + [7], + ) +}) + +it('rejects global synchronous raw queries while managed work is pending', async () => { + openDatabaseQueue(dbName) + expect(NitroSQLite.executeRaw(dbName, query)).toBe(raw) + + const gate = deferred() + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockReturnValueOnce(gate.promise) + const pending = NitroSQLite.executeRawAsync(dbName, query) + try { + expect(() => NitroSQLite.executeRaw(dbName, 'SELECT blocked')).toThrow( + 'busy', + ) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledTimes(1) + } finally { + gate.resolve(raw) + await pending + } +}) + +it('keeps global raw queries outside an active managed transaction', async () => { + openDatabaseQueue(dbName) + const entered = deferred() + const finish = deferred() + const transaction = NitroSQLite.transaction(dbName, async () => { + entered.resolve() + await finish.promise + }) + await entered.promise + + const pending = NitroSQLite.executeRawAsync(dbName, query) + try { + expect(() => NitroSQLite.executeRaw(dbName, query)).toThrow('busy') + expect(HybridNitroSQLite.executeRaw).not.toHaveBeenCalled() + expect(HybridNitroSQLite.executeRawAsync).not.toHaveBeenCalled() + } finally { + finish.resolve() + await transaction + await pending + } + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + query, + undefined, + ) + expect( + jest.mocked(HybridNitroSQLite.execute).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(HybridNitroSQLite.executeRawAsync).mock.invocationCallOrder[0]!, + ) +}) + +it('normalizes native errors and releases the managed queue after failure', async () => { + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => NitroSQLite.executeRaw(dbName, query)).toThrow(NitroSQLiteError) + expect(NitroSQLite.executeRaw(dbName, query)).toBe(raw) + + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockRejectedValueOnce('raw async failed') + await expect( + NitroSQLite.executeRawAsync(dbName, query), + ).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'raw async failed', + }) + await expect(NitroSQLite.executeRawAsync(dbName, query)).resolves.toBe(raw) +}) 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 24e14d71..64b55214 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts @@ -29,6 +29,86 @@ describe('Node SQLite mock', () => { expect(NitroSQLite.open).toBe(open) }) + it('returns raw rows that keep repeated column names', async () => { + const database = open({ name: 'raw' }) + database.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)') + database.execute( + 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT, owner_id INTEGER, photo BLOB)', + ) + database.execute( + "INSERT INTO users (id, name) VALUES (1, 'Dave'), (2, 'Ritesh')", + ) + database.execute( + 'INSERT INTO pets (id, name, owner_id, photo) VALUES (?, ?, ?, ?)', + [7, 'Rex', 1, new Uint8Array([1, 2]).buffer], + ) + const query = + 'SELECT users.id, users.name, pets.id, pets.name, pets.photo FROM users LEFT JOIN pets ON pets.owner_id = users.id ORDER BY users.id' + + const result = database.executeRaw(query) + expect(result.columnNames).toEqual(['id', 'name', 'id', 'name', 'photo']) + expect(result.rows).toEqual([ + [1, 'Dave', 7, 'Rex', expect.any(ArrayBuffer)], + [2, 'Ritesh', null, null, null], + ]) + expect(new Uint8Array(result.rows[0]![4] as ArrayBuffer)).toEqual( + new Uint8Array([1, 2]), + ) + await expect(database.executeRawAsync(query)).resolves.toEqual(result) + + expect( + database.executeRaw("INSERT INTO users (name) VALUES ('Ada')"), + ).toMatchObject({ rowsAffected: 1, insertId: 3, columnNames: [], rows: [] }) + }) + + it('switches a prepared statement between raw and object rows', async () => { + const database = open({ name: 'raw-prepared' }) + const statement = database.prepare('SELECT ? AS a, ? AS b') + + expect(statement.executeRaw([1, 2]).rows).toEqual([[1, 2]]) + expect(statement.execute([3, 4]).results).toEqual([{ a: 3, b: 4 }]) + await expect(statement.executeRawAsync([5, 6])).resolves.toMatchObject({ + columnNames: ['a', 'b'], + rows: [[5, 6]], + }) + statement.finalize() + }) + + it('runs raw queries inside transactions', async () => { + const database = open({ name: 'raw-transaction' }) + database.execute('CREATE TABLE items (value INTEGER)') + + await database.transaction(async (tx) => { + tx.executeRaw('INSERT INTO items (value) VALUES (?)', [1]) + expect( + (await tx.executeRawAsync('SELECT value FROM items')).rows, + ).toEqual([[1]]) + }) + }) + + it.each([0, 1])( + 'refreshes raw prepared column names after a schema change, id=%s', + async (id) => { + const database = open({ name: 'raw-schema-change' }) + database.execute('CREATE TABLE items (id INTEGER)') + database.execute('INSERT INTO items VALUES (1)') + const statement = database.prepare('SELECT * FROM items WHERE id = ?') + database.execute( + "ALTER TABLE items ADD COLUMN label TEXT DEFAULT 'added'", + ) + + const result = statement.executeRaw([id]) + expect(result.columnNames).toEqual(['id', 'label']) + expect(result.rows).toEqual(id === 1 ? [[1, 'added']] : []) + + database.execute('ALTER TABLE items RENAME COLUMN label TO name') + const asyncResult = await statement.executeRawAsync([id]) + expect(asyncResult.columnNames).toEqual(['id', 'name']) + expect(asyncResult.rows).toEqual(result.rows) + statement.finalize() + }, + ) + it('binds repeated named parameters in first-occurrence order', () => { const database = open({ name: 'named' }) expect( diff --git a/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts index 270d0e1d..f6902e6c 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts @@ -9,7 +9,7 @@ import { import type { NitroSQLitePreparedStatement } from '../specs/NitroSQLitePreparedStatement.nitro' import { prepare } from '../operations/prepare' import { open } from '../operations/session' -import { nativeResult } from './testUtils' +import { nativeRawResult, nativeResult } from './testUtils' const dbName = 'prepare-test' const query = 'SELECT ? AS value' @@ -35,6 +35,8 @@ function mockStatement(): NitroSQLitePreparedStatement { }, execute: jest.fn(() => nativeResult([{ value: 7 }])), executeAsync: jest.fn(async () => nativeResult([{ value: 8 }])), + executeRaw: jest.fn(() => nativeRawResult(['value'], [[9]])), + executeRawAsync: jest.fn(async () => nativeRawResult(['value'], [[10]])), finalize: jest.fn(() => { finalized = true }), @@ -95,6 +97,33 @@ it('keeps synchronous calls from overtaking an asynchronous execution', async () statement.finalize() }) +it('runs raw executions through the statement queue', async () => { + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + const statement = prepare(dbName, query) + + expect(statement.executeRaw([9]).rows).toEqual([[9]]) + const pending = statement.executeRawAsync([10]) + expect(() => statement.executeRaw()).toThrow('busy with another operation') + await expect(pending).resolves.toMatchObject({ + columnNames: ['value'], + rows: [[10]], + }) + expect(native.executeRaw).toHaveBeenCalledWith([9]) + expect(native.executeRawAsync).toHaveBeenCalledWith([10]) + + jest.mocked(native.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => statement.executeRaw()).toThrow('raw failed') + jest.mocked(native.executeRawAsync).mockRejectedValueOnce('async raw failed') + await expect(statement.executeRawAsync()).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async raw failed', + }) + statement.finalize() +}) + it('converts preparation and execution errors', async () => { jest.mocked(HybridNitroSQLite.prepare).mockImplementationOnce(() => { throw new Error('prepare failed') diff --git a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts index ab114c76..57af2526 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -5,7 +5,7 @@ import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' import { open } from '../operations/session' import { execute } from '../operations/execute' -import { deferred, nativeResult } from './testUtils' +import { deferred, nativeRawResult, nativeResult } from './testUtils' const dbName = 'session-test' const options = { name: dbName, location: 'data' } @@ -89,6 +89,45 @@ describe('open', () => { expect(isDatabaseOpen(dbName)).toBe(false) }) + it('routes raw queries through the connection queue', async () => { + const db = open(options) + const raw = nativeRawResult(['id', 'id'], [[1, 7]]) + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest.mocked(HybridNitroSQLite.executeRawAsync).mockResolvedValue(raw) + + expect(db.executeRaw('SELECT 1', [1])).toBe(raw) + await expect(db.executeRawAsync('SELECT 2')).resolves.toBe(raw) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith( + dbName, + 'SELECT 1', + [1], + ) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + 'SELECT 2', + undefined, + ) + + const pending = db.executeRawAsync('SELECT 3') + expect(() => db.executeRaw('SELECT 4')).toThrow( + 'busy with another operation', + ) + await pending + + jest.mocked(HybridNitroSQLite.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => db.executeRaw('SELECT 5')).toThrow('raw failed') + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockRejectedValueOnce('async raw failed') + await expect(db.executeRawAsync('SELECT 6')).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async raw failed', + }) + db.close() + }) + it('delegates transactions through the connection', async () => { const db = open(options) await expect( diff --git a/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts index ae4d24f2..08e14a25 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts @@ -1,4 +1,7 @@ -import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from '../specs/NitroSQLiteQueryResult.nitro' import type { SQLiteValue } from '../types' export function nativeResult( @@ -7,6 +10,17 @@ export function nativeResult( return { rowsAffected: results.length, results } as NitroSQLiteQueryResult } +export function nativeRawResult( + columnNames: string[] = [], + rows: SQLiteValue[][] = [], +): NitroSQLiteRawQueryResult { + return { + rowsAffected: rows.length, + columnNames, + rows, + } as NitroSQLiteRawQueryResult +} + export function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void diff --git a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts index 52d1cc6b..6553dd1e 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts @@ -4,7 +4,8 @@ import Database from 'better-sqlite3' import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' import { transaction } from '../operations/transaction' -import { deferred, nativeResult } from './testUtils' +import type { Transaction } from '../types' +import { deferred, nativeRawResult, nativeResult } from './testUtils' const dbName = 'transaction-test' @@ -61,6 +62,44 @@ describe('transaction', () => { ) }) + it('runs raw queries inside the transaction with the same guards', async () => { + const raw = nativeRawResult(['value'], [[1]]) + const pendingQuery = deferred() + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockReturnValue(pendingQuery.promise) + + let handle: Transaction | undefined + await transaction(dbName, async (tx) => { + handle = tx + expect(tx.executeRaw('SELECT ?', [1])).toBe(raw) + const pending = tx.executeRawAsync('SELECT ?', [2]) + expect(() => tx.executeRaw('SELECT sync')).toThrow( + 'Await all tx.executeAsync', + ) + pendingQuery.resolve(raw) + await expect(pending).resolves.toBe(raw) + }) + + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith( + dbName, + 'SELECT ?', + [1], + ) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + 'SELECT ?', + [2], + ) + expect(() => handle!.executeRaw('SELECT 1')).toThrow( + 'finalized transaction', + ) + expect(() => handle!.executeRawAsync('SELECT 1')).toThrow( + 'finalized transaction', + ) + }) + it('rejects synchronous transaction work until earlier async queries settle', async () => { const pendingQuery = deferred>() jest diff --git a/packages/react-native-nitro-sqlite/src/index.ts b/packages/react-native-nitro-sqlite/src/index.ts index 1ab6af75..173bcc42 100644 --- a/packages/react-native-nitro-sqlite/src/index.ts +++ b/packages/react-native-nitro-sqlite/src/index.ts @@ -1,7 +1,12 @@ import { transaction } from './operations/transaction' import { HybridNitroSQLite } from './nitro' import { open } from './operations/session' -import { execute, executeAsync } from './operations/execute' +import { + execute, + executeAsync, + executeRaw, + executeRawAsync, +} from './operations/execute' import { prepare } from './operations/prepare' import { init } from './OnLoad' import { executeBatch, executeBatchAsync } from './operations/executeBatch' @@ -20,6 +25,8 @@ export const NitroSQLite = { transaction, execute, executeAsync, + executeRaw, + executeRawAsync, prepare, executeBatch, executeBatchAsync, @@ -34,6 +41,8 @@ export type { NitroSQLiteQueryResult, NitroSQLiteQueryRows, NitroSQLiteQueryColumnMetadata, + NitroSQLiteRawQueryResult, + NitroSQLiteRawQueryRows, } from './specs/NitroSQLiteQueryResult.nitro' export type { TypeOrmNitroSQLiteConnection } from './typeORM' export type * from './types' diff --git a/packages/react-native-nitro-sqlite/src/mock.ts b/packages/react-native-nitro-sqlite/src/mock.ts index 0971a4a7..4ff95e89 100644 --- a/packages/react-native-nitro-sqlite/src/mock.ts +++ b/packages/react-native-nitro-sqlite/src/mock.ts @@ -18,7 +18,13 @@ import { import NitroSQLiteError from './NitroSQLiteError' import type { NitroSQLiteConnectionOptions, PreparedStatement } from './types' import type { MockConnection } from './mock/MockConnection' -import { executeBatch, executePrepared, executeQuery } from './mock/query' +import { + executeBatch, + executePrepared, + executePreparedRaw, + executeQuery, + executeRawQuery, +} from './mock/query' import { runTransaction } from './mock/transaction' export type { MockConnection } from './mock/MockConnection' @@ -110,6 +116,10 @@ export function open(options: NitroSQLiteConnectionOptions): MockConnection { runSync(() => executeQuery(database, query, params)), executeAsync: (query, params) => runAsync(() => executeQuery(database, query, params)), + executeRaw: (query, params) => + runSync(() => executeRawQuery(database, query, params)), + executeRawAsync: (query, params) => + runAsync(() => executeRawQuery(database, query, params)), executeBatch: (commands) => runSync(() => executeBatch(database, commands)), executeBatchAsync: (commands) => runAsync(() => executeBatch(database, commands), true), @@ -122,12 +132,19 @@ export function open(options: NitroSQLiteConnectionOptions): MockConnection { throw new NitroSQLiteError('Prepared statement is finalized.') return executePrepared(database, statement, params) } + const executeRaw: PreparedStatement['executeRaw'] = (params) => { + if (!statement) + throw new NitroSQLiteError('Prepared statement is finalized.') + return executePreparedRaw(database, statement, params) + } return { get isFinalized() { return statement === undefined }, execute: (params) => runSync(() => execute(params)), executeAsync: (params) => runAsync(() => execute(params), true), + executeRaw: (params) => runSync(() => executeRaw(params)), + executeRawAsync: (params) => runAsync(() => executeRaw(params), true), finalize: () => runSync(() => { // better-sqlite3 releases statement resources when the object is collected. diff --git a/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts index fbfc449a..c0c71ef9 100644 --- a/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts +++ b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts @@ -13,6 +13,8 @@ export type MockConnection = Pick< | 'delete' | 'execute' | 'executeAsync' + | 'executeRaw' + | 'executeRawAsync' | 'executeBatch' | 'executeBatchAsync' | 'prepare' diff --git a/packages/react-native-nitro-sqlite/src/mock/query.ts b/packages/react-native-nitro-sqlite/src/mock/query.ts index 05bf33b6..517bc48f 100644 --- a/packages/react-native-nitro-sqlite/src/mock/query.ts +++ b/packages/react-native-nitro-sqlite/src/mock/query.ts @@ -1,4 +1,5 @@ import type BetterSqlite3 from 'better-sqlite3' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import type { BatchQueryCommand, QueryResult, @@ -9,6 +10,7 @@ import type { type Database = InstanceType type Statement = BetterSqlite3.Statement> +type RawStatement = BetterSqlite3.Statement type BoundValue = string | number | Buffer | null type ExpandedBatchCommand = { query: string; params?: SQLiteQueryParams } @@ -37,6 +39,48 @@ export function executePrepared( } as QueryResult } +/** @internal Execute an already prepared statement, returning rows as arrays of column values. */ +export function executePreparedRaw( + database: Database, + statement: Statement, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + const bindings = getBindings(statement.source, params) + let columnNames: string[] = [] + let rows: SQLiteValue[][] = [] + if (statement.reader) { + try { + // better-sqlite3's types keep the object row type after raw(true). + const rawStatement = statement.raw(true) as unknown as RawStatement + const rawRows = rawStatement.all(...bindings) + // Execution may reprepare the statement after a schema change. + columnNames = statement.columns().map((column) => column.name) + rows = rawRows.map((row) => + row.map((value, index) => normalizeValue(value, columnNames[index]!)), + ) + } finally { + // Raw mode stays on the statement, and prepared statements also serve execute(). + statement.raw(false) + } + } else statement.run(...bindings) + const { rowsAffected, insertId } = getDatabaseChanges(database) + return { + rowsAffected, + insertId, + columnNames, + rows, + } as NitroSQLiteRawQueryResult +} + +/** @internal Execute one SQL statement and return rows as arrays of column values. */ +export function executeRawQuery( + database: Database, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + return executePreparedRaw(database, database.prepare(query), params) +} + /** @internal Execute one SQL statement and return Nitro-shaped data. */ export function executeQuery( database: Database, @@ -178,24 +222,28 @@ function toBoundValue(value: SQLiteValue): BoundValue { 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}.`) - } + result[key] = normalizeValue(value, key) } return result } +function normalizeValue(value: unknown, column: string): SQLiteValue { + if (Buffer.isBuffer(value)) { + const bytes = new ArrayBuffer(value.byteLength) + new Uint8Array(bytes).set(value) + return bytes + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + throw new Error(`Unsupported SQLite value in column ${column}.`) +} + function getChangeCount(database: Database): number { return getDatabaseChanges(database).rowsAffected } diff --git a/packages/react-native-nitro-sqlite/src/mock/transaction.ts b/packages/react-native-nitro-sqlite/src/mock/transaction.ts index a9099b62..c425cc0d 100644 --- a/packages/react-native-nitro-sqlite/src/mock/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/mock/transaction.ts @@ -1,7 +1,7 @@ import type BetterSqlite3 from 'better-sqlite3' import NitroSQLiteError from '../NitroSQLiteError' import type { QueryResultRow, SQLiteQueryParams, Transaction } from '../types' -import { executeQuery } from './query' +import { executeQuery, executeRawQuery } from './query' /** @internal Run a callback while the connection's exclusive queue item owns it. */ export async function runTransaction( @@ -21,6 +21,14 @@ export async function runTransaction( ) } } + const track = (operation: Promise): Promise => { + pending.add(operation) + operation.then( + () => pending.delete(operation), + () => pending.delete(operation), + ) + return operation + } const finish = (query: 'COMMIT' | 'ROLLBACK') => { assertSync() const result = executeQuery(database, query) @@ -37,15 +45,21 @@ export async function runTransaction( params?: SQLiteQueryParams, ) => { assertActive() - const operation = Promise.resolve().then(() => - executeQuery(database, query, params), + return track( + Promise.resolve().then(() => + executeQuery(database, query, params), + ), ) - pending.add(operation) - operation.then( - () => pending.delete(operation), - () => pending.delete(operation), + }, + executeRaw: (query, params) => { + assertSync() + return executeRawQuery(database, query, params) + }, + executeRawAsync: async (query, params) => { + assertActive() + return track( + Promise.resolve().then(() => executeRawQuery(database, query, params)), ) - return operation }, commit: () => finish('COMMIT'), rollback: () => finish('ROLLBACK'), diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index 47e0f86c..0aff4973 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -1,7 +1,10 @@ import { HybridNitroSQLite } from '../nitro' import type { QueryResult, QueryResultRow, SQLiteQueryParams } from '../types' import NitroSQLiteError from '../NitroSQLiteError' -import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from '../specs/NitroSQLiteQueryResult.nitro' import { isDatabaseOpen, queueStatementAsync, @@ -108,6 +111,94 @@ export async function executeAsyncNative( } } +/** Execute one SQL statement synchronously by database name, returning rows as arrays. + * Uses the default managed connection's queue when the database was opened through `open()`. + * Otherwise, calls the open native database directly. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + * @returns Rows and column names in SQL column order, with affected row count and insert ID. + * @throws NitroSQLiteError if the managed connection is busy or the query fails. + */ +export function executeRaw( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + if (!isDatabaseOpen(dbName)) { + return executeRawNative(dbName, query, params) + } + + return executeRawManaged(dbName, query, params) +} + +export function executeRawManaged( + dbName: string, + query: string, + params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, +): NitroSQLiteRawQueryResult { + return startOperationSync(queueKey, () => + executeRawNative(dbName, query, params), + ) +} + +export function executeRawNative( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + try { + return HybridNitroSQLite.executeRaw(dbName, query, params) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } +} + +/** Execute one SQL statement asynchronously by database name, returning rows as arrays. + * Uses the default managed connection's queue when the database was opened through `open()`. + * Otherwise, calls the open native database directly. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + * @returns A promise of rows and column names in SQL column order, with affected row count and insert ID. + * @throws NitroSQLiteError as a rejected promise if the query fails. + */ +export async function executeRawAsync( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): Promise { + if (!isDatabaseOpen(dbName)) { + return executeRawAsyncNative(dbName, query, params) + } + + return executeRawAsyncManaged(dbName, query, params) +} + +export async function executeRawAsyncManaged( + dbName: string, + query: string, + params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, +): Promise { + return queueStatementAsync(queueKey, () => + executeRawAsyncNative(dbName, query, params), + ) +} + +export async function executeRawAsyncNative( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): Promise { + try { + return await HybridNitroSQLite.executeRawAsync(dbName, query, params) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } +} + export function buildJSQueryResult( result: NitroSQLiteQueryResult, leadingRows?: Row[], diff --git a/packages/react-native-nitro-sqlite/src/operations/prepare.ts b/packages/react-native-nitro-sqlite/src/operations/prepare.ts index 3451a8df..ce0e5de7 100644 --- a/packages/react-native-nitro-sqlite/src/operations/prepare.ts +++ b/packages/react-native-nitro-sqlite/src/operations/prepare.ts @@ -6,6 +6,7 @@ import type { QueryResultRow, SQLiteQueryParams, } from '../types' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import { buildJSQueryResult } from './execute' import { queueOperationAsync, startOperationSync } from '../DatabaseQueue' import type { DatabaseQueueKey } from '../DatabaseQueue' @@ -55,6 +56,26 @@ export function prepare( throw NitroSQLiteError.fromError(error) } }, + executeRaw: (params?: SQLiteQueryParams): NitroSQLiteRawQueryResult => { + try { + return startOperationSync(queueKey, () => + nativeStatement.executeRaw(params), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, + executeRawAsync: async ( + params?: SQLiteQueryParams, + ): Promise => { + try { + return await queueOperationAsync(queueKey, () => + nativeStatement.executeRawAsync(params), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, finalize: () => { try { startOperationSync(queueKey, () => nativeStatement.finalize()) diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index cfc02703..7a84e004 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -9,7 +9,12 @@ import type { QueryResultRow, QueryResult, } from '../types' -import { executeAsyncManaged, executeManaged } from './execute' +import { + executeAsyncManaged, + executeManaged, + executeRawAsyncManaged, + executeRawManaged, +} from './execute' import { executeBatch, executeBatchAsync } from './executeBatch' import { prepare } from './prepare' import NitroSQLiteError from '../NitroSQLiteError' @@ -145,6 +150,14 @@ export function open( runOperation(() => executeAsyncManaged(connectionId, query, params, queueKey), ), + executeRaw: (query: string, params?: SQLiteQueryParams) => + runOperation(() => + executeRawManaged(connectionId, query, params, queueKey), + ), + executeRawAsync: async (query: string, params?: SQLiteQueryParams) => + runOperation(() => + executeRawAsyncManaged(connectionId, query, params, queueKey), + ), prepare: (query: string) => runOperation(() => prepare(connectionId, query, queueKey)), executeBatch: (commands: BatchQueryCommand[]) => diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index 9f879bf2..0c617ade 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -5,7 +5,13 @@ import type { QueryResult, QueryResultRow, } from '../types' -import { executeAsyncNative, executeNative } from './execute' +import { + executeAsyncNative, + executeNative, + executeRawAsyncNative, + executeRawNative, +} from './execute' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import NitroSQLiteError from '../NitroSQLiteError' import type { DatabaseQueueKey } from '../DatabaseQueue' @@ -44,29 +50,25 @@ export const transaction = async ( } } - const executeOnTransaction = ( - query: string, - params?: SQLiteQueryParams, - ): QueryResult => { + const throwIfNotActive = () => { if (state.current.kind !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) } + } + + const runOnTransaction = (run: () => Value): Value => { + throwIfNotActive() throwIfAsyncPending() - return executeNative(dbName, query, params) + return run() } - const executeAsyncOnTransaction = ( - query: string, - params?: SQLiteQueryParams, - ): Promise> => { - if (state.current.kind !== 'active') { - throw new NitroSQLiteError( - `Cannot execute query on finalized transaction: ${dbName}`, - ) - } - const pending = executeAsyncNative(dbName, query, params) + const runAsyncOnTransaction = ( + run: () => Promise, + ): Promise => { + throwIfNotActive() + const pending = run() pendingAsyncStatements.add(pending) pending.then( () => pendingAsyncStatements.delete(pending), @@ -75,6 +77,30 @@ export const transaction = async ( return pending } + const executeOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): QueryResult => + runOnTransaction(() => executeNative(dbName, query, params)) + + const executeAsyncOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): Promise> => + runAsyncOnTransaction(() => executeAsyncNative(dbName, query, params)) + + const executeRawOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): NitroSQLiteRawQueryResult => + runOnTransaction(() => executeRawNative(dbName, query, params)) + + const executeRawAsyncOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): Promise => + runAsyncOnTransaction(() => executeRawAsyncNative(dbName, query, params)) + const commit = () => { if (state.current.kind !== 'active') { throw new NitroSQLiteError( @@ -130,6 +156,8 @@ export const transaction = async ( commit, execute: executeOnTransaction, executeAsync: executeAsyncOnTransaction, + executeRaw: executeRawOnTransaction, + executeRawAsync: executeRawAsyncOnTransaction, rollback, }) diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts index 38f4986d..a9bd407f 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts @@ -8,6 +8,7 @@ import type { import type { NitroSQLiteQueryResult, NitroSQLiteQueryRows, + NitroSQLiteRawQueryResult, } from './NitroSQLiteQueryResult.nitro' import type { NitroSQLitePreparedStatement } from './NitroSQLitePreparedStatement.nitro' @@ -108,6 +109,28 @@ export interface NitroSQLite params?: SQLiteQueryParams, onRows?: (rows: NitroSQLiteQueryRows) => void, ): Promise + /** Execute one SQL statement synchronously on the calling thread, returning rows as arrays of column values. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Positional values bound to SQL placeholders. + * @returns Column names, rows as value arrays, affected row count, and insert ID. + */ + executeRaw( + dbName: string, + query: string, + params?: SQLiteQueryParams, + ): NitroSQLiteRawQueryResult + /** Execute one SQL statement on a background thread, returning rows as arrays of column values. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Positional values bound to SQL placeholders. + * @returns A promise of the native raw query result. + */ + executeRawAsync( + dbName: string, + query: string, + params?: SQLiteQueryParams, + ): Promise /** Prepare one SQL statement on an open native connection for repeated execution. * Finalize the returned statement before closing its connection. * @param dbName Name or ID of an open database connection. diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts index de545bd9..e4ce3c62 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts @@ -1,6 +1,9 @@ import type { HybridObject } from 'react-native-nitro-modules' import type { SQLiteQueryParams } from '../types' -import type { NitroSQLiteQueryResult } from './NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from './NitroSQLiteQueryResult.nitro' /** Native prepared statement returned by `NitroSQLite.native.prepare()`. */ export interface NitroSQLitePreparedStatement @@ -23,6 +26,18 @@ export interface NitroSQLitePreparedStatement * @returns A promise of the native query result. */ executeAsync(params?: SQLiteQueryParams): Promise + /** Execute on the calling thread like `execute`, returning rows as arrays of column values. + * @param params Values bound to positional placeholders. + * @returns Column names, rows as value arrays, affected row count, and insert ID. + */ + executeRaw(params?: SQLiteQueryParams): NitroSQLiteRawQueryResult + /** Execute on a background thread like `executeAsync`, returning rows as arrays of column values. + * @param params Values bound to positional placeholders. + * @returns A promise of the native raw query result. + */ + executeRawAsync( + params?: SQLiteQueryParams, + ): Promise /** Release the native statement. Repeated calls are safe. */ finalize(): void } diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts index efab64c1..970b86e9 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts @@ -24,6 +24,27 @@ export interface NitroSQLiteQueryResult { readonly metadata?: Record } +/** Result rows as arrays of column values, in column order. */ +export type NitroSQLiteRawQueryRows = CustomType< + SQLiteValue[][], + 'margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults', + { include: 'NitroSQLiteQueryResults.hpp' } +> + +/** Native result of one SQL statement, with rows as arrays of column values. */ +export interface NitroSQLiteRawQueryResult { + /** SQLite's latest row change count. For a read-only query it may reflect an earlier write. */ + readonly rowsAffected: number + /** Last insert row ID for this connection. It may refer to an earlier statement. */ + readonly insertId?: number + + /** Result column names in column order. Repeated names are kept. */ + readonly columnNames: string[] + + /** Rows as arrays of column values, in the order of `columnNames`. */ + readonly rows: NitroSQLiteRawQueryRows +} + // TODO: Investigate why this cannot be represented in Nitro // export type NitroQueryResultRow = { // [key: string]: SQLiteValue diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 7ce24aab..51448151 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -1,4 +1,7 @@ -import type { NitroSQLiteQueryResult } from './specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from './specs/NitroSQLiteQueryResult.nitro' /** Options for `open()` and `NitroSQLite.open()`. */ export interface NitroSQLiteConnectionOptions { @@ -63,6 +66,10 @@ export interface NitroSQLiteConnection { execute: ExecuteQuery /** Queue one SQL statement and resolve with its result. */ executeAsync: ExecuteAsyncQuery + /** Execute one SQL statement synchronously, returning rows as arrays of column values. Throws while the connection is busy. */ + executeRaw: ExecuteRawQuery + /** Queue one SQL statement and resolve with rows as arrays of column values. */ + executeRawAsync: ExecuteRawAsyncQuery /** * Prepare one SQL statement on this connection for repeated execution. * Finalize the returned statement before closing the connection. @@ -174,6 +181,25 @@ export type ExecuteAsyncQuery = ( params?: SQLiteQueryParams, ) => Promise> +/** Execute one SQL statement synchronously and return rows as arrays of column values. + * Repeated column names are kept, in the order of `columnNames`. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + */ +export type ExecuteRawQuery = ( + query: string, + params?: SQLiteQueryParams, +) => NitroSQLiteRawQueryResult + +/** Queue one SQL statement and resolve with rows as arrays of column values. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + */ +export type ExecuteRawAsyncQuery = ( + query: string, + params?: SQLiteQueryParams, +) => Promise + /** * A reusable SQL statement bound to the connection that prepared it. Each * execution resets the statement and its bindings before applying new values. @@ -186,6 +212,10 @@ export interface PreparedStatement { execute: ExecutePreparedStatement /** Queue execution on a background thread and resolve with the query result. */ executeAsync: ExecutePreparedStatementAsync + /** Execute on the calling thread like `execute`, returning rows as arrays of column values. */ + executeRaw: ExecuteRawPreparedStatement + /** Queue execution like `executeAsync` and resolve with rows as arrays of column values. */ + executeRawAsync: ExecuteRawPreparedStatementAsync /** Release the native statement. Calling this more than once is safe. */ finalize(): void } @@ -212,6 +242,23 @@ export type ExecutePreparedStatementAsync = < params?: SQLiteQueryParams, ) => Promise> +/** + * Execute a prepared statement and return rows as arrays of column values. + * Each call replaces the previous parameter bindings. + * @param params Values bound to the statement's positional placeholders. + */ +export type ExecuteRawPreparedStatement = ( + params?: SQLiteQueryParams, +) => NitroSQLiteRawQueryResult + +/** + * Queue a prepared statement execution and resolve with rows as arrays of column values. + * @param params Values bound to the statement's positional placeholders. + */ +export type ExecuteRawPreparedStatementAsync = ( + params?: SQLiteQueryParams, +) => Promise + /** Handle valid only while its transaction callback is active. */ export interface Transaction { /** Commit now. Marks completion only after SQLite accepts COMMIT. @@ -229,6 +276,10 @@ export interface Transaction { execute: ExecuteQuery /** Execute within this transaction on a background thread. */ executeAsync: ExecuteAsyncQuery + /** Execute within this transaction on the calling thread, returning rows as arrays of column values. */ + executeRaw: ExecuteRawQuery + /** Execute within this transaction on a background thread, returning rows as arrays of column values. */ + executeRawAsync: ExecuteRawAsyncQuery } /** One command in a batch. A nested `params` array repeats the query for each parameter set. */