diff --git a/README.md b/README.md index f650f2d5..2a7b8433 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ To open another connection to the same file, use `open({ name: 'myDb.sqlite', co | Method | Sync | Async | Description | |--------|------|-------|-------------| | **Execute** | `db.execute(query, params?)` | `db.executeAsync(query, params?)` | Run a single SQL statement. | +| **Prepared statement** | `db.prepare(query)` | Statement `executeAsync(params?)` | Prepare once and execute repeatedly with different parameters. | | **Batch** | `db.executeBatch(commands)` | `db.executeBatchAsync(commands)` | Run multiple statements in one transaction. | | **Load file** | `db.loadFile(path)` | `db.loadFileAsync(path)` | Execute SQL from a file. | | **Transaction** | — | `db.transaction(async (tx) => { ... })` | Run multiple statements in a transaction (async only). | @@ -160,6 +161,21 @@ const { rowsAffected } = db.executeBatch(commands) // Or: await db.executeBatchAsync(commands) ``` +## Prepared statements + +Use `db.prepare()` when the same SQL statement is executed repeatedly with different parameters. Call `finalize()` once the statement is no longer needed, and always finalize it before closing its database connection. + +```typescript +const insertUser = db.prepare( + 'INSERT INTO users (id, name) VALUES (?, ?)', +) + +insertUser.execute([1, 'Ada']) +await insertUser.executeAsync([2, 'Grace']) + +insertUser.finalize() +``` + # Column metadata When you need column types or names for the result set, use the `metadata` field on the query result. Keys are column names; values include `name`, `type` (e.g. from `ColumnType`), and `index`. diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index f2735689..e120e9d4 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -3,6 +3,7 @@ import { setupTestDb } from './common' import registerExecuteUnitTests from './specs/operations/execute.spec' import registerTransactionUnitTests from './specs/operations/transaction.spec' import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' +import registerPreparedStatementUnitTests from './specs/operations/preparedStatement.spec' import registerTypeORMUnitTestsSpecs from './specs/typeorm.spec' import registerDatabaseQueueUnitTests from './specs/DatabaseQueue.spec' import registerSqliteVecUnitTestsSpecs from './specs/sqlite-vec.spec' @@ -15,6 +16,10 @@ export function registerUnitTests() { describe('operations/execute.spec.ts', registerExecuteUnitTests) describe('operations/transaction.spec.ts', registerTransactionUnitTests) describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests) + describe( + 'operations/preparedStatement.spec.ts', + registerPreparedStatementUnitTests, + ) describe('DatabaseQueue.spec.ts', registerDatabaseQueueUnitTests) describe('Connections.spec.ts', registerConnectionUnitTests) describe('benchmarkDatabase.spec.ts', registerBenchmarkDatabaseUnitTests) diff --git a/example/tests/unit/specs/operations/preparedStatement.spec.ts b/example/tests/unit/specs/operations/preparedStatement.spec.ts new file mode 100644 index 00000000..15d01ae1 --- /dev/null +++ b/example/tests/unit/specs/operations/preparedStatement.spec.ts @@ -0,0 +1,125 @@ +import { + chance, + expect, + isNitroSQLiteError, + TEST_ERROR_CODES, +} from '@tests/unit/common' +import { describe, it } from '@tests/TestApi' +import { testDb } from '@tests/db' + +export default function registerPreparedStatementUnitTests() { + describe('prepared statements', () => { + it('reuses one statement with different parameter values', () => { + const insert = testDb.prepare( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + ) + const firstUser = { + id: chance.integer(), + name: chance.name(), + age: chance.integer(), + networth: chance.floating(), + } + const secondUser = { + id: chance.integer(), + name: chance.name(), + age: chance.integer(), + networth: chance.floating(), + } + + expect(insert.isFinalized).toBe(false) + expect( + insert.execute([ + firstUser.id, + firstUser.name, + firstUser.age, + firstUser.networth, + ]).rowsAffected, + ).toBe(1) + expect( + insert.execute([ + secondUser.id, + secondUser.name, + secondUser.age, + secondUser.networth, + ]).rowsAffected, + ).toBe(1) + + const select = testDb.prepare('SELECT * FROM User WHERE id = ?') + expect(select.execute([firstUser.id]).rows._array).toEqual([firstUser]) + expect(select.execute([secondUser.id]).rows._array).toEqual([secondUser]) + + insert.finalize() + select.finalize() + expect(insert.isFinalized).toBe(true) + expect(select.isFinalized).toBe(true) + }) + + it('executes asynchronously', async () => { + const id = chance.integer() + const statement = testDb.prepare( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + ) + + const result = await statement.executeAsync([ + id, + chance.name(), + chance.integer(), + chance.floating(), + ]) + + expect(result.rowsAffected).toBe(1) + expect( + testDb.execute('SELECT * FROM User WHERE id = ?', [id]).rows.length, + ).toBe(1) + statement.finalize() + }) + + it('can run again after an execution error', () => { + const statement = testDb.prepare( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + ) + statement.execute([42, 'Ada', 37, 1]) + + try { + statement.execute([42, 'Duplicate', 37, 1]) + throw new Error('Expected duplicate key to fail') + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + } + + expect(statement.execute([43, 'Grace', 38, 2]).rowsAffected).toBe(1) + statement.finalize() + }) + + it('rejects execution after finalization', () => { + const statement = testDb.prepare('SELECT * FROM User') + statement.finalize() + + try { + statement.execute() + throw new Error('Expected execution to throw after finalization') + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + if (isNitroSQLiteError(error)) { + expect(error.message).toContain( + 'Prepared statement has been finalized', + ) + } + } + }) + + it('rejects a query that contains no SQL', () => { + for (const query of ['', '-- just a comment']) { + try { + testDb.prepare(query) + throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } catch (error) { + if (!isNitroSQLiteError(error)) throw error + expect(error.message).toContain( + 'Query does not contain any SQL statement', + ) + } + } + }) + }) +} diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index 7970699a..82ca71f0 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -129,27 +129,33 @@ void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const } void bindStatement(sqlite3_stmt* statement, const SQLiteQueryParams& values) { - for (int valueIndex = 0; valueIndex < values.size(); valueIndex++) { + for (size_t valueIndex = 0; valueIndex < values.size(); valueIndex++) { int sqliteIndex = valueIndex + 1; - SQLiteValue value = values.at(valueIndex); + const SQLiteValue& value = values.at(valueIndex); + int bindStatus = SQLITE_OK; + if (std::holds_alternative(value)) { - sqlite3_bind_null(statement, sqliteIndex); + bindStatus = sqlite3_bind_null(statement, sqliteIndex); } else if (std::holds_alternative(value)) { - sqlite3_bind_int(statement, sqliteIndex, std::get(value)); + bindStatus = sqlite3_bind_int(statement, sqliteIndex, std::get(value)); } else if (std::holds_alternative(value)) { // Bind whole numbers as INTEGER so vec0 rowid/pk/partition (which reject REAL) work; SQLite still coerces to REAL for REAL columns. double doubleValue = std::get(value); if (std::trunc(doubleValue) == doubleValue && doubleValue >= kInt64MinAsDouble && doubleValue < kInt64UpperBoundAsDouble) { - sqlite3_bind_int64(statement, sqliteIndex, static_cast(doubleValue)); + bindStatus = sqlite3_bind_int64(statement, sqliteIndex, static_cast(doubleValue)); } else { - sqlite3_bind_double(statement, sqliteIndex, doubleValue); + bindStatus = sqlite3_bind_double(statement, sqliteIndex, doubleValue); } } else if (std::holds_alternative(value)) { - const auto stringValue = std::get(value); - sqlite3_bind_text(statement, sqliteIndex, stringValue.c_str(), stringValue.length(), SQLITE_TRANSIENT); + const auto& stringValue = std::get(value); + bindStatus = sqlite3_bind_text(statement, sqliteIndex, stringValue.c_str(), stringValue.length(), SQLITE_TRANSIENT); } else if (std::holds_alternative>(value)) { - const auto arrayBufferValue = std::get>(value); - sqlite3_bind_blob(statement, sqliteIndex, arrayBufferValue->data(), arrayBufferValue->size(), SQLITE_STATIC); + const auto& arrayBufferValue = std::get>(value); + bindStatus = sqlite3_bind_blob(statement, sqliteIndex, arrayBufferValue->data(), arrayBufferValue->size(), SQLITE_TRANSIENT); + } + + if (bindStatus != SQLITE_OK) { + throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(sqlite3_db_handle(statement))); } } } @@ -175,6 +181,12 @@ namespace { throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(db)); } + // sqlite3_prepare_v2 reports SQLITE_OK with a null statement when the query holds no SQL, + // such as an empty string or nothing but comments. + if (!statement) { + throw NitroSQLiteException::SqlExecution("Query does not contain any SQL statement"); + } + if (params) { bindStatement(statement.get(), *params); } @@ -200,6 +212,67 @@ namespace { } } + std::shared_ptr executeStatement(sqlite3* db, sqlite3_stmt* statement) { + SQLiteQueryResults results; + + consumeStatement(db, statement, [&](sqlite3_stmt* currentStatement) { + SQLiteQueryResultRow row; + int count = sqlite3_column_count(currentStatement); + + for (int i = 0; i < count; i++) { + int columnType = sqlite3_column_type(currentStatement, i); + std::string columnName = sqlite3_column_name(currentStatement, i); + + switch (columnType) { + case SQLITE_INTEGER: + case SQLITE_FLOAT: + row[columnName] = sqlite3_column_double(currentStatement, i); + break; + case SQLITE_TEXT: { + auto columnValue = reinterpret_cast(sqlite3_column_text(currentStatement, i)); + row[columnName] = columnValue; + break; + } + case SQLITE_BLOB: { + int blobSize = sqlite3_column_bytes(currentStatement, i); + const void* blob = sqlite3_column_blob(currentStatement, i); + if (blobSize > 0) { + const auto* blobData = reinterpret_cast(blob); + row[columnName] = ArrayBuffer::copy(blobData, static_cast(blobSize)); + } else { + row[columnName] = ArrayBuffer::allocate(0); + } + break; + } + case SQLITE_NULL: + default: + row[columnName] = NullType::null; + break; + } + } + + results.push_back(std::move(row)); + }); + + std::optional metadata = std::nullopt; + int count = sqlite3_column_count(statement); + for (int i = 0; i < count; i++) { + std::string columnName = sqlite3_column_name(statement, i); + ColumnType columnDeclaredType = mapSQLiteTypeToColumnType(sqlite3_column_decltype(statement, i)); + auto columnMeta = NitroSQLiteQueryColumnMetadata(columnName, std::move(columnDeclaredType), i); + + if (!metadata) { + metadata = std::make_optional(); + } + metadata->insert({columnName, std::move(columnMeta)}); + } + + int rowsAffected = sqlite3_changes(db); + long long latestInsertRowId = sqlite3_last_insert_rowid(db); + return std::make_shared(std::move(results), static_cast(latestInsertRowId), rowsAffected, + std::move(metadata)); + } + } // namespace SQLiteConnectionPtr sqliteGetOpenDatabase(const std::string& dbName) { @@ -220,64 +293,7 @@ std::shared_ptr sqliteExecute(const SQLiteConnecti } auto statement = prepareStatement(db, query, params); - SQLiteQueryResults results; - - consumeStatement(db, statement.get(), [&](sqlite3_stmt* currentStatement) { - SQLiteQueryResultRow row; - int count = sqlite3_column_count(currentStatement); - - for (int i = 0; i < count; i++) { - int columnType = sqlite3_column_type(currentStatement, i); - std::string columnName = sqlite3_column_name(currentStatement, i); - - switch (columnType) { - case SQLITE_INTEGER: - case SQLITE_FLOAT: - row[columnName] = sqlite3_column_double(currentStatement, i); - break; - case SQLITE_TEXT: { - auto columnValue = reinterpret_cast(sqlite3_column_text(currentStatement, i)); - row[columnName] = columnValue; - break; - } - case SQLITE_BLOB: { - int blobSize = sqlite3_column_bytes(currentStatement, i); - const void* blob = sqlite3_column_blob(currentStatement, i); - if (blobSize > 0) { - const auto* blobData = reinterpret_cast(blob); - row[columnName] = ArrayBuffer::copy(blobData, static_cast(blobSize)); - } else { - row[columnName] = ArrayBuffer::allocate(0); - } - break; - } - case SQLITE_NULL: - default: - row[columnName] = NullType::null; - break; - } - } - - results.push_back(std::move(row)); - }); - - std::optional metadata = std::nullopt; - int count = sqlite3_column_count(statement.get()); - for (int i = 0; i < count; i++) { - std::string columnName = sqlite3_column_name(statement.get(), i); - ColumnType columnDeclaredType = mapSQLiteTypeToColumnType(sqlite3_column_decltype(statement.get(), i)); - auto columnMeta = NitroSQLiteQueryColumnMetadata(columnName, std::move(columnDeclaredType), i); - - if (!metadata) { - metadata = std::make_optional(); - } - metadata->insert({columnName, std::move(columnMeta)}); - } - - int rowsAffected = sqlite3_changes(db); - long long latestInsertRowId = sqlite3_last_insert_rowid(db); - return std::make_shared(std::move(results), static_cast(latestInsertRowId), rowsAffected, - std::move(metadata)); + return executeStatement(db, statement.get()); } SQLiteOperationResult sqliteExecuteCommand(const std::string& dbName, const std::string& query, @@ -301,4 +317,80 @@ SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection return {.rowsAffected = isReadOnly ? 0 : sqlite3_changes(db)}; } +struct SQLitePreparedStatement::State { + State(SQLiteConnectionPtr connection, SQLiteStatement statement) : connection(std::move(connection)), statement(std::move(statement)) {} + + SQLiteConnectionPtr connection; + SQLiteStatement statement; + mutable std::mutex mutex; +}; + +SQLitePreparedStatement::SQLitePreparedStatement(std::shared_ptr state) : _state(std::move(state)) {} + +SQLitePreparedStatement::~SQLitePreparedStatement() { + finalize(); +} + +std::shared_ptr SQLitePreparedStatement::execute(const std::optional& params) { + std::lock_guard lock(_state->mutex); + std::lock_guard connectionLock(_state->connection->mutex); + + if (!_state->statement) { + throw NitroSQLiteException("Prepared statement has been finalized"); + } + + sqlite3* database = _state->connection->database; + if (database == nullptr) { + throw NitroSQLiteException("Prepared statement belongs to a closed database connection"); + } + + int resetStatus = sqlite3_reset(_state->statement.get()); + if (resetStatus != SQLITE_OK) { + throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(database)); + } + + int clearBindingsStatus = sqlite3_clear_bindings(_state->statement.get()); + if (clearBindingsStatus != SQLITE_OK) { + throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(database)); + } + + if (params) { + bindStatement(_state->statement.get(), *params); + } + + try { + return executeStatement(database, _state->statement.get()); + } catch (...) { + sqlite3_reset(_state->statement.get()); + throw; + } +} + +void SQLitePreparedStatement::finalize() { + std::lock_guard lock(_state->mutex); + std::lock_guard connectionLock(_state->connection->mutex); + _state->statement.reset(); +} + +bool SQLitePreparedStatement::isFinalized() const { + std::lock_guard lock(_state->mutex); + return !_state->statement; +} + +size_t SQLitePreparedStatement::getExternalMemorySize() const noexcept { + return sizeof(*this) + sizeof(State); +} + +std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query) { + auto connection = sqliteGetOpenDatabase(dbName); + std::lock_guard lock(connection->mutex); + if (connection->database == nullptr) { + throw NitroSQLiteException::DatabaseNotOpen(dbName); + } + + auto statement = prepareStatement(connection->database, query, std::nullopt); + auto state = std::make_shared(connection, std::move(statement)); + return std::shared_ptr(new SQLitePreparedStatement(std::move(state))); +} + } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp index 7ac2309c..896a72fd 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp @@ -15,6 +15,25 @@ void sqliteOpenDb(const std::string& dbName, const std::string& docPath, bool re std::string sqliteOpenConnection(const std::string& dbName, const std::string& docPath, bool readOnly = false); +class SQLitePreparedStatement { +public: + ~SQLitePreparedStatement(); + + std::shared_ptr execute(const std::optional& params); + void finalize(); + bool isFinalized() const; + size_t getExternalMemorySize() const noexcept; + +private: + struct State; + + explicit SQLitePreparedStatement(std::shared_ptr state); + + std::shared_ptr _state; + + friend std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query); +}; + void sqliteCloseDb(const std::string& dbName); void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const std::optional& connectionId = std::nullopt, @@ -37,6 +56,8 @@ SQLiteOperationResult sqliteExecuteCommand(const std::string& dbName, const std: SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params = std::nullopt); +std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query); + void sqliteCloseAll(); } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index a484be92..effe9332 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -6,6 +6,7 @@ #include "../NitroSQLiteLogs.hpp" #include "../NitroSQLiteMacros.hpp" #include "../NitroSQLiteOperations.hpp" +#include "HybridNitroSQLitePreparedStatement.hpp" #include "HybridNitroSQLiteQueryResult.hpp" #include #include @@ -227,6 +228,10 @@ HybridNitroSQLite::executeAsync(const std::string& dbName, const std::string& qu }); }; +std::shared_ptr HybridNitroSQLite::prepare(const std::string& dbName, const std::string& query) { + return std::make_shared(sqlitePrepare(dbName, query)); +} + BatchQueryResult HybridNitroSQLite::executeBatch(const std::string& dbName, const std::vector& batchParams) { const auto commands = batchParamsToCommands(batchParams); diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index dd8a4b8e..17aa4058 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -1,6 +1,7 @@ #pragma once #include "../NitroSQLiteTypes.hpp" +#include "HybridNitroSQLitePreparedStatementSpec.hpp" #include "HybridNitroSQLiteQueryResultSpec.hpp" #include "HybridNitroSQLiteSpec.hpp" @@ -58,6 +59,8 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { std::shared_ptr>> executeAsync(const std::string& dbName, const std::string& query, const std::optional& params) override; + std::shared_ptr prepare(const std::string& dbName, const std::string& query) override; + /** Run a nonempty batch in one exclusive transaction on the calling thread. * A failed command rolls back the batch. * @return Total affected row count. diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp new file mode 100644 index 00000000..89da478b --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp @@ -0,0 +1,59 @@ +#include "HybridNitroSQLitePreparedStatement.hpp" +#include "HybridNitroSQLiteQueryResult.hpp" +#include + +namespace margelo::nitro::rnnitrosqlite { + +namespace { + + std::optional copyArrayBufferParamsForBackground(const std::optional& params) { + if (!params) { + return std::nullopt; + } + + SQLiteQueryParams copiedParams; + copiedParams.reserve(params->size()); + + for (const auto& value : *params) { + if (std::holds_alternative>(value)) { + copiedParams.push_back(ArrayBuffer::copy(std::get>(value))); + } else { + copiedParams.push_back(value); + } + } + + return copiedParams; + } + +} // namespace + +HybridNitroSQLitePreparedStatement::HybridNitroSQLitePreparedStatement(std::shared_ptr statement) + : HybridObject(TAG), _statement(std::move(statement)) {} + +std::shared_ptr +HybridNitroSQLitePreparedStatement::execute(const std::optional& params) { + return _statement->execute(params); +} + +std::shared_ptr>> +HybridNitroSQLitePreparedStatement::executeAsync(const std::optional& params) { + const auto copiedParams = copyArrayBufferParamsForBackground(params); + const auto statement = _statement; + + return Promise>::async( + [statement, copiedParams]() -> std::shared_ptr { return statement->execute(copiedParams); }); +} + +void HybridNitroSQLitePreparedStatement::finalize() { + _statement->finalize(); +} + +bool HybridNitroSQLitePreparedStatement::getIsFinalized() { + return _statement->isFinalized(); +} + +size_t HybridNitroSQLitePreparedStatement::getExternalMemorySize() noexcept { + return sizeof(*this) + _statement->getExternalMemorySize(); +} + +} // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp new file mode 100644 index 00000000..cc2cdd47 --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "../NitroSQLiteOperations.hpp" +#include "../NitroSQLiteTypes.hpp" +#include "HybridNitroSQLitePreparedStatementSpec.hpp" +#include + +namespace margelo::nitro::rnnitrosqlite { + +class HybridNitroSQLitePreparedStatement : public HybridNitroSQLitePreparedStatementSpec { +public: + explicit HybridNitroSQLitePreparedStatement(std::shared_ptr statement); + + std::shared_ptr execute(const std::optional& params) override; + std::shared_ptr>> + executeAsync(const std::optional& params) override; + void finalize() override; + bool getIsFinalized() override; + + size_t getExternalMemorySize() noexcept override; + +private: + std::shared_ptr _statement; +}; + +} // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/android/RNNitroSQLite+autolinking.cmake b/packages/react-native-nitro-sqlite/nitrogen/generated/android/RNNitroSQLite+autolinking.cmake index 15e44d42..cad7718c 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/android/RNNitroSQLite+autolinking.cmake +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/android/RNNitroSQLite+autolinking.cmake @@ -35,6 +35,7 @@ target_sources( # Shared Nitrogen C++ sources ../nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp ../nitrogen/generated/shared/c++/HybridNitroSQLiteOnLoadSpec.cpp + ../nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp ../nitrogen/generated/shared/c++/HybridNitroSQLiteQueryResultSpec.cpp # Android-specific Nitrogen C++ sources ../nitrogen/generated/android/c++/JHybridNitroSQLiteOnLoadSpec.cpp 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 new file mode 100644 index 00000000..906479d3 --- /dev/null +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp @@ -0,0 +1,24 @@ +/// +/// HybridNitroSQLitePreparedStatementSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridNitroSQLitePreparedStatementSpec.hpp" + +namespace margelo::nitro::rnnitrosqlite { + + void HybridNitroSQLitePreparedStatementSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridGetter("isFinalized", &HybridNitroSQLitePreparedStatementSpec::getIsFinalized); + prototype.registerHybridMethod("execute", &HybridNitroSQLitePreparedStatementSpec::execute); + prototype.registerHybridMethod("executeAsync", &HybridNitroSQLitePreparedStatementSpec::executeAsync); + prototype.registerHybridMethod("finalize", &HybridNitroSQLitePreparedStatementSpec::finalize); + }); + } + +} // namespace margelo::nitro::rnnitrosqlite 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 new file mode 100644 index 00000000..fa97c166 --- /dev/null +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp @@ -0,0 +1,73 @@ +/// +/// HybridNitroSQLitePreparedStatementSpec.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 + +// Forward declaration of `HybridNitroSQLiteQueryResultSpec` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { class HybridNitroSQLiteQueryResultSpec; } + +#include +#include "HybridNitroSQLiteQueryResultSpec.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace margelo::nitro::rnnitrosqlite { + + using namespace margelo::nitro; + + /** + * An abstract base class for `NitroSQLitePreparedStatement` + * Inherit this class to create instances of `HybridNitroSQLitePreparedStatementSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridNitroSQLitePreparedStatement: public HybridNitroSQLitePreparedStatementSpec { + * public: + * HybridNitroSQLitePreparedStatement(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridNitroSQLitePreparedStatementSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridNitroSQLitePreparedStatementSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridNitroSQLitePreparedStatementSpec() override = default; + + public: + // Properties + virtual bool getIsFinalized() = 0; + + public: + // Methods + virtual std::shared_ptr execute(const std::optional, std::string, double>>>& params) = 0; + virtual std::shared_ptr>> executeAsync(const std::optional, std::string, double>>>& params) = 0; + virtual void finalize() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "NitroSQLitePreparedStatement"; + }; + +} // namespace margelo::nitro::rnnitrosqlite 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 305201f2..1297822c 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,7 @@ namespace margelo::nitro::rnnitrosqlite { prototype.registerHybridMethod("detach", &HybridNitroSQLiteSpec::detach); prototype.registerHybridMethod("execute", &HybridNitroSQLiteSpec::execute); prototype.registerHybridMethod("executeAsync", &HybridNitroSQLiteSpec::executeAsync); + prototype.registerHybridMethod("prepare", &HybridNitroSQLiteSpec::prepare); prototype.registerHybridMethod("executeBatch", &HybridNitroSQLiteSpec::executeBatch); prototype.registerHybridMethod("executeBatchAsync", &HybridNitroSQLiteSpec::executeBatchAsync); prototype.registerHybridMethod("loadFile", &HybridNitroSQLiteSpec::loadFile); 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 ed91df3d..a4c212e2 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 `HybridNitroSQLiteQueryResultSpec` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { class HybridNitroSQLiteQueryResultSpec; } +// Forward declaration of `HybridNitroSQLitePreparedStatementSpec` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { class HybridNitroSQLitePreparedStatementSpec; } // Forward declaration of `BatchQueryResult` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { struct BatchQueryResult; } // Forward declaration of `BatchQueryCommand` to properly resolve imports. @@ -31,6 +33,7 @@ namespace margelo::nitro::rnnitrosqlite { struct FileLoadResult; } #include #include #include +#include "HybridNitroSQLitePreparedStatementSpec.hpp" #include "BatchQueryResult.hpp" #include "BatchQueryCommand.hpp" #include "FileLoadResult.hpp" @@ -75,6 +78,7 @@ namespace margelo::nitro::rnnitrosqlite { virtual void detach(const std::string& mainDbName, const std::string& alias) = 0; virtual std::shared_ptr 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) = 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; virtual FileLoadResult loadFile(const std::string& dbName, const std::string& location) = 0; diff --git a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts index 22a9ccfb..3c52ddf1 100644 --- a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts +++ b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts @@ -8,6 +8,7 @@ export const HybridNitroSQLite = { detach: jest.fn(), execute: jest.fn(), executeAsync: jest.fn(), + prepare: jest.fn(), executeBatch: jest.fn(), executeBatchAsync: jest.fn(), loadFile: jest.fn(), diff --git a/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts new file mode 100644 index 00000000..270d0e1d --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts @@ -0,0 +1,126 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { + closeDatabaseQueue, + isDatabaseOpen, + openDatabaseQueue, +} from '../DatabaseQueue' +import type { NitroSQLitePreparedStatement } from '../specs/NitroSQLitePreparedStatement.nitro' +import { prepare } from '../operations/prepare' +import { open } from '../operations/session' +import { nativeResult } from './testUtils' + +const dbName = 'prepare-test' +const query = 'SELECT ? AS value' + +beforeEach(() => { + jest.clearAllMocks() + openDatabaseQueue(dbName) +}) + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +function mockStatement(): NitroSQLitePreparedStatement { + let finalized = false + return { + name: 'PreparedStatement', + toString: () => 'PreparedStatement', + equals: () => false, + dispose: jest.fn(), + get isFinalized() { + return finalized + }, + execute: jest.fn(() => nativeResult([{ value: 7 }])), + executeAsync: jest.fn(async () => nativeResult([{ value: 8 }])), + finalize: jest.fn(() => { + finalized = true + }), + } +} + +it('reuses a statement, returns rows, and exposes finalization', async () => { + closeDatabaseQueue(dbName) + const db = open({ name: dbName }) + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + + const statement = db.prepare(query) + expect(HybridNitroSQLite.prepare).toHaveBeenCalledWith(dbName, query) + expect(statement.isFinalized).toBe(false) + expect(statement.execute([7]).rows.item(0)).toEqual({ value: 7 }) + expect((await statement.executeAsync([8])).rows.item(0)).toEqual({ + value: 8, + }) + expect(native.execute).toHaveBeenCalledWith([7]) + expect(native.executeAsync).toHaveBeenCalledWith([8]) + + statement.finalize() + expect(statement.isFinalized).toBe(true) + expect(native.finalize).toHaveBeenCalledTimes(1) + db.close() +}) + +it('prepares against an independent connection ID', () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockReturnValue('prepared-connection') + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + const db = open({ name: dbName, connection: 'independent' }) + + const statement = db.prepare(query) + + expect(HybridNitroSQLite.prepare).toHaveBeenCalledWith( + 'prepared-connection', + query, + ) + statement.finalize() + db.close() +}) + +it('keeps synchronous calls from overtaking an asynchronous execution', async () => { + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + const statement = prepare(dbName, query) + + const pending = statement.executeAsync() + expect(() => statement.execute()).toThrow('busy with another operation') + expect(() => statement.finalize()).toThrow('busy with another operation') + await pending + + expect(statement.execute().rows.length).toBe(1) + statement.finalize() +}) + +it('converts preparation and execution errors', async () => { + jest.mocked(HybridNitroSQLite.prepare).mockImplementationOnce(() => { + throw new Error('prepare failed') + }) + expect(() => prepare(dbName, query)).toThrow('prepare failed') + + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + const statement = prepare(dbName, query) + + jest.mocked(native.execute).mockImplementationOnce(() => { + throw new Error('execute failed') + }) + expect(() => statement.execute()).toThrow('execute failed') + + jest.mocked(native.executeAsync).mockRejectedValueOnce('async failed') + await expect(statement.executeAsync()).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async failed', + }) + await expect(statement.executeAsync()).resolves.toMatchObject({ + rows: { length: 1 }, + }) + + jest.mocked(native.finalize).mockImplementationOnce(() => { + throw new Error('finalize failed') + }) + expect(() => statement.finalize()).toThrow('finalize failed') +}) diff --git a/packages/react-native-nitro-sqlite/src/index.ts b/packages/react-native-nitro-sqlite/src/index.ts index aa2c3fc7..d79fac7a 100644 --- a/packages/react-native-nitro-sqlite/src/index.ts +++ b/packages/react-native-nitro-sqlite/src/index.ts @@ -2,6 +2,7 @@ import { transaction } from './operations/transaction' import { HybridNitroSQLite } from './nitro' import { open } from './operations/session' import { execute, executeAsync } from './operations/execute' +import { prepare } from './operations/prepare' import { init } from './OnLoad' import { executeBatch, executeBatchAsync } from './operations/executeBatch' @@ -19,6 +20,7 @@ export const NitroSQLite = { transaction, execute, executeAsync, + prepare, executeBatch, executeBatchAsync, } diff --git a/packages/react-native-nitro-sqlite/src/operations/prepare.ts b/packages/react-native-nitro-sqlite/src/operations/prepare.ts new file mode 100644 index 00000000..eea110be --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/operations/prepare.ts @@ -0,0 +1,60 @@ +import { HybridNitroSQLite } from '../nitro' +import NitroSQLiteError from '../NitroSQLiteError' +import type { + PreparedStatement, + QueryResult, + QueryResultRow, + SQLiteQueryParams, +} from '../types' +import { buildJSQueryResult } from './execute' +import { queueOperationAsync, startOperationSync } from '../DatabaseQueue' +import type { DatabaseQueueKey } from '../DatabaseQueue' + +export function prepare( + dbName: string, + query: string, + queueKey: DatabaseQueueKey = dbName, +): PreparedStatement { + try { + const nativeStatement = startOperationSync(queueKey, () => + HybridNitroSQLite.prepare(dbName, query), + ) + + return { + get isFinalized() { + return nativeStatement.isFinalized + }, + execute: ( + params?: SQLiteQueryParams, + ): QueryResult => { + try { + return startOperationSync(queueKey, () => + buildJSQueryResult(nativeStatement.execute(params)), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, + executeAsync: async ( + params?: SQLiteQueryParams, + ): Promise> => { + try { + return await queueOperationAsync(queueKey, async () => + buildJSQueryResult(await nativeStatement.executeAsync(params)), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, + finalize: () => { + try { + startOperationSync(queueKey, () => nativeStatement.finalize()) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, + } + } catch (error) { + throw NitroSQLiteError.fromError(error) + } +} diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index af8d52ef..fa7d6f68 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -11,6 +11,7 @@ import type { } from '../types' import { executeAsyncManaged, executeManaged } from './execute' import { executeBatch, executeBatchAsync } from './executeBatch' +import { prepare } from './prepare' import NitroSQLiteError from '../NitroSQLiteError' import { closeDatabaseQueue, @@ -137,6 +138,8 @@ export function open( runOperation(() => executeAsyncManaged(connectionId, query, params, queueKey), ), + prepare: (query: string) => + runOperation(() => prepare(connectionId, query, queueKey)), executeBatch: (commands: BatchQueryCommand[]) => runOperation(() => executeBatch(connectionId, commands, queueKey)), executeBatchAsync: async (commands: BatchQueryCommand[]) => 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 b6985ede..a0f40df5 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts @@ -6,6 +6,7 @@ import type { SQLiteQueryParams, } from '../types' import type { NitroSQLiteQueryResult } from './NitroSQLiteQueryResult.nitro' +import type { NitroSQLitePreparedStatement } from './NitroSQLitePreparedStatement.nitro' /** Native database operations exposed through `NitroSQLite.native`. * These calls bypass the managed connection queue. Coordinate them with any @@ -66,6 +67,11 @@ export interface NitroSQLite query: string, params?: SQLiteQueryParams, ): Promise + /** Prepare one SQL statement for repeated execution. + * @param dbName Name or ID of an open database connection. + * @param query SQL statement with optional positional placeholders. + */ + prepare(dbName: string, query: string): NitroSQLitePreparedStatement /** Execute commands in one exclusive transaction on the calling thread. * An empty batch throws; a failed command rolls back the batch. * @param dbName Name of an open database. diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts new file mode 100644 index 00000000..7c991dba --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts @@ -0,0 +1,15 @@ +import type { HybridObject } from 'react-native-nitro-modules' +import type { SQLiteQueryParams } from '../types' +import type { NitroSQLiteQueryResult } from './NitroSQLiteQueryResult.nitro' + +export interface NitroSQLitePreparedStatement + extends HybridObject<{ + ios: 'c++' + android: 'c++' + }> { + readonly isFinalized: boolean + + execute(params?: SQLiteQueryParams): NitroSQLiteQueryResult + executeAsync(params?: SQLiteQueryParams): Promise + finalize(): void +} diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 1fdec287..d377ea14 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -44,6 +44,8 @@ export interface NitroSQLiteConnection { execute: ExecuteQuery /** Queue one SQL statement and resolve with its result. */ executeAsync: ExecuteAsyncQuery + /** Prepare one SQL statement for repeated execution. */ + prepare(query: string): PreparedStatement /** Execute commands in one exclusive transaction. Throws while the connection is busy. * @param commands SQL commands and optional parameter sets. * @returns Total affected row count. @@ -132,6 +134,26 @@ export type ExecuteAsyncQuery = ( params?: SQLiteQueryParams, ) => Promise> +/** A reusable SQL statement. Finalize it before closing its connection. */ +export interface PreparedStatement { + readonly isFinalized: boolean + execute: ExecutePreparedStatement + executeAsync: ExecutePreparedStatementAsync + finalize(): void +} + +export type ExecutePreparedStatement = < + Row extends QueryResultRow = QueryResultRow, +>( + params?: SQLiteQueryParams, +) => QueryResult + +export type ExecutePreparedStatementAsync = < + Row extends QueryResultRow = QueryResultRow, +>( + params?: SQLiteQueryParams, +) => Promise> + /** Handle valid only while its transaction callback is active. */ export interface Transaction { /** Commit now. Further operations on this transaction throw. */