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 \