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: 1 addition & 1 deletion docs/content/docs/guides/node-test-mock.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test('stores a note', () => {
})
```

The mock returns query rows through both `results` and `rows`, and supports [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`.
The mock returns query rows through both `results` and `rows`, supports [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays) through `executeRaw()` and `executeRawAsync()`, and supports [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`.

## Share a database between connections

Expand Down
15 changes: 15 additions & 0 deletions docs/content/docs/guides/parameters-and-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ for (const person of result.rows._array) {

SQLite INTEGER and REAL result values both arrive as JavaScript numbers. BLOB values arrive as `ArrayBuffer`; NULL values arrive as `null`, even if the parameter was `undefined`. A bound boolean is stored through SQLite's integer binding, so read it as a number and convert it in application code if needed. JavaScript numbers cannot represent every 64-bit SQLite integer exactly; choose a storage representation appropriate for identifiers that exceed the safe integer range.

## Rows as arrays

A row object holds one value per column name, so when two result columns share a name, the later value replaces the earlier one. This happens in joins such as `SELECT users.id, pets.id ...`. `db.executeRaw()` and `db.executeRawAsync()` take the same arguments as `execute()` and `executeAsync()` but return each row as an array of values in column order, so every column is kept.

```ts
const { columnNames, rows } = db.executeRaw(
'SELECT users.id, users.name, pets.id, pets.name FROM users LEFT JOIN pets ON pets.owner_id = users.id',
)

// columnNames: ['id', 'name', 'id', 'name']
// rows: [[1, 'Ada', 7, 'Rex'], [2, 'Grace', null, null]]
```

The result has `columnNames`, `rows`, `rowsAffected`, and `insertId`. It has no `rows._array` container and no `metadata`. `columnNames` is filled even when the query returns no rows. Transactions and prepared statements have the same two methods. The exact shape is in [`NitroSQLiteRawQueryResult`](/api/react-native-nitro-sqlite/interfaces/NitroSQLiteRawQueryResult).

## Column metadata

For a query with result columns, `metadata` maps column names to `{ name, type, index }`. `index` is the zero-based result column position. The [`ColumnType` values](/api/react-native-nitro-sqlite/enumerations/ColumnType) are `BOOLEAN = 0`, `NUMBER = 1`, `INT64 = 2`, `TEXT = 3`, `ARRAY_BUFFER = 4`, and `NULL_VALUE = 5`. The package exports `ColumnType` as a TypeScript type, not a runtime enum object. In the current native implementation, `metadata.type` is unreliable for detecting a column's declared SQL type. Use your schema for that decision. An expression without a declared type maps to `NULL_VALUE`, which does not mean the result itself is null. The exact shape is in [`NitroSQLiteQueryColumnMetadata`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryColumnMetadata).
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guides/prepared-statements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Prepared statements
description: Reuse a compiled SQL statement with different bound values.
---

Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`. Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept.
Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`, plus `executeRaw(params)` and `executeRawAsync(params)` for [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays). Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept.

```ts
import { open } from 'react-native-nitro-sqlite'
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guides/sync-and-async.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Pick the query form that fits your workload and understand connecti

Use `db.execute()` for a small query that needs an immediate result, and `await db.executeAsync()` for work that may take longer. A synchronous call blocks its JavaScript caller until SQLite finishes. An asynchronous call returns a promise and lets JavaScript continue while the database work runs in the background.

`executeBatch` and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL.
`executeRaw`, `executeBatch`, and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL.

```ts
import { open } from 'react-native-nitro-sqlite'
Expand Down
6 changes: 3 additions & 3 deletions docs/content/docs/guides/transactions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Throw from the callback when an application check fails. Use `tx` for those chec

## Commit and rollback behavior

When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.commit`, or `tx.rollback` after finalization throw.
When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.executeRaw`, `tx.executeRawAsync`, `tx.commit`, or `tx.rollback` after finalization throw.

A failed COMMIT can leave SQLite's transaction active, for example when a deferred foreign-key constraint fails. NitroSQLite marks a commit complete only after SQLite accepts it. A failed manual or automatic commit triggers rollback and rejects with the commit error. This also happens if the callback catches a failed `tx.commit()` and returns without explicitly rolling back. After the failure, only `tx.rollback()` is allowed on that transaction handle.

Expand All @@ -39,7 +39,7 @@ If rollback also fails, the rejection preserves the original message and appends

<span id="check-application-rules" />

The connection queue is occupied for the whole callback. Use `tx.execute` or `tx.executeAsync` for every statement on this database during that callback, including statements in helpers.
The connection queue is occupied for the whole callback. Use the `tx` methods (`execute`, `executeAsync`, `executeRaw`, or `executeRawAsync`) for every statement on this database during that callback, including statements in helpers.

> [!TIP]
> Pass `tx` to any helper that runs statements during the transaction.
Expand All @@ -57,7 +57,7 @@ await db.transaction(async (tx) => {

## Await pending statements

Await every `tx.executeAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction.
Await every `tx.executeAsync()` and `tx.executeRawAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.executeRaw()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction.

`tx.commit()` and `tx.rollback()` are synchronous. Their declared return type exposes the native query result fields. The JavaScript helper also adds `rows` at runtime, but that field is not in their declared type. Use them to finalize the transaction, not to retrieve rows. The transaction starts with `BEGIN TRANSACTION`; the global [`NitroSQLite.transaction`](/api/react-native-nitro-sqlite/variables/NitroSQLite) helper also accepts `isExclusive` to use `BEGIN EXCLUSIVE TRANSACTION`. See [transaction modes](/docs/concepts/transactions#transaction-modes) for the SQLite differences.

Expand Down
1 change: 1 addition & 0 deletions docs/scripts/check-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const requiredExports = {
'NitroSQLiteNative',
'NitroSQLiteQueryResult',
'NitroSQLiteQueryColumnMetadata',
'NitroSQLiteRawQueryResult',
'TypeOrmNitroSQLiteConnection',
'typeORMDriver',
],
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 registerExecuteRawUnitTests from './specs/operations/executeRaw.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/executeRaw.spec.ts', registerExecuteRawUnitTests)
describe('operations/transaction.spec.ts', registerTransactionUnitTests)
describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests)
describe('operations/loadFile.spec.ts', registerLoadFileUnitTests)
Expand Down
145 changes: 145 additions & 0 deletions example/tests/unit/specs/operations/executeRaw.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
expect,
isNitroSQLiteError,
TEST_ERROR_CODES,
} from '@tests/unit/common'
import { describe, it } from '@tests/TestApi'
import { testDb } from '@tests/db'

const valuesQuery =
'SELECT 1 AS duplicate, 2 AS duplicate, 3.5 AS "café", NULL AS nullable, zeroblob(2) AS payload'

const joinQuery = `
SELECT owners.id, owners.name, pets.id, pets.name
FROM owners LEFT JOIN pets ON pets.owner_id = owners.id
ORDER BY owners.id
`

function createJoinTables() {
testDb.execute('CREATE TABLE owners (id INTEGER PRIMARY KEY, name TEXT)')
testDb.execute(
'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT, owner_id INTEGER)',
)
testDb.execute("INSERT INTO owners VALUES (1, 'Dave'), (2, 'Ritesh')")
testDb.execute("INSERT INTO pets VALUES (7, 'Rex', 1)")
}

function expectValuesRow(row: unknown[] | undefined) {
expect(row?.slice(0, 4)).toEqual([1, 2, 3.5, null])
expect(Array.from(new Uint8Array(row?.[4] as ArrayBuffer))).toEqual([0, 0])
}

export default function registerExecuteRawUnitTests() {
describe('executeRaw', () => {
it('returns every value type and keeps repeated column names', async () => {
const result = testDb.executeRaw(valuesQuery)
expect(result.columnNames).toEqual([
'duplicate',
'duplicate',
'café',
'nullable',
'payload',
])
expect(result.rows).toHaveLength(1)
expectValuesRow(result.rows[0])

const asyncResult = await testDb.executeRawAsync(valuesQuery)
expect(asyncResult.columnNames).toEqual(result.columnNames)
expectValuesRow(asyncResult.rows[0])
})

it('keeps both sides of a join with matching column names', async () => {
createJoinTables()
const expected = [
[1, 'Dave', 7, 'Rex'],
[2, 'Ritesh', null, null],
]

const result = testDb.executeRaw(joinQuery)
expect(result.columnNames).toEqual(['id', 'name', 'id', 'name'])
expect(result.rows).toEqual(expected)
expect((await testDb.executeRawAsync(joinQuery)).rows).toEqual(expected)
})

it('binds parameters and reports changes for writes', async () => {
const inserted = testDb.executeRaw(
'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)',
[1, 'Ada', 36, 1.5],
)
expect(inserted.rowsAffected).toBe(1)
expect(inserted.insertId).toBe(1)
expect(inserted.columnNames).toEqual([])
expect(inserted.rows).toEqual([])

const selected = await testDb.executeRawAsync(
'SELECT name, age FROM User WHERE id = ?',
[1],
)
expect(selected.rows).toEqual([['Ada', 36]])
})

it('returns column names for an empty result', () => {
const result = testDb.executeRaw('SELECT id, name FROM User WHERE 0')
expect(result.columnNames).toEqual(['id', 'name'])
expect(result.rows).toEqual([])
})

it('returns large results in order', async () => {
const query = `
WITH RECURSIVE counter(value) AS (
SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < 10000
)
SELECT value, 'row-' || value FROM counter
`
for (const result of [
testDb.executeRaw(query),
await testDb.executeRawAsync(query),
]) {
expect(result.rows).toHaveLength(10_000)
expect(result.rows[0]).toEqual([1, 'row-1'])
expect(result.rows[9_999]).toEqual([10_000, 'row-10000'])
}
})

it('runs inside a transaction', async () => {
createJoinTables()
await testDb.transaction(async (tx) => {
tx.executeRaw("INSERT INTO pets VALUES (8, 'Tom', 2)")
expect((await tx.executeRawAsync(joinQuery)).rows).toEqual([
[1, 'Dave', 7, 'Rex'],
[2, 'Ritesh', 8, 'Tom'],
])
})
})

it('runs prepared statements and switches between raw and object rows', async () => {
createJoinTables()
const statement = testDb.prepare(`${joinQuery} LIMIT ?`)
try {
expect(statement.executeRaw([1]).rows).toEqual([[1, 'Dave', 7, 'Rex']])
expect(statement.execute([1]).results).toEqual([{ id: 7, name: 'Rex' }])
const asyncResult = await statement.executeRawAsync([2])
expect(asyncResult.columnNames).toEqual(['id', 'name', 'id', 'name'])
expect(asyncResult.rows).toHaveLength(2)
} finally {
statement.finalize()
}
})

it('throws NitroSQLiteError for invalid SQL', async () => {
try {
testDb.executeRaw('SELECT * FROM missing_table')
throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR)
} catch (error) {
expect(isNitroSQLiteError(error)).toBe(true)
}

try {
await testDb.executeRawAsync('SELECT * FROM missing_table')
throw new Error(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION)
} catch (error) {
expect(isNitroSQLiteError(error)).toBe(true)
}
})
})
}
69 changes: 55 additions & 14 deletions packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ namespace {
// and a smaller final batch shortens the conversion left after the statement completes.
constexpr size_t kRowBatchSize = 256;

NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) {
// Reads every row of a bound statement. With onRows, full batches go to it and only the rows
// after the last batch are returned.
SQLiteQueryResultData readStatementRows(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows) {
int columnCount = 0;
std::vector<std::string> columnNames;
std::vector<SQLiteQueryResultRow> rows;
Expand Down Expand Up @@ -292,8 +294,14 @@ namespace {
captureColumns();
}

return {std::move(columnNames), std::move(rows)};
}

NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) {
auto [columnNames, rows] = readStatementRows(db, statement, onRows);

std::optional<SQLiteQueryTableMetadata> metadata = std::nullopt;
for (int i = 0; i < columnCount; i++) {
for (int i = 0; i < static_cast<int>(columnNames.size()); i++) {
const std::string& columnName = columnNames[i];
ColumnType columnDeclaredType = mapSQLiteTypeToColumnType(sqlite3_column_decltype(statement, i));
auto columnMeta = NitroSQLiteQueryColumnMetadata(columnName, std::move(columnDeclaredType), i);
Expand All @@ -312,21 +320,45 @@ namespace {
return result;
}

NitroSQLiteRawQueryResult executeStatementRaw(sqlite3* db, sqlite3_stmt* statement) {
auto [columnNames, rows] = readStatementRows(db, statement, nullptr);

NitroSQLiteRawQueryResult result;
result.rowsAffected = sqlite3_changes(db);
result.insertId = static_cast<double>(sqlite3_last_insert_rowid(db));
result.columnNames = columnNames;
result.rows = SQLiteRawQueryResults(SQLiteQueryResults(std::move(columnNames), std::move(rows)));
return result;
}

// Runs a query on the connection's cached statement for it, bound to params.
template <typename Execute>
auto executeCached(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional<SQLiteQueryParams>& params,
Execute&& execute) {
std::lock_guard lock(connection->mutex);
sqlite3* db = connection->database;
if (db == nullptr) {
throw NitroSQLiteException::DatabaseNotOpen(connection->name);
}

CachedStatement statement(*connection, query);
if (params) {
bindStatement(statement.get(), *params);
}
return execute(db, statement.get());
}

} // namespace

NitroSQLiteQueryResult sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query,
const std::optional<SQLiteQueryParams>& params, const SQLiteRowBatchHandler& onRows) {
std::lock_guard lock(connection->mutex);
sqlite3* db = connection->database;
if (db == nullptr) {
throw NitroSQLiteException::DatabaseNotOpen(connection->name);
}
return executeCached(connection, query, params,
[&](sqlite3* db, sqlite3_stmt* statement) { return executeStatement(db, statement, onRows); });
}

CachedStatement statement(*connection, query);
if (params) {
bindStatement(statement.get(), *params);
}
return executeStatement(db, statement.get(), onRows);
NitroSQLiteRawQueryResult sqliteExecuteRaw(const SQLiteConnectionPtr& connection, const std::string& query,
const std::optional<SQLiteQueryParams>& params) {
return executeCached(connection, query, params, executeStatementRaw);
}

SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query,
Expand Down Expand Up @@ -379,7 +411,8 @@ SQLitePreparedStatement::~SQLitePreparedStatement() {
finalize();
}

NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional<SQLiteQueryParams>& params) {
template <typename Execute>
auto SQLitePreparedStatement::executeBound(const std::optional<SQLiteQueryParams>& params, Execute&& execute) {
std::lock_guard lock(_state->mutex);
std::lock_guard connectionLock(_state->connection->mutex);

Expand Down Expand Up @@ -407,13 +440,21 @@ NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional<SQLi
}

try {
return executeStatement(database, _state->statement.get());
return execute(database, _state->statement.get());
} catch (...) {
sqlite3_reset(_state->statement.get());
throw;
}
}

NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional<SQLiteQueryParams>& params) {
return executeBound(params, [](sqlite3* database, sqlite3_stmt* statement) { return executeStatement(database, statement); });
}

NitroSQLiteRawQueryResult SQLitePreparedStatement::executeRaw(const std::optional<SQLiteQueryParams>& params) {
return executeBound(params, executeStatementRaw);
}

void SQLitePreparedStatement::finalize() {
std::lock_guard lock(_state->mutex);
std::lock_guard connectionLock(_state->connection->mutex);
Expand Down
Loading
Loading