Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/ci-paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.*",
"packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.*",
"packages/react-native-nitro-sqlite/cpp/NitroSQLiteStatementGroup.hpp",
"packages/react-native-nitro-sqlite/cpp/NitroSQLiteStatementTail.hpp",
"packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*",
"packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3-symbol-prefix.h",
"packages/react-native-nitro-sqlite-vec/cpp/**",
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guides/load-sql-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ console.log(result.commands, result.rowsAffected)

## SQL file format

The file format is deliberately simple. Each nonempty line is sent to SQLite as one statement. Do not split a statement across lines, put a comment on its own line, or rely on a general SQL dump parser. For example:
The file format is deliberately simple. Each nonempty line is sent to SQLite as one statement, and a line containing more than one statement fails the import. Do not split a statement across lines, put a comment on its own line, or rely on a general SQL dump parser. For example:

```sql
CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guides/parameters-and-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Be especially careful with nullable columns and expressions when specifying a ro

## Errors

The connection's JavaScript helpers normalize database failures to `NitroSQLiteError`. Async methods reject; sync methods throw. An empty SQL string or one containing only comments also fails with the native category `SqlExecutionError`, because SQLite produces no statement to execute. Catch the error around the operation you can recover from:
The connection's JavaScript helpers normalize database failures to `NitroSQLiteError`. Async methods reject; sync methods throw. An empty SQL string or one containing only comments also fails with the native category `SqlExecutionError`, because SQLite produces no statement to execute. Each query runs one statement, so SQL that contains another statement after the first fails with the same category instead of running only the first one; trailing whitespace, comments, and semicolons are allowed. Use [batch operations](/docs/guides/batch-operations) to run several statements. Catch the error around the operation you can recover from:

```ts
import { NitroSQLiteError } from 'react-native-nitro-sqlite'
Expand Down
27 changes: 27 additions & 0 deletions example/tests/unit/specs/operations/execute.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,33 @@ export default function registerExecuteUnitTests() {
)
})

it('rejects a query that contains more than one statement', async () => {
const query =
'CREATE TABLE MultiStatementFirst (id INTEGER); CREATE TABLE MultiStatementSecond (id INTEGER)'

for (const run of [
() => testDb.execute(query),
() => testDb.executeAsync(query),
() => testDb.prepare(query),
]) {
try {
await run()
throw new Error('Expected a multi-statement query to fail')
} catch (error) {
if (!isNitroSQLiteError(error)) throw error
expect(error.message).toContain(
'Query contains more than one SQL statement',
)
}
}

expect(
testDb.execute(
"SELECT name FROM sqlite_schema WHERE name LIKE 'MultiStatement%'",
).results,
).toEqual([])
})

it('materializes native query results once', () => {
const sourceRows = [
{ id: 1, nullable: null },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "NitroSQLiteException.hpp"
#include "NitroSQLiteLogs.hpp"
#include "NitroSQLiteStatementGroup.hpp"
#include "NitroSQLiteStatementTail.hpp"
#include "NitroSQLiteUtils.hpp"
#include "hybridObjects/HybridNitroSQLiteQueryResult.hpp"
#include "sqlite/sqlite3.h"
Expand Down Expand Up @@ -164,7 +165,8 @@ namespace {

SQLiteStatement prepareStatement(sqlite3* db, const std::string& query, const std::optional<SQLiteQueryParams>& params) {
sqlite3_stmt* rawStatement = nullptr;
int statementStatus = sqlite3_prepare_v2(db, query.c_str(), -1, &rawStatement, nullptr);
const char* tail = nullptr;
int statementStatus = sqlite3_prepare_v2(db, query.c_str(), -1, &rawStatement, &tail);
SQLiteStatement statement(rawStatement);

if (statementStatus != SQLITE_OK) {
Expand All @@ -177,6 +179,11 @@ namespace {
throw NitroSQLiteException::SqlExecution("Query does not contain any SQL statement");
}

// Only the first statement would run, so reject the query instead of silently skipping the rest.
if (hasTrailingStatement(db, tail)) {
throw NitroSQLiteException::SqlExecution("Query contains more than one SQL statement");
}

if (params) {
bindStatement(statement.get(), *params);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include <sqlite3.h>

namespace margelo::nitro::rnnitrosqlite {

// sqlite3_prepare_v2 compiles only the first statement of a query and points its tail at the rest.
// Preparing that tail lets SQLite decide whether it holds more than whitespace, comments, or semicolons:
// those prepare to a null statement, while another statement prepares or fails to prepare.
// Shared with the host test so the check runs against the bundled SQLite.
inline bool hasTrailingStatement(sqlite3* db, const char* tail) {
if (tail == nullptr || *tail == '\0') {
return false;
}

sqlite3_stmt* statement = nullptr;
const int status = sqlite3_prepare_v2(db, tail, -1, &statement, nullptr);
sqlite3_finalize(statement);
return status != SQLITE_OK || statement != nullptr;
}

} // namespace margelo::nitro::rnnitrosqlite
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#include "NitroSQLiteStatementTail.hpp"
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>

using margelo::nitro::rnnitrosqlite::hasTrailingStatement;

namespace {

void expect(bool condition, const std::string& message) {
if (!condition) {
throw std::runtime_error(message);
}
}

bool queryHasTrailingStatement(sqlite3* db, const std::string& query) {
sqlite3_stmt* raw = nullptr;
const char* tail = nullptr;
if (sqlite3_prepare_v2(db, query.c_str(), -1, &raw, &tail) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(db));
}
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)> statement(raw, sqlite3_finalize);
expect(statement != nullptr, "query should contain a statement: " + query);
return hasTrailingStatement(db, tail);
}

} // namespace

int main() {
sqlite3* raw = nullptr;
if (sqlite3_open(":memory:", &raw) != SQLITE_OK) {
return 1;
}
std::unique_ptr<sqlite3, decltype(&sqlite3_close)> db(raw, sqlite3_close);
try {
for (const std::string query : {
"SELECT 1",
"SELECT 1;",
"SELECT 1;\n ",
"SELECT 1;;;",
"SELECT 1; -- trailing comment",
"SELECT 1; /* trailing comment */",
"SELECT 1; /* unterminated comment",
}) {
expect(!queryHasTrailingStatement(db.get(), query), "single statement was rejected: " + query);
}

for (const std::string query : {
"CREATE TABLE foo (id INTEGER); CREATE TABLE bar (id INTEGER);",
"SELECT 1; SELECT 2",
"SELECT 1; -- comment\nSELECT 2",
// The second statement cannot prepare before the first one runs.
"CREATE TABLE later (id INTEGER); INSERT INTO later VALUES (1)",
"SELECT 1; not valid SQL",
}) {
expect(queryHasTrailingStatement(db.get(), query), "trailing statement was not detected: " + query);
}

expect(!hasTrailingStatement(db.get(), nullptr), "a missing tail should not be rejected");
expect(sqlite3_next_stmt(db.get(), nullptr) == nullptr, "tail check leaked a prepared statement");
std::cout << "[PASS] detects SQL after the first statement" << '\n';
return 0;
} catch (const std::exception& error) {
std::cerr << "[FAIL] " << error.what() << '\n';
return 1;
}
}
15 changes: 15 additions & 0 deletions scripts/test-cpp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ clang++ \
-o /tmp/statementGroupTests
/tmp/statementGroupTests

clang++ \
-std=c++20 \
-Wall \
-Wextra \
-Werror \
-Ipackages/react-native-nitro-sqlite/cpp \
-Ipackages/react-native-nitro-sqlite/cpp/sqlite \
packages/react-native-nitro-sqlite/tests/cpp/statementTail.test.cpp \
/tmp/sqlite3.o \
-ldl \
-lm \
-pthread \
-o /tmp/statementTailTests
/tmp/statementTailTests

clang \
-std=c11 \
-DSQLITE_THREADSAFE=0 \
Expand Down