Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/ci-paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/guides/sync-and-async.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<span id="ordering-on-one-connection" />
Expand Down
2 changes: 2 additions & 0 deletions example/tests/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down
184 changes: 184 additions & 0 deletions example/tests/unit/specs/operations/queryResults.spec.ts
Original file line number Diff line number Diff line change
@@ -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<NativeQueryResult>
}[] = [
{
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<NativeQueryResult> {
const statement = testDb.prepare(query)
try {
return asynchronous
? await statement.executeAsync(params)
: statement.execute(params)
} finally {
statement.finalize()
}
}
82 changes: 82 additions & 0 deletions packages/react-native-nitro-sqlite/cpp/SQLiteJSON.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include "SQLiteJSON.hpp"
#include "sqlite/sqlite3.h"
#include <charconv>
#include <cmath>
#include <stdexcept>

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<unsigned char>(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<unsigned char>(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<unsigned char>(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<char>(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
18 changes: 18 additions & 0 deletions packages/react-native-nitro-sqlite/cpp/SQLiteJSON.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#pragma once

#include <cstdint>
#include <string>
#include <string_view>

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
Loading
Loading