diff --git a/docs/content/docs/guides/load-sql-file.mdx b/docs/content/docs/guides/load-sql-file.mdx index 4e4bf648..f04534f6 100644 --- a/docs/content/docs/guides/load-sql-file.mdx +++ b/docs/content/docs/guides/load-sql-file.mdx @@ -26,4 +26,10 @@ INSERT INTO tags (name) VALUES ('home'); The loader counts executed lines in `commands` and adds their affected row counts in `rowsAffected`. It rolls back when a statement fails. Choose `loadFileAsync()` for a larger file to keep the database work off the JavaScript thread. Use [batch operations](/docs/guides/batch-operations) when statements need bound parameters or when you already have the commands in JavaScript. +## Import failures + +SQLite can reject a statement or fail to commit the import. NitroSQLite attempts one rollback after a failed import transaction, preserving the source path and the original SQL error. Statement failures also include the one-based file line and failing command. A failed BEGIN does not trigger rollback of an existing transaction. If rollback itself fails, its error is appended to the original import error. + +The managed methods throw or reject with `NitroSQLiteError` and native category `CouldNotLoadFile`. After a successful rollback, completed statements from the file are discarded and the connection can be used again. The error includes the failing SQL text, so avoid including sensitive literal values in files whose errors may be logged. + Both methods return a [`FileLoadResult`](/api/react-native-nitro-sqlite/interfaces/FileLoadResult) with optional `commands` and `rowsAffected` numbers. diff --git a/example/ios/NitroSQLiteExample.xcodeproj/project.pbxproj b/example/ios/NitroSQLiteExample.xcodeproj/project.pbxproj index de1347fa..aa99e08c 100644 --- a/example/ios/NitroSQLiteExample.xcodeproj/project.pbxproj +++ b/example/ios/NitroSQLiteExample.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7B5735B8E367752583C44170 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; A7EB6D102DCB00010003AF01 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7EB6D0F2DCB00010003AF01 /* SceneDelegate.swift */; }; + D463F2F53B094E469119C69E /* load-file-with-error.sql in Resources */ = {isa = PBXBuildFile; fileRef = D463F2F53B094E469119C69F /* load-file-with-error.sql */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +30,7 @@ A7EB6D0F2DCB00010003AF01 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = NitroSQLiteExample/SceneDelegate.swift; sourceTree = ""; }; C3E89C92C507841F368080BD /* Pods_NitroSQLiteExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NitroSQLiteExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + D463F2F53B094E469119C69F /* load-file-with-error.sql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "load-file-with-error.sql"; path = "NitroSQLiteExample/load-file-with-error.sql"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +48,7 @@ 13B07FAE1A68108700A75B9A /* NitroSQLiteExample */ = { isa = PBXGroup; children = ( + D463F2F53B094E469119C69F /* load-file-with-error.sql */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, 4A947A180A327B2E26F1CC50 /* BenchmarkMemory.m */, @@ -164,6 +167,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D463F2F53B094E469119C69E /* load-file-with-error.sql in Resources */, 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 7B5735B8E367752583C44170 /* PrivacyInfo.xcprivacy in Resources */, diff --git a/example/ios/NitroSQLiteExample/AppDelegate.swift b/example/ios/NitroSQLiteExample/AppDelegate.swift index 9ca655b1..8064f1a2 100644 --- a/example/ios/NitroSQLiteExample/AppDelegate.swift +++ b/example/ios/NitroSQLiteExample/AppDelegate.swift @@ -15,6 +15,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + if let loadFileFixturePath = Bundle.main.path( + forResource: "load-file-with-error", + ofType: "sql" + ) { + UserDefaults.standard.set(loadFileFixturePath, forKey: "loadFileFixturePath") + } + let delegate = ReactNativeDelegate() let factory = RCTReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() diff --git a/example/ios/NitroSQLiteExample/load-file-with-error.sql b/example/ios/NitroSQLiteExample/load-file-with-error.sql new file mode 100644 index 00000000..eb93fd86 --- /dev/null +++ b/example/ios/NitroSQLiteExample/load-file-with-error.sql @@ -0,0 +1,2 @@ +INSERT INTO LoadFileRegression (value) VALUES ('rolled back'); +THIS IS NOT VALID SQL; diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index e120e9d4..30d0a01d 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -1,5 +1,6 @@ import { beforeEach, describe } from '../TestApi' import { setupTestDb } from './common' +import registerLoadFileUnitTests from './specs/operations/loadFile.spec' import registerExecuteUnitTests from './specs/operations/execute.spec' import registerTransactionUnitTests from './specs/operations/transaction.spec' import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' @@ -16,6 +17,7 @@ export function registerUnitTests() { describe('operations/execute.spec.ts', registerExecuteUnitTests) describe('operations/transaction.spec.ts', registerTransactionUnitTests) describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests) + describe('operations/loadFile.spec.ts', registerLoadFileUnitTests) describe( 'operations/preparedStatement.spec.ts', registerPreparedStatementUnitTests, diff --git a/example/tests/unit/specs/operations/loadFile.spec.ts b/example/tests/unit/specs/operations/loadFile.spec.ts new file mode 100644 index 00000000..70002c50 --- /dev/null +++ b/example/tests/unit/specs/operations/loadFile.spec.ts @@ -0,0 +1,53 @@ +import { Platform, Settings } from 'react-native' +import { expect } from '@tests/unit/common' +import { describe, it } from '@tests/TestApi' +import { testDb } from '@tests/db' + +export default function registerLoadFileUnitTests() { + if (Platform.OS !== 'ios') { + return + } + + const loadFileFixturePath = Settings.get('loadFileFixturePath') + + describe('loadFile', () => { + it('preserves the SQL error context and rolls back the completed commands', () => { + if (typeof loadFileFixturePath !== 'string') { + throw new Error('SQL import fixture path is missing') + } + testDb.execute( + 'CREATE TABLE LoadFileRegression (value TEXT NOT NULL) STRICT;', + ) + + let errorMessage: string | undefined + try { + testDb.loadFile(loadFileFixturePath) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + + errorMessage = error.message + } + + expect(errorMessage).toContain('Could not load file:') + expect(errorMessage).toContain('load-file-with-error.sql') + expect(errorMessage).toContain('line 2') + expect(errorMessage).toContain('THIS IS NOT VALID SQL;') + expect(errorMessage).toContain('syntax error') + + const rollbackResult = testDb.execute( + 'SELECT COUNT(*) AS count FROM LoadFileRegression;', + ) + expect(rollbackResult.rows?._array).toEqual([{ count: 0 }]) + + testDb.execute( + "INSERT INTO LoadFileRegression (value) VALUES ('connection remains usable');", + ) + const usableConnectionResult = testDb.execute( + 'SELECT COUNT(*) AS count FROM LoadFileRegression;', + ) + expect(usableConnectionResult.rows?._array).toEqual([{ count: 1 }]) + }) + }) +} diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp index dadd2e0d..8ee40330 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp @@ -7,42 +7,62 @@ #include "NitroSQLiteOperations.hpp" #include #include +#include namespace margelo::nitro::rnnitrosqlite { SQLiteOperationResult importSqlFile(const SQLiteConnectionPtr& connection, const std::string& fileLocation) { std::lock_guard lock(connection->mutex); - std::string line; std::ifstream sqFile(fileLocation); - if (sqFile.is_open()) { - try { - int rowsAffected = 0; - int commands = 0; - sqliteExecuteCommand(connection, "BEGIN EXCLUSIVE TRANSACTION"); - while (std::getline(sqFile, line, '\n')) { - if (!line.empty()) { - try { - SQLiteOperationResult result = sqliteExecuteCommand(connection, line); - rowsAffected += result.rowsAffected; - commands++; - } catch (NitroSQLiteException& e) { - sqliteExecuteCommand(connection, "ROLLBACK"); - sqFile.close(); - throw NitroSQLiteException::CouldNotLoadFile(fileLocation, "Transaction was rolled back"); - } - } + if (!sqFile.is_open()) { + throw NitroSQLiteException::CouldNotLoadFile(fileLocation); + } + + int rowsAffected = 0; + int commands = 0; + int lineNumber = 0; + bool transactionStarted = false; + std::string command = "BEGIN EXCLUSIVE TRANSACTION"; + std::optional commandLine; + + try { + sqliteExecuteCommand(connection, command); + transactionStarted = true; + + std::string line; + while (std::getline(sqFile, line, '\n')) { + lineNumber++; + if (!line.empty()) { + command = line; + commandLine = lineNumber; + SQLiteOperationResult result = sqliteExecuteCommand(connection, command); + rowsAffected += result.rowsAffected; + commands++; } + } - sqFile.close(); - sqliteExecuteCommand(connection, "COMMIT"); - return {.rowsAffected = rowsAffected, .commands = commands}; - } catch (...) { - sqFile.close(); - sqliteExecuteCommand(connection, "ROLLBACK"); - throw NitroSQLiteException(NitroSQLiteExceptionType::UnknownError, "Unexpected error. Transaction was rolled back"); + command = "COMMIT"; + commandLine.reset(); + sqliteExecuteCommand(connection, command); + transactionStarted = false; + return {.rowsAffected = rowsAffected, .commands = commands}; + } catch (const std::exception& primaryError) { + std::string errorContext; + if (commandLine) { + errorContext = "line " + std::to_string(*commandLine) + " failed to execute `" + command + "`: " + primaryError.what(); + } else { + errorContext = "Failed to execute `" + command + "`: " + primaryError.what(); } - } else { - throw NitroSQLiteException::CouldNotLoadFile(fileLocation); + + if (transactionStarted) { + try { + sqliteExecuteCommand(connection, "ROLLBACK"); + } catch (const std::exception& rollbackError) { + errorContext += ". ROLLBACK failed: " + std::string(rollbackError.what()); + } + } + + throw NitroSQLiteException::CouldNotLoadFile(fileLocation, errorContext); } } diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp index 87b3ba95..6c655b29 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp @@ -13,6 +13,11 @@ namespace margelo::nitro::rnnitrosqlite { struct SQLiteConnection; +/** Import one nonempty line per statement in an exclusive transaction. + * Holds the retained connection's lock for the entire import. + * File and SQL failures throw CouldNotLoadFile with the path and original SQL/line context. + * Attempts rollback only after BEGIN succeeds; rollback errors are appended. + */ SQLiteOperationResult importSqlFile(const std::shared_ptr& connection, const std::string& fileLocation); } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts index 406eab12..dd30bc97 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts @@ -130,12 +130,17 @@ export interface NitroSQLite * @param dbName Name of an open database. * @param location Path to the SQL file. * @returns Number of executed commands and affected rows. + * @throws An Error with native category CouldNotLoadFile on an import + * failure, including the path, failing SQL/line, and any rollback failure. + * Completed commands are discarded when rollback succeeds. */ loadFile(dbName: string, location: string): FileLoadResult /** Import a SQL file on a background thread. * @param dbName Name of an open database. * @param location Path to the SQL file. * @returns A promise of the command and affected row counts. + * @throws An Error with native category CouldNotLoadFile, preserving + * the original import error and any rollback failure. Rejects asynchronously. */ loadFileAsync(dbName: string, location: string): Promise } diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 99aa4691..7ce24aab 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -83,10 +83,16 @@ export interface NitroSQLiteConnection { * `location` is a path to the SQL file; multi-line statements are unsupported. * @param location Path to the SQL file. * @returns Number of executed commands and affected rows. + * @throws NitroSQLiteError with native category CouldNotLoadFile on an import + * failure, including the path, failing SQL/line, and any rollback failure. + * Completed commands are discarded when rollback succeeds. */ loadFile(location: string): FileLoadResult /** Queue the file import and resolve with its command and row counts. * @param location Path to the SQL file. + * @returns A promise of the command and affected row counts. + * @throws NitroSQLiteError with native category CouldNotLoadFile, preserving + * the original import error and any rollback failure. Rejects asynchronously. */ loadFileAsync(location: string): Promise }