diff --git a/.github/ci-paths.json b/.github/ci-paths.json
index cc80e566..60fb33d8 100644
--- a/.github/ci-paths.json
+++ b/.github/ci-paths.json
@@ -54,7 +54,9 @@
"packages/react-native-nitro-sqlite/cpp/SQLiteDatabaseMigration.*",
"packages/react-native-nitro-sqlite/cpp/SQLiteDatabaseConnections.*",
"packages/react-native-nitro-sqlite/cpp/SQLiteExecuteBatch.*",
+ "packages/react-native-nitro-sqlite/cpp/SQLiteJSON.*",
"packages/react-native-nitro-sqlite/cpp/SQLiteOperations.*",
+ "packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.*",
"packages/react-native-nitro-sqlite/cpp/SQLiteStatementGroup.hpp",
"packages/react-native-nitro-sqlite/cpp/SQLiteStatementTail.hpp",
"packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*",
diff --git a/docs/content/docs/guides/sync-and-async.mdx b/docs/content/docs/guides/sync-and-async.mdx
index 1af38107..cb145781 100644
--- a/docs/content/docs/guides/sync-and-async.mdx
+++ b/docs/content/docs/guides/sync-and-async.mdx
@@ -23,6 +23,8 @@ Use synchronous calls when immediate results are useful and the work is small. P
`executeAsync()` resolves once with the complete result in row order. Returning many rows still costs JavaScript work, so paginate large results.
+SQLite stores binary data as BLOBs. Query results return those values as `ArrayBuffer` objects, including empty BLOBs. Text keeps embedded NULs and other control characters, SQL `NULL` becomes JavaScript `null`, and infinite numbers remain numeric values. These rules also apply to prepared statements and raw native queries.
+
## Connection ordering
diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts
index 30d0a01d..34b28c4d 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 registerQueryResultUnitTests from './specs/operations/queryResults.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/queryResults.spec.ts', registerQueryResultUnitTests)
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/queryResults.spec.ts b/example/tests/unit/specs/operations/queryResults.spec.ts
new file mode 100644
index 00000000..40adacb6
--- /dev/null
+++ b/example/tests/unit/specs/operations/queryResults.spec.ts
@@ -0,0 +1,184 @@
+import { NitroSQLite } from 'react-native-nitro-sqlite'
+import type {
+ NativeQueryResult,
+ SQLiteQueryParams,
+} from 'react-native-nitro-sqlite'
+import { describe, it } from '@tests/TestApi'
+import { TEST_DB_NAME, testDb } from '@tests/db'
+import { expect } from '@tests/unit/common'
+
+export default function registerQueryResultUnitTests() {
+ for (const { name, execute } of queryExecutors) {
+ describe(name, () => {
+ it('preserves escaped text, binary values, and exact numbers', async () => {
+ const text = `${String.fromCharCode(...Array.from({ length: 32 }, (_, i) => i))}"\\é中😀\u2028\u2029`
+ const bytes = [0, 34, 92, 127, 128, 255]
+ const result = await execute(
+ `SELECT ? AS text, ? AS payload, zeroblob(0) AS empty,
+ ? AS tiny, ? AS huge, ? AS precise, ? AS negative_zero,
+ ? AS positive_infinity, ? AS negative_infinity, ? AS missing`,
+ [
+ text,
+ new Uint8Array(bytes).buffer,
+ Number.MIN_VALUE,
+ Number.MAX_VALUE,
+ 1.0000000000000002,
+ -0,
+ Infinity,
+ -Infinity,
+ undefined,
+ ],
+ )
+ expect(result.results).toHaveLength(1)
+ const row = result.results[0]
+ expect(row?.text).toBe(text)
+ expect(row?.payload).toBeInstanceOf(ArrayBuffer)
+ if (!(row?.payload instanceof ArrayBuffer)) {
+ throw new Error('Expected a binary result')
+ }
+ expect(Array.from(new Uint8Array(row.payload))).toEqual(bytes)
+ expect(row.empty).toBeInstanceOf(ArrayBuffer)
+ if (!(row.empty instanceof ArrayBuffer)) {
+ throw new Error('Expected an empty binary result')
+ }
+ expect(row.empty.byteLength).toBe(0)
+ expect(row.tiny).toBe(Number.MIN_VALUE)
+ expect(row.huge).toBe(Number.MAX_VALUE)
+ expect(row.precise).toBe(1.0000000000000002)
+ expect(row.negative_zero).toBe(0)
+ expect(row.positive_infinity).toBe(Infinity)
+ expect(row.negative_infinity).toBe(-Infinity)
+ expect(row.missing).toBe(null)
+ })
+
+ it('uses the last duplicate column even when it replaces a native value', async () => {
+ const { results } = await execute(`SELECT
+ x'01' AS first, 7 AS first,
+ 9e999 AS second, 'last' AS second,
+ 3 AS third, x'ff00' AS third,
+ 4 AS fourth, -9e999 AS fourth,
+ x'02' AS fifth, NULL AS fifth`)
+ const row = results[0]
+ expect(row?.first).toBe(7)
+ expect(row?.second).toBe('last')
+ expect(row?.third).toBeInstanceOf(ArrayBuffer)
+ if (!(row?.third instanceof ArrayBuffer)) {
+ throw new Error('Expected the last binary column')
+ }
+ expect(Array.from(new Uint8Array(row.third))).toEqual([255, 0])
+ expect(row.fourth).toBe(-Infinity)
+ expect(row.fifth).toBe(null)
+ expect(Object.keys(row)).toEqual([
+ 'first',
+ 'second',
+ 'third',
+ 'fourth',
+ 'fifth',
+ ])
+ })
+
+ it('keeps SQLite integers as JavaScript numbers at the precision limits', async () => {
+ const { results } = await execute(`SELECT
+ 9007199254740993 AS positive, -9007199254740993 AS negative,
+ 9223372036854775807 AS maximum, -9223372036854775808 AS minimum`)
+ expect(results[0]).toEqual({
+ positive: Number('9007199254740993'),
+ negative: Number('-9007199254740993'),
+ maximum: Number('9223372036854775807'),
+ minimum: Number('-9223372036854775808'),
+ })
+ })
+
+ it('decodes malformed UTF-8 text without losing the rest of the result', async () => {
+ const { results } = await execute(
+ "SELECT x'ff0022' AS payload, 9e999 AS infinite, CAST(x'ff' AS TEXT) AS invalid, 'after' AS valid",
+ )
+ const row = results[0]
+ expect(row?.invalid).toBeTypeOf('string')
+ expect(row?.valid).toBe('after')
+ expect(row?.infinite).toBe(Infinity)
+ if (!(row?.payload instanceof ArrayBuffer)) {
+ throw new Error('Expected the binary column before malformed text')
+ }
+ expect(Array.from(new Uint8Array(row.payload))).toEqual([255, 0, 34])
+ })
+ })
+ }
+
+ it('restores binary and infinite values across asynchronous batch boundaries', async () => {
+ const result = await testDb.executeAsync(`
+ WITH RECURSIVE counter(id) AS (
+ SELECT 1 UNION ALL SELECT id + 1 FROM counter WHERE id < 1000
+ )
+ SELECT id, x'ff00225c' AS payload,
+ CASE WHEN id % 2 = 0 THEN 9e999 ELSE -9e999 END AS infinite,
+ CASE WHEN id = 513 THEN CAST(x'ff' AS TEXT)
+ WHEN id % 3 = 0 THEN NULL ELSE 'é中😀' END AS text
+ FROM counter ORDER BY id
+ `)
+ expect(result.rowBatchCount).toBe(3)
+ expect(result.rows.length).toBe(1000)
+ for (let index = 0; index < 1000; index++) {
+ const row = result.rows.item(index)
+ expect(row?.id).toBe(index + 1)
+ expect(row?.payload).toBeInstanceOf(ArrayBuffer)
+ if (!(row?.payload instanceof ArrayBuffer)) {
+ throw new Error(`Expected binary data in row ${index + 1}`)
+ }
+ expect(Array.from(new Uint8Array(row.payload))).toEqual([255, 0, 34, 92])
+ expect(row.infinite).toBe((index + 1) % 2 === 0 ? Infinity : -Infinity)
+ if (index === 512) expect(row.text).toBeTypeOf('string')
+ else expect(row.text).toBe((index + 1) % 3 === 0 ? null : 'é中😀')
+ }
+ })
+}
+
+const queryExecutors: {
+ name: string
+ execute(
+ query: string,
+ params?: SQLiteQueryParams,
+ ): NativeQueryResult | Promise
+}[] = [
+ {
+ name: 'execute',
+ execute: (query, params) => testDb.execute(query, params),
+ },
+ {
+ name: 'executeAsync',
+ execute: (query, params) => testDb.executeAsync(query, params),
+ },
+ {
+ name: 'prepared execute',
+ execute: (query, params) => executePrepared(query, params, false),
+ },
+ {
+ name: 'prepared executeAsync',
+ execute: (query, params) => executePrepared(query, params, true),
+ },
+ {
+ name: 'native execute',
+ execute: (query, params) =>
+ NitroSQLite.native.execute(TEST_DB_NAME, query, params),
+ },
+ {
+ name: 'native executeAsync',
+ execute: (query, params) =>
+ NitroSQLite.native.executeAsync(TEST_DB_NAME, query, params),
+ },
+]
+
+async function executePrepared(
+ query: string,
+ params: SQLiteQueryParams | undefined,
+ asynchronous: boolean,
+): Promise {
+ const statement = testDb.prepare(query)
+ try {
+ return asynchronous
+ ? await statement.executeAsync(params)
+ : statement.execute(params)
+ } finally {
+ statement.finalize()
+ }
+}
diff --git a/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.cpp b/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.cpp
new file mode 100644
index 00000000..77895f3d
--- /dev/null
+++ b/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.cpp
@@ -0,0 +1,82 @@
+#include "SQLiteJSON.hpp"
+#include "sqlite/sqlite3.h"
+#include
+#include
+#include
+
+namespace margelo::nitro::rnnitrosqlite {
+
+bool appendJSONString(std::string& json, std::string_view value) {
+ constexpr char hex[] = "0123456789abcdef";
+ json.push_back('"');
+ size_t start = 0;
+ for (size_t index = 0; index < value.size(); index++) {
+ const auto character = static_cast(value[index]);
+ if (character >= 0x80) {
+ const size_t length = character >= 0xc2 && character <= 0xdf ? 2
+ : character >= 0xe0 && character <= 0xef ? 3
+ : character >= 0xf0 && character <= 0xf4 ? 4
+ : 0;
+ if (length == 0 || index + length > value.size())
+ return false;
+ const auto second = static_cast(value[index + 1]);
+ if ((character == 0xe0 && second < 0xa0) || (character == 0xed && second >= 0xa0) || (character == 0xf0 && second < 0x90) ||
+ (character == 0xf4 && second >= 0x90))
+ return false;
+ for (size_t offset = 1; offset < length; offset++) {
+ if ((static_cast(value[index + offset]) & 0xc0) != 0x80)
+ return false;
+ }
+ index += length - 1;
+ continue;
+ }
+ if (character >= 0x20 && character != '"' && character != '\\')
+ continue;
+
+ json.append(value.substr(start, index - start));
+ if (character == '"' || character == '\\') {
+ json.push_back('\\');
+ json.push_back(static_cast(character));
+ } else {
+ json.append("\\u00");
+ json.push_back(hex[character >> 4]);
+ json.push_back(hex[character & 0xf]);
+ }
+ start = index + 1;
+ }
+ json.append(value.substr(start));
+ json.push_back('"');
+ return true;
+}
+
+void appendJSONInteger(std::string& json, int64_t value) {
+ char buffer[21];
+ const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), value);
+ if (error != std::errc())
+ throw std::runtime_error("Failed to encode SQLite integer as JSON");
+ json.append(buffer, end);
+}
+
+bool appendJSONNumber(std::string& json, double value) {
+ if (!std::isfinite(value))
+ return false;
+
+ char buffer[32];
+#if defined(__APPLE__)
+ // Apple marks floating-point to_chars unavailable when targeting the supported iOS 16.0 minimum.
+ if (value == 0 && std::signbit(value)) {
+ json.append("-0");
+ } else {
+ sqlite3_snprintf(sizeof(buffer), buffer, "%!.17g", value);
+ json.append(buffer);
+ }
+#else
+ const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), value);
+ if (error != std::errc())
+ throw std::runtime_error("Failed to encode SQLite number as JSON");
+ json.append(buffer, end);
+#endif
+ return true;
+}
+
+} // namespace margelo::nitro::rnnitrosqlite
diff --git a/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.hpp b/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.hpp
new file mode 100644
index 00000000..ae57379b
--- /dev/null
+++ b/packages/react-native-nitro-sqlite/cpp/SQLiteJSON.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace margelo::nitro::rnnitrosqlite {
+
+/** Append a quoted JSON string, preserving UTF-8 and escaping control characters. Return false for malformed UTF-8. */
+bool appendJSONString(std::string& json, std::string_view value);
+
+/** Append an SQLite integer as a JSON number. JavaScript still receives a number, not a bigint. */
+void appendJSONInteger(std::string& json, int64_t value);
+
+/** Append a finite number with exact double round-trip precision. Return false without appending for NaN or infinity. */
+bool appendJSONNumber(std::string& json, double value);
+
+} // namespace margelo::nitro::rnnitrosqlite
diff --git a/packages/react-native-nitro-sqlite/cpp/SQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/SQLiteOperations.cpp
index b90e5732..8585071f 100644
--- a/packages/react-native-nitro-sqlite/cpp/SQLiteOperations.cpp
+++ b/packages/react-native-nitro-sqlite/cpp/SQLiteOperations.cpp
@@ -222,7 +222,7 @@ namespace {
NativeQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) {
int columnCount = 0;
std::vector columnNames;
- std::vector rows;
+ std::optional rows;
size_t rowBatchCount = 0;
bool columnsCaptured = false;
@@ -236,6 +236,7 @@ namespace {
}
columnNames.emplace_back(columnName);
}
+ rows.emplace(columnNames);
columnsCaptured = true;
};
@@ -245,49 +246,10 @@ namespace {
captureColumns();
}
- SQLiteQueryResultRow row;
- row.reserve(columnNames.size());
-
- for (int i = 0; i < columnCount; i++) {
- int columnType = sqlite3_column_type(currentStatement, i);
-
- switch (columnType) {
- case SQLITE_INTEGER:
- case SQLITE_FLOAT:
- row.emplace_back(sqlite3_column_double(currentStatement, i));
- break;
- case SQLITE_TEXT: {
- auto columnValue = reinterpret_cast(sqlite3_column_text(currentStatement, i));
- if (columnValue == nullptr) {
- throw SQLiteException::SqlExecution(sqlite3_errmsg(db));
- }
- int columnBytes = sqlite3_column_bytes(currentStatement, i);
- row.emplace_back(std::string(columnValue, static_cast(columnBytes)));
- 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.emplace_back(ArrayBuffer::copy(blobData, static_cast(blobSize)));
- } else {
- row.emplace_back(ArrayBuffer::allocate(0));
- }
- break;
- }
- case SQLITE_NULL:
- default:
- row.emplace_back(NullType::null);
- break;
- }
- }
-
- rows.push_back(std::move(row));
- if (onRows && rows.size() == kRowBatchSize) {
- onRows(SQLiteQueryResults(columnNames, std::move(rows)));
+ rows->appendRow(currentStatement);
+ if (onRows && rows->size() == kRowBatchSize) {
+ onRows(rows->takeRows());
rowBatchCount++;
- rows = {};
}
});
@@ -308,8 +270,7 @@ namespace {
metadata->insert({columnName, std::move(columnMeta)});
}
- return NativeQueryResult(sqlite3_changes(db), static_cast(sqlite3_last_insert_rowid(db)),
- SQLiteQueryResults(std::move(columnNames), std::move(rows)), std::move(metadata),
+ return NativeQueryResult(sqlite3_changes(db), static_cast(sqlite3_last_insert_rowid(db)), rows->takeRows(), std::move(metadata),
static_cast(rowBatchCount));
}
diff --git a/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.cpp b/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.cpp
new file mode 100644
index 00000000..39288110
--- /dev/null
+++ b/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.cpp
@@ -0,0 +1,174 @@
+#include "SQLiteQueryResults.hpp"
+#include "SQLiteException.hpp"
+#include "SQLiteJSON.hpp"
+#include "sqlite/sqlite3.h"
+#include
+#include
+
+namespace margelo::nitro::rnnitrosqlite {
+
+namespace {
+ std::string_view readText(sqlite3_stmt* statement, int columnIndex);
+ SQLiteValue readNativeColumn(sqlite3_stmt* statement, int columnIndex);
+} // namespace
+
+SQLiteQueryResults::SQLiteQueryResults() : data(std::make_shared()) {}
+
+SQLiteQueryResults::SQLiteQueryResults(SQLiteQueryResultData result)
+ : data(std::make_shared(std::move(result))) {}
+
+SQLiteQueryResultBuilder::SQLiteQueryResultBuilder(std::vector columnNames) : _columnNames(std::move(columnNames)) {
+ _keys.reserve(_columnNames.size());
+ for (const auto& name : _columnNames) {
+ std::string key;
+ if (!appendJSONString(key, name)) {
+ // Preserve JSI's column-name conversion when a database contains malformed UTF-8 names.
+ _data = SQLiteNativeQueryResults{_columnNames, {}};
+ return;
+ }
+ key.push_back(':');
+ _keys.push_back(std::move(key));
+ }
+ std::get(_data).json.stringRepresentation = "[";
+}
+
+void SQLiteQueryResultBuilder::appendRow(sqlite3_stmt* statement) {
+ if (auto* native = std::get_if(&_data)) {
+ SQLiteQueryResultRow row;
+ row.reserve(_columnNames.size());
+ for (size_t columnIndex = 0; columnIndex < _columnNames.size(); columnIndex++) {
+ row.push_back(readNativeColumn(statement, static_cast(columnIndex)));
+ }
+ native->rows.push_back(std::move(row));
+ _rowCount++;
+ return;
+ }
+
+ auto& json = std::get(_data).json.stringRepresentation;
+ if (_rowCount != 0)
+ json.push_back(',');
+ json.push_back('{');
+ for (size_t columnIndex = 0; columnIndex < _columnNames.size(); columnIndex++) {
+ if (columnIndex != 0)
+ json.push_back(',');
+ json.append(_keys[columnIndex]);
+ const int index = static_cast(columnIndex);
+ switch (sqlite3_column_type(statement, index)) {
+ case SQLITE_INTEGER:
+ appendJSONInteger(json, sqlite3_column_int64(statement, index));
+ break;
+ case SQLITE_FLOAT: {
+ const double value = sqlite3_column_double(statement, index);
+ if (!appendJSONNumber(json, value))
+ appendPatch(columnIndex, value);
+ break;
+ }
+ case SQLITE_TEXT: {
+ const auto text = readText(statement, index);
+ const auto start = json.size();
+ if (!appendJSONString(json, text)) {
+ json.resize(start);
+ appendPatch(columnIndex, std::string(text));
+ }
+ break;
+ }
+ case SQLITE_BLOB:
+ appendPatch(columnIndex, readNativeColumn(statement, index));
+ break;
+ case SQLITE_NULL:
+ default:
+ json.append("null");
+ break;
+ }
+ }
+ json.push_back('}');
+ _rowCount++;
+}
+
+SQLiteQueryResults SQLiteQueryResultBuilder::takeRows() {
+ const bool native = std::holds_alternative(_data);
+ if (!native)
+ std::get(_data).json.stringRepresentation.push_back(']');
+ SQLiteQueryResults result(std::move(_data));
+ if (native)
+ _data = SQLiteNativeQueryResults{_columnNames, {}};
+ else
+ _data = SQLiteJSONQueryResults{AnyJSON{"["}, {}};
+ _rowCount = 0;
+ return result;
+}
+
+void SQLiteQueryResultBuilder::appendPatch(size_t columnIndex, SQLiteValue value) {
+ auto& encoded = std::get(_data);
+ encoded.json.stringRepresentation.append("null");
+ // A later column with the same alias wins, including over a BLOB or infinity.
+ if (std::find(_columnNames.begin() + columnIndex + 1, _columnNames.end(), _columnNames[columnIndex]) == _columnNames.end()) {
+ encoded.patches.push_back({_rowCount, _columnNames[columnIndex], std::move(value)});
+ }
+}
+
+namespace {
+ std::string_view readText(sqlite3_stmt* statement, int columnIndex) {
+ const auto* text = reinterpret_cast(sqlite3_column_text(statement, columnIndex));
+ if (text == nullptr)
+ throw SQLiteException::SqlExecution(sqlite3_errmsg(sqlite3_db_handle(statement)));
+ return {text, static_cast(sqlite3_column_bytes(statement, columnIndex))};
+ }
+
+ SQLiteValue readNativeColumn(sqlite3_stmt* statement, int columnIndex) {
+ switch (sqlite3_column_type(statement, columnIndex)) {
+ case SQLITE_INTEGER:
+ case SQLITE_FLOAT:
+ return sqlite3_column_double(statement, columnIndex);
+ case SQLITE_TEXT:
+ return std::string(readText(statement, columnIndex));
+ case SQLITE_BLOB: {
+ const int size = sqlite3_column_bytes(statement, columnIndex);
+ const auto* bytes = reinterpret_cast(sqlite3_column_blob(statement, columnIndex));
+ return size > 0 ? ArrayBuffer::copy(bytes, static_cast(size)) : ArrayBuffer::allocate(0);
+ }
+ case SQLITE_NULL:
+ default:
+ return NullType::null;
+ }
+ }
+} // namespace
+
+} // namespace margelo::nitro::rnnitrosqlite
+
+namespace margelo::nitro {
+
+jsi::Value JSIConverter::toJSI(jsi::Runtime& runtime, const rnnitrosqlite::SQLiteQueryResults& results) {
+ if (const auto* native = std::get_if(results.data.get())) {
+ std::vector names;
+ names.reserve(native->columnNames.size());
+ for (const auto& name : native->columnNames) {
+ names.emplace_back(jsi::PropNameID::forUtf8(runtime, reinterpret_cast(name.data()), name.size()));
+ }
+ jsi::Array array(runtime, native->rows.size());
+ for (size_t rowIndex = 0; rowIndex < native->rows.size(); rowIndex++) {
+ jsi::Object object(runtime);
+ const auto& row = native->rows[rowIndex];
+ for (size_t columnIndex = 0; columnIndex < names.size(); columnIndex++) {
+ object.setProperty(runtime, names[columnIndex], JSIConverter::toJSI(runtime, row[columnIndex]));
+ }
+ array.setValueAtIndex(runtime, rowIndex, std::move(object));
+ }
+ return array;
+ }
+
+ const auto& data = std::get(*results.data);
+ auto converted = JSIConverter::toJSI(runtime, data.json);
+ if (data.patches.empty())
+ return converted;
+
+ auto array = converted.getObject(runtime).getArray(runtime);
+ for (const auto& patch : data.patches) {
+ auto row = array.getValueAtIndex(runtime, patch.rowIndex).getObject(runtime);
+ auto name = jsi::PropNameID::forUtf8(runtime, patch.columnName);
+ row.setProperty(runtime, name, JSIConverter::toJSI(runtime, patch.value));
+ }
+ return converted;
+}
+
+} // namespace margelo::nitro
diff --git a/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.hpp b/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.hpp
index 7e3e7a92..7f4367b1 100644
--- a/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.hpp
+++ b/packages/react-native-nitro-sqlite/cpp/SQLiteQueryResults.hpp
@@ -1,6 +1,7 @@
#pragma once
#include "SQLiteTypes.hpp"
+#include
#include
#include
#include
@@ -9,25 +10,61 @@
#include
#include
+struct sqlite3_stmt;
+
namespace margelo::nitro::rnnitrosqlite {
using SQLiteQueryResultRow = std::vector;
-struct SQLiteQueryResultData {
+struct SQLiteQueryResultPatch {
+ size_t rowIndex;
+ std::string columnName;
+ SQLiteValue value;
+};
+
+struct SQLiteJSONQueryResults {
+ AnyJSON json{"[]"};
+ std::vector patches;
+};
+
+struct SQLiteNativeQueryResults {
std::vector columnNames;
std::vector rows;
};
-// Rows read by native code and converted to JavaScript objects on the JS thread.
-// The shared storage keeps copies of a query result cheap.
+using SQLiteQueryResultData = std::variant;
+
+// JSON is encoded on the SQLite thread. BLOBs and non-finite numbers retain their native values.
struct SQLiteQueryResults {
- SQLiteQueryResults() : data(std::make_shared()) {}
- SQLiteQueryResults(std::vector columnNames, std::vector rows)
- : data(std::make_shared(std::move(columnNames), std::move(rows))) {}
+ SQLiteQueryResults();
+ explicit SQLiteQueryResults(SQLiteQueryResultData result);
std::shared_ptr data;
};
+/** Encodes SQLite rows as they are read, keeping only values JSON cannot represent in native storage. */
+class SQLiteQueryResultBuilder final {
+public:
+ /** Capture the statement's column names after SQLite has performed any automatic reprepare. */
+ explicit SQLiteQueryResultBuilder(std::vector columnNames);
+ /** Copy the current row before SQLite advances the statement or releases its column buffers. */
+ void appendRow(sqlite3_stmt* statement);
+ /** Number of rows in the batch that has not yet been handed to JavaScript. */
+ size_t size() const {
+ return _rowCount;
+ }
+ /** Finish this batch and start an empty batch with the same columns. */
+ SQLiteQueryResults takeRows();
+
+private:
+ void appendPatch(size_t columnIndex, SQLiteValue value);
+
+ std::vector _columnNames;
+ std::vector _keys;
+ SQLiteQueryResultData _data;
+ size_t _rowCount = 0;
+};
+
/** Receives leading result rows in batches while the statement still runs. */
using SQLiteRowBatchHandler = std::function;
@@ -37,26 +74,7 @@ namespace margelo::nitro {
template <>
struct JSIConverter final {
- static jsi::Value toJSI(jsi::Runtime& runtime, const rnnitrosqlite::SQLiteQueryResults& results) {
- const auto& data = *results.data;
- std::vector names;
- names.reserve(data.columnNames.size());
- for (const auto& name : data.columnNames) {
- names.emplace_back(jsi::PropNameID::forUtf8(runtime, reinterpret_cast(name.data()), name.size()));
- }
-
- jsi::Array array(runtime, data.rows.size());
- for (size_t rowIndex = 0; rowIndex < data.rows.size(); rowIndex++) {
- jsi::Object object(runtime);
- const auto& row = data.rows[rowIndex];
- for (size_t columnIndex = 0; columnIndex < names.size(); columnIndex++) {
- // Repeated aliases overwrite earlier values, as the former row map did.
- object.setProperty(runtime, names[columnIndex], JSIConverter::toJSI(runtime, row[columnIndex]));
- }
- array.setValueAtIndex(runtime, rowIndex, std::move(object));
- }
- return array;
- }
+ static jsi::Value toJSI(jsi::Runtime& runtime, const rnnitrosqlite::SQLiteQueryResults& results);
// Query results only travel from native code to JavaScript.
static rnnitrosqlite::SQLiteQueryResults fromJSI(jsi::Runtime&, const jsi::Value&) {
diff --git a/packages/react-native-nitro-sqlite/src/specs/NativeQueryResult.ts b/packages/react-native-nitro-sqlite/src/specs/NativeQueryResult.ts
index 3c9cc305..20728b4f 100644
--- a/packages/react-native-nitro-sqlite/src/specs/NativeQueryResult.ts
+++ b/packages/react-native-nitro-sqlite/src/specs/NativeQueryResult.ts
@@ -1,7 +1,10 @@
import type { CustomType } from 'react-native-nitro-modules'
import type { ColumnType, SQLiteValue } from '../types'
-/** Result rows keyed by column name. Native rows become JavaScript objects on the JavaScript thread. */
+/** Result rows keyed by column name, decoded through Nitro's `AnyJSON` converter.
+ * BLOBs remain `ArrayBuffer` values, and non-finite numbers retain their numeric values.
+ * Duplicate column names use the last column's value.
+ */
export type NativeQueryRows = CustomType<
Record[],
'margelo::nitro::rnnitrosqlite::SQLiteQueryResults',
diff --git a/packages/react-native-nitro-sqlite/tests/cpp/json.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/json.test.cpp
new file mode 100644
index 00000000..057fa63e
--- /dev/null
+++ b/packages/react-native-nitro-sqlite/tests/cpp/json.test.cpp
@@ -0,0 +1,86 @@
+#include "SQLiteJSON.hpp"
+#include "sqlite/sqlite3.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace margelo::nitro::rnnitrosqlite;
+
+int main() {
+ sqlite3* database = nullptr;
+ assert(sqlite3_open(":memory:", &database) == SQLITE_OK);
+ sqlite3_stmt* statement = nullptr;
+ assert(sqlite3_prepare_v2(database, "SELECT json_valid(?1), json_extract(?1, '$')", -1, &statement, nullptr) == SQLITE_OK);
+
+ std::string controls;
+ for (char value = 0; value < 32; value++)
+ controls.push_back(value);
+ const std::vector strings = {
+ "", "plain", "quote\" and backslash\\", controls, std::string("a\0b", 3), "café 中 😀", "line\nfeed\rtab\t"};
+ for (const auto& value : strings) {
+ std::string json;
+ assert(appendJSONString(json, value));
+ sqlite3_reset(statement);
+ assert(sqlite3_bind_text(statement, 1, json.data(), static_cast(json.size()), SQLITE_TRANSIENT) == SQLITE_OK);
+ assert(sqlite3_step(statement) == SQLITE_ROW);
+ assert(sqlite3_column_int(statement, 0) == 1);
+ const auto* text = reinterpret_cast(sqlite3_column_text(statement, 1));
+ assert(std::string(text, static_cast(sqlite3_column_bytes(statement, 1))) == value);
+ }
+
+ for (const std::string value : {"\x80", "\xc0\xaf", "\xe0\x80\xaf", "\xed\xa0\x80", "\xf4\x90\x80\x80", "\xff", "\xe2\x82"}) {
+ std::string json;
+ assert(!appendJSONString(json, value));
+ }
+
+ const auto checkNumber = [](double value) {
+ std::string json;
+ assert(appendJSONNumber(json, value));
+ char* end = nullptr;
+ const double decoded = std::strtod(json.c_str(), &end);
+ assert(end == json.c_str() + json.size());
+ assert(std::bit_cast(decoded) == std::bit_cast(value));
+ // Apple targets use SQLite's formatter instead of floating-point to_chars.
+ if (value != 0) {
+ char fallback[32];
+ sqlite3_snprintf(sizeof(fallback), fallback, "%!.17g", value);
+ assert(std::bit_cast(std::strtod(fallback, nullptr)) == std::bit_cast(value));
+ }
+ };
+ for (const double value : {0.0, -0.0, 1.0, -1.0, 0.1, std::nextafter(1.0, 2.0), std::numeric_limits::max(),
+ std::numeric_limits::min(), std::numeric_limits::denorm_min()}) {
+ checkNumber(value);
+ }
+ std::mt19937_64 random(42);
+ for (int index = 0; index < 10000; index++) {
+ const double value = std::bit_cast(random());
+ if (std::isfinite(value))
+ checkNumber(value);
+ }
+ for (const double value :
+ {std::numeric_limits::infinity(), -std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) {
+ std::string json = "prefix";
+ assert(!appendJSONNumber(json, value));
+ assert(json == "prefix");
+ }
+
+ for (const int64_t value : {int64_t{0}, int64_t{1}, int64_t{-1}, std::numeric_limits::min(), std::numeric_limits::max(),
+ int64_t{9007199254740993}}) {
+ std::string json;
+ appendJSONInteger(json, value);
+ char* end = nullptr;
+ assert(std::strtoll(json.c_str(), &end, 10) == value);
+ assert(end == json.c_str() + json.size());
+ }
+
+ sqlite3_finalize(statement);
+ assert(sqlite3_close(database) == SQLITE_OK);
+ std::cout << "[PASS] JSON string escaping and exact finite double round trips\n";
+}
diff --git a/scripts/test-cpp.sh b/scripts/test-cpp.sh
index 134312a5..af15e3a1 100644
--- a/scripts/test-cpp.sh
+++ b/scripts/test-cpp.sh
@@ -10,6 +10,21 @@ clang \
-c packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.c \
-o /tmp/sqlite3.o
+clang++ \
+ -std=c++20 \
+ -Wall \
+ -Wextra \
+ -Werror \
+ -Ipackages/react-native-nitro-sqlite/cpp \
+ packages/react-native-nitro-sqlite/cpp/SQLiteJSON.cpp \
+ packages/react-native-nitro-sqlite/tests/cpp/json.test.cpp \
+ /tmp/sqlite3.o \
+ -ldl \
+ -lm \
+ -pthread \
+ -o /tmp/sqliteJSONTests
+/tmp/sqliteJSONTests
+
clang++ \
-std=c++20 \
-Wall \