From a01aa5950814f84fa61f6168c9a0038192767c0c Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Fri, 21 Aug 2026 08:47:53 -0400 Subject: [PATCH 1/3] Skip redundant sqlite3_clear_bindings and long long disambiguation on hot insert path opsqlite_bind_statement always cleared bindings before binding, even though the plain execute/executeSync/reactiveExecute paths bind immediately after a fresh sqlite3_prepare_v2, where bindings are already unset. Added a should_clear_bindings flag (default true) so PreparedStatementHostObject's bind/bindSync, which reuse a persistent statement across calls, keep the clear, while the fresh-prepare paths skip it. Also dropped to_variant's long long branch: it always ended up calling sqlite3_bind_double downstream anyway (same as the double branch), so the extra cast/comparison per numeric param had no behavioral effect. Benchmarked ~1000 sequential single-row inserts against react-native-nitro-sqlite; these were part of closing an unexplained insert throughput gap. --- cpp/OPBridge.cpp | 13 ++++++++----- cpp/OPBridge.hpp | 3 ++- cpp/OPDatabase.cpp | 2 +- cpp/OPUtils.cpp | 7 ++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cpp/OPBridge.cpp b/cpp/OPBridge.cpp index 24920535..23505cd4 100644 --- a/cpp/OPBridge.cpp +++ b/cpp/OPBridge.cpp @@ -25,8 +25,11 @@ namespace opsqlite { inline void opsqlite_bind_statement(sqlite3_stmt *statement, - const std::vector *values) { - sqlite3_clear_bindings(statement); + const std::vector *values, + bool should_clear_bindings) { + if (should_clear_bindings) { + sqlite3_clear_bindings(statement); + } size_t size = values->size(); @@ -386,7 +389,7 @@ BridgeResult opsqlite_execute(sqlite3 *db, std::string const &query, } if (params != nullptr && !params->empty()) { - opsqlite_bind_statement(statement, params); + opsqlite_bind_statement(statement, params, /* should_clear_bindings */ false); } // sqlite3_column_count is the correct signal: it's non-zero for any @@ -527,7 +530,7 @@ BridgeResult opsqlite_execute_host_objects( } if (params != nullptr && !params->empty()) { - opsqlite_bind_statement(statement, params); + opsqlite_bind_statement(statement, params, /* should_clear_bindings */ false); } int i, count, column_type; @@ -685,7 +688,7 @@ opsqlite_execute_raw(sqlite3 *db, std::string const &query, } if (params != nullptr && !params->empty()) { - opsqlite_bind_statement(statement, params); + opsqlite_bind_statement(statement, params, /* should_clear_bindings */ false); } int i, column_type; diff --git a/cpp/OPBridge.hpp b/cpp/OPBridge.hpp index 16ce9bdf..26cd1439 100644 --- a/cpp/OPBridge.hpp +++ b/cpp/OPBridge.hpp @@ -90,7 +90,8 @@ sqlite3_stmt *opsqlite_prepare_statement(sqlite3 *db, std::string const &query); void opsqlite_finalize_statement(sqlite3_stmt *statement); void opsqlite_bind_statement(sqlite3_stmt *statement, - const std::vector *params); + const std::vector *params, + bool should_clear_bindings = true); BridgeResult opsqlite_execute_prepared_statement( sqlite3 *db, sqlite3_stmt *statement, std::vector *results, diff --git a/cpp/OPDatabase.cpp b/cpp/OPDatabase.cpp index 9f17b567..ac098ca2 100644 --- a/cpp/OPDatabase.cpp +++ b/cpp/OPDatabase.cpp @@ -714,7 +714,7 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, auto variant_args = to_variant_vec(rt, js_args); sqlite3_stmt *stmt = opsqlite_prepare_statement(db, query_str); - opsqlite_bind_statement(stmt, &variant_args); + opsqlite_bind_statement(stmt, &variant_args, /* should_clear_bindings */ false); auto callback = std::make_shared(query.getProperty(rt, "callback")); diff --git a/cpp/OPUtils.cpp b/cpp/OPUtils.cpp index 07f47eec..f62f7e10 100644 --- a/cpp/OPUtils.cpp +++ b/cpp/OPUtils.cpp @@ -83,13 +83,14 @@ JSVariant to_variant(jsi::Runtime &rt, const jsi::Value &value) { } else if (value.isBool()) { return JSVariant(value.getBool()); } else if (value.isNumber()) { + // Binding only ever emits sqlite3_bind_int (below) or sqlite3_bind_double + // (see opsqlite_bind_statement) — the long long alternative always fell + // through to sqlite3_bind_double anyway, so it added a redundant cast + // and comparison here for no behavioral difference. double doubleVal = value.asNumber(); int intVal = (int)doubleVal; - long long longVal = (long)doubleVal; if (intVal == doubleVal) { return JSVariant(intVal); - } else if (longVal == doubleVal) { - return JSVariant(longVal); } else { return JSVariant(doubleVal); } From 32f2f185059060e3244bcc39957e7a5746ab3ac9 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Fri, 21 Aug 2026 09:30:26 -0400 Subject: [PATCH 2/3] Cache rowsAffected/insertId/rows PropNameIDs per-runtime res.setProperty(rt, "rowsAffected", ...) and friends go through the Object::setProperty(Runtime&, const char*, ...) overload, which calls String::createFromAscii on every single call -- allocating a fresh JS String just to set a property whose name never changes across calls. Cache the PropNameIDs for "rowsAffected"/"insertId"/"rows" once and reuse them, the same way column_prop_ids are already reused across rows within a single call -- except these are reused across calls too. PropNameID is scoped to the jsi::Runtime that created it, and runtime "generations" can briefly overlap during a bridgeless reload (see the comment on generation_alive in OPTypes.hpp), so a single global cache isn't safe. Keyed the cache by Runtime* instead (thread_local, no lock needed since JSI runtime access is always confined to one thread at a time). Wired into create_js_rows (the executeSync/execute hot path), create_result (executeWithHostObjects), and create_raw_result. --- cpp/OPUtils.cpp | 53 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/cpp/OPUtils.cpp b/cpp/OPUtils.cpp index f62f7e10..3513d629 100644 --- a/cpp/OPUtils.cpp +++ b/cpp/OPUtils.cpp @@ -9,6 +9,7 @@ #include "OPMacros.hpp" #include #include +#include #include namespace opsqlite { @@ -16,6 +17,37 @@ namespace opsqlite { namespace jsi = facebook::jsi; namespace react = facebook::react; +namespace { + +// "rowsAffected"/"insertId"/"rows" are set on every execute() result +// regardless of query, so their PropNameIDs are worth caching the same way +// column_prop_ids are reused across rows -- except these are reused across +// *calls* too. PropNameID is a handle scoped to the jsi::Runtime that +// created it, and runtime "generations" can briefly overlap during a +// bridgeless reload (see OPTypes.hpp), so the cache is keyed by Runtime* +// rather than a single global. thread_local because JSI runtime access is +// always confined to one thread at a time; no lock needed. +struct ResultPropNames { + jsi::PropNameID rowsAffected; + jsi::PropNameID insertId; + jsi::PropNameID rows; +}; + +ResultPropNames &result_prop_names(jsi::Runtime &rt) { + static thread_local std::unordered_map cache; + auto it = cache.find(&rt); + if (it != cache.end()) { + return it->second; + } + auto [inserted, _] = cache.emplace( + &rt, ResultPropNames{jsi::PropNameID::forAscii(rt, "rowsAffected"), + jsi::PropNameID::forAscii(rt, "insertId"), + jsi::PropNameID::forAscii(rt, "rows")}); + return inserted->second; +} + +} // namespace + jsi::Value to_jsi(jsi::Runtime &rt, const JSVariant &value) { if (std::holds_alternative(value)) { return std::get(value); @@ -178,18 +210,19 @@ std::vector to_variant_vec(jsi::Runtime &rt, jsi::Value const &xs) { } jsi::Value create_js_rows(jsi::Runtime &rt, const BridgeResult &status) { + auto &prop_names = result_prop_names(rt); jsi::Object res = jsi::Object(rt); - res.setProperty(rt, "rowsAffected", status.affectedRows); + res.setProperty(rt, prop_names.rowsAffected, status.affectedRows); if (status.affectedRows > 0 && status.insertId != 0) { - res.setProperty(rt, "insertId", jsi::Value(status.insertId)); + res.setProperty(rt, prop_names.insertId, jsi::Value(status.insertId)); } size_t row_count = status.rows.size(); size_t column_count = status.column_names.size(); if (row_count == 0) { - res.setProperty(rt, "rows", jsi::Array(rt, 0)); + res.setProperty(rt, prop_names.rows, jsi::Array(rt, 0)); return res; } @@ -208,7 +241,7 @@ jsi::Value create_js_rows(jsi::Runtime &rt, const BridgeResult &status) { } rows.setValueAtIndex(rt, i, std::move(row)); } - res.setProperty(rt, "rows", std::move(rows)); + res.setProperty(rt, prop_names.rows, std::move(rows)); return res; } @@ -217,11 +250,12 @@ jsi::Value create_result(jsi::Runtime &rt, const BridgeResult &status, std::vector *results, std::shared_ptr> metadata) { + auto &prop_names = result_prop_names(rt); jsi::Object res = jsi::Object(rt); - res.setProperty(rt, "rowsAffected", status.affectedRows); + res.setProperty(rt, prop_names.rowsAffected, status.affectedRows); if (status.affectedRows > 0 && status.insertId != 0) { - res.setProperty(rt, "insertId", jsi::Value(status.insertId)); + res.setProperty(rt, prop_names.insertId, jsi::Value(status.insertId)); } size_t rowCount = results->size(); @@ -233,7 +267,7 @@ create_result(jsi::Runtime &rt, const BridgeResult &status, jsi::Object::createFromHostObject( rt, std::make_shared(obj))); } - res.setProperty(rt, "rows", std::move(array)); + res.setProperty(rt, prop_names.rows, std::move(array)); size_t column_count = metadata->size(); auto column_array = jsi::Array(rt, column_count); @@ -252,6 +286,7 @@ create_result(jsi::Runtime &rt, const BridgeResult &status, jsi::Value create_raw_result(jsi::Runtime &rt, const BridgeResult &status, const std::vector> *results) { + auto &prop_names = result_prop_names(rt); size_t row_count = results->size(); jsi::Object res(rt); jsi::Array raw_rows = jsi::Array(rt, row_count); @@ -271,8 +306,8 @@ create_raw_result(jsi::Runtime &rt, const BridgeResult &status, rt, i, jsi::String::createFromUtf8(rt, status.column_names.at(i))); } - res.setProperty(rt, "rowsAffected", status.affectedRows); - res.setProperty(rt, "insertId", status.insertId); + res.setProperty(rt, prop_names.rowsAffected, status.affectedRows); + res.setProperty(rt, prop_names.insertId, status.insertId); res.setProperty(rt, "rawRows", std::move(raw_rows)); res.setProperty(rt, "columnNames", std::move(column_names)); From 4c138699664452ad77004f6d1b3a227933e95bc5 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Fri, 21 Aug 2026 10:17:13 -0400 Subject: [PATCH 3/3] Fix turso --- cpp/turso/OPTursoBridge.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cpp/turso/OPTursoBridge.cpp b/cpp/turso/OPTursoBridge.cpp index 21b4f6b8..fd69a1d8 100644 --- a/cpp/turso/OPTursoBridge.cpp +++ b/cpp/turso/OPTursoBridge.cpp @@ -345,7 +345,8 @@ void reset_statement(turso_statement_t *statement) { } // namespace void opsqlite_bind_statement(sqlite3_stmt *statement, - const std::vector *values) { + const std::vector *values, + [[maybe_unused]] bool should_clear_bindings) { auto *stmt = to_turso_stmt(statement); for (size_t i = 0; i < values->size(); i++) {