diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b2a34ce7..d1f8c92ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,7 @@ All notable changes to this project will be documented in this file. GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: APIs flagged [**:fire: EXPERIMENTAL**](README.md#what-are-experimental-features). Those are unstable, and may break between any two minor releases of the library. - #### 4.x Releases @@ -59,9 +57,15 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: - [0.110.0](#01100), ... - + +**New** + +- [#659](https://github.com/groue/GRDB.swift/pull/659): Database interruption + +### Documentation Diff + +A new [Interrupt a Database](README.md#interrupt-a-database) chapter documents the new `interrupt()` method. ## 4.6.2 diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 79eb2bb844..f0dc530690 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -812,6 +812,9 @@ 56DF001C228DDBA300D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0019228DDBA200D611F3 /* AssociationPrefetchingRowTests.swift */; }; 56DF001D228DDBA300D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF001A228DDBA300D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; 56DF001E228DDBA300D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF001A228DDBA300D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; + 56E4F7EE2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7ED2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift */; }; + 56E4F7EF2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7ED2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift */; }; + 56E4F7F02392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7ED2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift */; }; 56E5D7D41B4D3FEE00430942 /* GRDB.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 56E5D7CA1B4D3FED00430942 /* GRDB.framework */; }; 56E5D7FE1B4D422E00430942 /* GRDB.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC3773F319C8CBB3004FCF85 /* GRDB.framework */; }; 56E5D8041B4D424400430942 /* GRDBTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5623E0901B4AFACC00B20B7F /* GRDBTestCase.swift */; }; @@ -1655,6 +1658,7 @@ 56DE7B361C42BBBB00861EB8 /* PerformanceCoreDataTests.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = PerformanceCoreDataTests.sqlite; sourceTree = ""; }; 56DF0019228DDBA200D611F3 /* AssociationPrefetchingRowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingRowTests.swift; sourceTree = ""; }; 56DF001A228DDBA300D611F3 /* AssociationPrefetchingCodableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingCodableRecordTests.swift; sourceTree = ""; }; + 56E4F7ED2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseAbortedTransactionTests.swift; sourceTree = ""; }; 56E5D7CA1B4D3FED00430942 /* GRDB.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GRDB.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 56E5D7D31B4D3FEE00430942 /* GRDBiOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GRDBiOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 56E5D7F91B4D422D00430942 /* GRDBOSXTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GRDBOSXTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2218,6 +2222,7 @@ children = ( 5665F85C203EE6030084C6C0 /* ColumnInfoTests.swift */, 56DAA2C41DE99D8D006E10C8 /* Cursor */, + 56E4F7ED2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift */, 564448821EF56B1B00DD2861 /* DatabaseAfterNextTransactionCommitTests.swift */, 564F9C1D1F069B4E00877A00 /* DatabaseAggregateTests.swift */, 564A50C61BFF4B7F00B3A3A2 /* DatabaseCollationTests.swift */, @@ -3461,6 +3466,7 @@ 56AF746F1D41FB9C005E9FF3 /* DatabaseValueConvertibleEscapingTests.swift in Sources */, 569BBA3722905FFA00478429 /* InflectionsTests.swift in Sources */, 5672DE6A1CDB751D0022BA81 /* DatabasePoolBackupTests.swift in Sources */, + 56E4F7EF2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 561CFA992376E546000C8BAA /* AssociationHasManyThroughOrderingTests.swift in Sources */, 5698AC4D1DA2D48A0056AF8C /* FTS3RecordTests.swift in Sources */, 56057C632291C7C600A7CB10 /* AssociationHasManyThroughRowScopeTests.swift in Sources */, @@ -3666,6 +3672,7 @@ 5698AC491DA2D48A0056AF8C /* FTS3RecordTests.swift in Sources */, 569BBA3622905FFA00478429 /* InflectionsTests.swift in Sources */, 56D496931D81316E008276D7 /* FetchableRecordTests.swift in Sources */, + 56E4F7EE2392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 561CFA982376E546000C8BAA /* AssociationHasManyThroughOrderingTests.swift in Sources */, 56D496881D81316E008276D7 /* DatabaseValueConversionTests.swift in Sources */, 56057C622291C7C600A7CB10 /* AssociationHasManyThroughRowScopeTests.swift in Sources */, @@ -4004,6 +4011,7 @@ AAA4DD3B230F262000C74B15 /* DatabaseValueConvertibleEscapingTests.swift in Sources */, AAA4DD3C230F262000C74B15 /* InflectionsTests.swift in Sources */, AAA4DD3D230F262000C74B15 /* DatabasePoolBackupTests.swift in Sources */, + 56E4F7F02392E2D000A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 561CFA9A2376E546000C8BAA /* AssociationHasManyThroughOrderingTests.swift in Sources */, AAA4DD3E230F262000C74B15 /* FTS3RecordTests.swift in Sources */, AAA4DD3F230F262000C74B15 /* AssociationHasManyThroughRowScopeTests.swift in Sources */, diff --git a/GRDB/Core/Database+Statements.swift b/GRDB/Core/Database+Statements.swift index 859882b916..d42bbc0d5e 100644 --- a/GRDB/Core/Database+Statements.swift +++ b/GRDB/Core/Database+Statements.swift @@ -222,6 +222,12 @@ extension Database { extension Database { func executeUpdateStatement(_ statement: UpdateStatement) throws { + // In aborted transaction, forbid all statements but statements that + // manage transaction. + if statement.transactionEffect == nil { + try assertNotInsideAbortedTransactionBlock(sql: statement.sql, arguments: statement.arguments) + } + let authorizer = observationBroker.updateStatementWillExecute(statement) let sqliteStatement = statement.sqliteStatement var code: Int32 = SQLITE_OK @@ -289,7 +295,9 @@ extension Database { } @inline(__always) - func selectStatementWillExecute(_ statement: SelectStatement) { + func selectStatementWillExecute(_ statement: SelectStatement) throws { + try assertNotInsideAbortedTransactionBlock(sql: statement.sql, arguments: statement.arguments) + if _isRecordingSelectedRegion { // Don't record schema introspection queries, which may be // run, or not, depending on the state of the schema cache. diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 01aae0eb54..f11ee46bb4 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -146,6 +146,38 @@ public final class Database { /// Use recordingSelectedRegion(_:), see `selectStatementWillExecute(_:)` var _isRecordingSelectedRegion: Bool = false var _selectedRegion = DatabaseRegion() + + /// True inside `inTransaction { ... }` and `inSavepoint { ... }` + @usableFromInline var isInsideTransactionBlock = false + + /// When true, a transaction has been aborted (for example, by + /// `sqlite3_interrupt`, or a `ON CONFLICT ROLLBACK` clause. + /// + /// try db.inTransaction { + /// do { + /// // Aborted by sqlite3_interrupt or any other + /// // SQLite error which leaves transaction + /// ... + /// } catch { ... } + /// + /// // <- Here we're inside an aborted transaction. + /// ... + /// + /// return .commit + /// } + /// + /// When a transaction has been aborted, we throw SQLITE_ABORT for all + /// database accesses, because we don't want the user to think they are + /// safely wrapped by a transaction. + /// + /// We only monitor aborted transaction for `inTransaction` and + /// `inSavepoint` methods. When you use explicit methods like + /// `beginTransaction`, `commit`, or raw SQL statements, aborted + /// transactions are not monitored, and you won't get SQLITE_ABORT for + /// database accesses that run after a transaction has been aborted. + @usableFromInline var isInsideAbortedTransactionBlock: Bool { + return isInsideTransactionBlock && !isInsideTransaction + } // MARK: - Private properties @@ -635,11 +667,25 @@ extension Database { throw DatabaseError(resultCode: code, message: lastErrorMessage) } } + + func interrupt() { + sqlite3_interrupt(sqliteConnection) + } } extension Database { // MARK: - Transactions & Savepoint + func assertNotInsideAbortedTransactionBlock(sql: String? = nil, arguments: StatementArguments? = nil) throws { + if isInsideAbortedTransactionBlock { + throw DatabaseError( + resultCode: SQLITE_ABORT, + message: "Transaction was aborted", + sql: sql, + arguments: arguments) + } + } + /// Executes a block inside a database transaction. /// /// try dbQueue.inDatabase do { @@ -666,6 +712,12 @@ extension Database { // Begin transaction try beginTransaction(kind) + let wasInsideTransactionBlock = isInsideTransactionBlock + isInsideTransactionBlock = true + defer { + isInsideTransactionBlock = wasInsideTransactionBlock + } + // Now that transaction has begun, we'll rollback in case of error. // But we'll throw the first caught error, so that user knows // what happened. @@ -675,6 +727,15 @@ extension Database { let completion = try block() switch completion { case .commit: + // In case of aborted transaction, throw SQLITE_ABORT instead + // of the generic SQLITE_ERROR "cannot commit - no transaction is active" + try assertNotInsideAbortedTransactionBlock() + + // Leave transaction block now, so that transaction observers + // can execute statements without getting errors from + // assertNotInsideAbortedTransactionBlock. + isInsideTransactionBlock = wasInsideTransactionBlock + try commit() needsRollback = false case .rollback: @@ -726,7 +787,8 @@ extension Database { // So when the default GRDB transaction kind is not deferred, we open a // transaction instead if !isInsideTransaction && configuration.defaultTransactionKind != .deferred { - return try inTransaction(configuration.defaultTransactionKind, block) + try inTransaction(configuration.defaultTransactionKind, block) + return } // If the savepoint is top-level, we'll use ROLLBACK TRANSACTION in @@ -742,6 +804,12 @@ extension Database { // the user uses "grdb" as a savepoint name. try execute(sql: "SAVEPOINT grdb") + let wasInsideTransactionBlock = isInsideTransactionBlock + isInsideTransactionBlock = true + defer { + isInsideTransactionBlock = wasInsideTransactionBlock + } + // Now that savepoint has begun, we'll rollback in case of error. // But we'll throw the first caught error, so that user knows // what happened. @@ -751,6 +819,15 @@ extension Database { let completion = try block() switch completion { case .commit: + // In case of aborted transaction, throw SQLITE_ABORT instead + // of the generic SQLITE_ERROR "cannot commit - no transaction is active" + try assertNotInsideAbortedTransactionBlock() + + // Leave transaction block now, so that transaction observers + // can execute statements without getting errors from + // assertNotInsideAbortedTransactionBlock. + isInsideTransactionBlock = wasInsideTransactionBlock + try execute(sql: "RELEASE SAVEPOINT grdb") assert(!topLevelSavepoint || !isInsideTransaction) needsRollback = false @@ -888,8 +965,7 @@ extension Database { // The second technique is more robust, because we don't have to guess // which rollback errors should be ignored, and which rollback errors // should be exposed to the library user. - SchedulingWatchdog.preconditionValidQueue(self) // guard sqlite3_get_autocommit - if sqlite3_get_autocommit(sqliteConnection) == 0 { + if isInsideTransaction { try execute(sql: "ROLLBACK TRANSACTION") } assert(!isInsideTransaction) diff --git a/GRDB/Core/DatabasePool.swift b/GRDB/Core/DatabasePool.swift index 98baa755e1..57c03ae2e3 100644 --- a/GRDB/Core/DatabasePool.swift +++ b/GRDB/Core/DatabasePool.swift @@ -252,6 +252,13 @@ extension DatabasePool { extension DatabasePool: DatabaseReader { + // MARK: - Interrupting Database Operations + + public func interrupt() { + writer.interrupt() + readerPool.forEach { $0.interrupt() } + } + // MARK: - Reading from Database /// Synchronously executes a read-only block in a protected dispatch queue, diff --git a/GRDB/Core/DatabaseQueue.swift b/GRDB/Core/DatabaseQueue.swift index fd9ae3f35d..b194d4b7c9 100644 --- a/GRDB/Core/DatabaseQueue.swift +++ b/GRDB/Core/DatabaseQueue.swift @@ -149,6 +149,12 @@ extension DatabaseQueue { extension DatabaseQueue { + // MARK: - Interrupting Database Operations + + public func interrupt() { + writer.interrupt() + } + // MARK: - Reading from Database /// Synchronously executes a read-only block in a protected dispatch queue, diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index ffad142a8d..5a88f6bea2 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -26,6 +26,51 @@ public protocol DatabaseReader: AnyObject { /// The database configuration var configuration: Configuration { get } + // MARK: - Interrupting Database Operations + + /// This method causes any pending database operation to abort and return at + /// its earliest opportunity. + /// + /// It can be called from any thread. + /// + /// A call to `interrupt()` that occurs when there are no running SQL + /// statements is a no-op and has no effect on SQL statements that are + /// started after `interrupt()` returns. + /// + /// A database operation that is interrupted will throw a DatabaseError with + /// code SQLITE_INTERRUPT. If the interrupted SQL operation is an INSERT, + /// UPDATE, or DELETE that is inside an explicit transaction, then the + /// entire transaction will be rolled back automatically. If the rolled back + /// transaction was started by a transaction-wrapping method such as + /// `DatabaseWriter.write` or `Database.inTransaction`, then all database + /// accesses will throw a DatabaseError with code SQLITE_ABORT until the + /// wrapping method returns. + /// + /// For example: + /// + /// try dbQueue.write { db in + /// // interrupted: + /// try Player(...).insert(db) // throws SQLITE_INTERRUPT + /// // not executed: + /// try Player(...).insert(db) + /// } // throws SQLITE_INTERRUPT + /// + /// try dbQueue.write { db in + /// do { + /// // interrupted: + /// try Player(...).insert(db) // throws SQLITE_INTERRUPT + /// } catch { } + /// try Player(...).insert(db) // throws SQLITE_ABORT + /// } // throws SQLITE_ABORT + /// + /// try dbQueue.write { db in + /// do { + /// // interrupted: + /// try Player(...).insert(db) // throws SQLITE_INTERRUPT + /// } catch { } + /// } // throws SQLITE_ABORT + func interrupt() + // MARK: - Read From Database /// Synchronously executes a read-only block that takes a database @@ -250,6 +295,13 @@ public final class AnyDatabaseReader: DatabaseReader { return base.configuration } + // MARK: - Interrupting Database Operations + + /// :nodoc: + public func interrupt() { + base.interrupt() + } + // MARK: - Reading from Database /// :nodoc: diff --git a/GRDB/Core/DatabaseSnapshot.swift b/GRDB/Core/DatabaseSnapshot.swift index 77acfe18b7..df92854f96 100644 --- a/GRDB/Core/DatabaseSnapshot.swift +++ b/GRDB/Core/DatabaseSnapshot.swift @@ -47,6 +47,12 @@ public class DatabaseSnapshot: DatabaseReader { // DatabaseReader extension DatabaseSnapshot { + // MARK: - Interrupting Database Operations + + public func interrupt() { + serializedDatabase.interrupt() + } + // MARK: - Reading from Database /// Synchronously executes a read-only block that takes a database diff --git a/GRDB/Core/DatabaseValueConvertible.swift b/GRDB/Core/DatabaseValueConvertible.swift index 6836906f67..301904d3ca 100644 --- a/GRDB/Core/DatabaseValueConvertible.swift +++ b/GRDB/Core/DatabaseValueConvertible.swift @@ -67,7 +67,7 @@ public final class DatabaseValueCursor: Cursor _statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { @@ -123,7 +123,7 @@ public final class NullableDatabaseValueCursor: _statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { diff --git a/GRDB/Core/DatabaseWriter.swift b/GRDB/Core/DatabaseWriter.swift index ec6d88065e..bc730c4069 100644 --- a/GRDB/Core/DatabaseWriter.swift +++ b/GRDB/Core/DatabaseWriter.swift @@ -380,7 +380,7 @@ extension DatabaseWriter { if let value = observer.reducer.value(fetchedValue) { startValue = value } - + db.add(transactionObserver: observer, extent: .observerLifetime) } } catch { @@ -601,6 +601,13 @@ public final class AnyDatabaseWriter: DatabaseWriter { return base.configuration } + // MARK: - Interrupting Database Operations + + /// :nodoc: + public func interrupt() { + base.interrupt() + } + // MARK: - Reading from Database /// :nodoc: diff --git a/GRDB/Core/Row.swift b/GRDB/Core/Row.swift index e1c4d3ad1a..13bfdf89e2 100644 --- a/GRDB/Core/Row.swift +++ b/GRDB/Core/Row.swift @@ -779,7 +779,7 @@ public final class RowCursor: Cursor { statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { diff --git a/GRDB/Core/SerializedDatabase.swift b/GRDB/Core/SerializedDatabase.swift index c08b1445af..0b09b52b49 100644 --- a/GRDB/Core/SerializedDatabase.swift +++ b/GRDB/Core/SerializedDatabase.swift @@ -199,6 +199,11 @@ final class SerializedDatabase { return try block(db) } + func interrupt() { + // Intentionally not scheduled in our serial queue + db.interrupt() + } + /// Fatal error if current dispatch queue is not valid. func preconditionValidQueue( _ message: @autoclosure() -> String = "Database was not used on the correct thread.", diff --git a/GRDB/Core/Statement.swift b/GRDB/Core/Statement.swift index 82346a6fe3..05564bb0f7 100644 --- a/GRDB/Core/Statement.swift +++ b/GRDB/Core/Statement.swift @@ -31,7 +31,7 @@ public class Statement { .trimmingCharacters(in: .sqlStatementSeparators) } - unowned let database: Database + @usableFromInline unowned let database: Database /// Creates a prepared statement. Returns nil if the compiled string is /// blank or empty. @@ -346,8 +346,8 @@ public final class SelectStatement: Statement { /// Creates a cursor over the statement which does not produce any /// value. Each call to the next() cursor method calls the sqlite3_step() /// C function. - func makeCursor(arguments: StatementArguments? = nil) -> StatementCursor { - return StatementCursor(statement: self, arguments: arguments) + func makeCursor(arguments: StatementArguments? = nil) throws -> StatementCursor { + return try StatementCursor(statement: self, arguments: arguments) } /// Utility function for cursors @@ -384,13 +384,13 @@ final class StatementCursor: Cursor { var _done = false // Use SelectStatement.makeCursor() instead - init(statement: SelectStatement, arguments: StatementArguments? = nil) { + init(statement: SelectStatement, arguments: StatementArguments? = nil) throws { _statement = statement _sqliteStatement = statement.sqliteStatement _statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { diff --git a/GRDB/Core/StatementColumnConvertible.swift b/GRDB/Core/StatementColumnConvertible.swift index dd0077f59f..95aa91a86f 100644 --- a/GRDB/Core/StatementColumnConvertible.swift +++ b/GRDB/Core/StatementColumnConvertible.swift @@ -79,7 +79,7 @@ public final class FastDatabaseValueCursor: Cursor _statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { diff --git a/GRDB/Record/FetchableRecord.swift b/GRDB/Record/FetchableRecord.swift index 7dbb8b73f1..ebf27ca89d 100644 --- a/GRDB/Record/FetchableRecord.swift +++ b/GRDB/Record/FetchableRecord.swift @@ -431,7 +431,7 @@ public final class RecordCursor: Cursor { _statement.reset(withArguments: arguments) // Assume cursor is created for iteration - statement.database.selectStatementWillExecute(statement) + try statement.database.selectStatementWillExecute(statement) } deinit { diff --git a/GRDBCustom.xcodeproj/project.pbxproj b/GRDBCustom.xcodeproj/project.pbxproj index 2c4004df6a..4e04ba7f98 100755 --- a/GRDBCustom.xcodeproj/project.pbxproj +++ b/GRDBCustom.xcodeproj/project.pbxproj @@ -458,6 +458,8 @@ 56DF0016228DDB8300D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0013228DDB8200D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; 56DF0017228DDB8300D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0014228DDB8200D611F3 /* AssociationPrefetchingRowTests.swift */; }; 56DF0018228DDB8300D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0014228DDB8200D611F3 /* AssociationPrefetchingRowTests.swift */; }; + 56E4F7F92392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7F72392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift */; }; + 56E4F7FA2392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7F72392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift */; }; 56E9FAC42210468500C703A8 /* SQLInterpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E9FAC32210468500C703A8 /* SQLInterpolation.swift */; }; 56E9FAC52210468500C703A8 /* SQLInterpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E9FAC32210468500C703A8 /* SQLInterpolation.swift */; }; 56E9FAD52210538400C703A8 /* SQLLiteral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E9FAD42210538400C703A8 /* SQLLiteral.swift */; }; @@ -1028,6 +1030,7 @@ 56DE7B101C3D93ED00861EB8 /* StatementArgumentsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatementArgumentsTests.swift; sourceTree = ""; }; 56DF0013228DDB8200D611F3 /* AssociationPrefetchingCodableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingCodableRecordTests.swift; sourceTree = ""; }; 56DF0014228DDB8200D611F3 /* AssociationPrefetchingRowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingRowTests.swift; sourceTree = ""; }; + 56E4F7F72392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseAbortedTransactionTests.swift; sourceTree = ""; }; 56E8CE0C1BB4FA5600828BEC /* DatabaseValueConvertibleFetchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseValueConvertibleFetchTests.swift; sourceTree = ""; }; 56E8CE0F1BB4FE5B00828BEC /* StatementColumnConvertibleFetchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatementColumnConvertibleFetchTests.swift; sourceTree = ""; }; 56E9FAC32210468500C703A8 /* SQLInterpolation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLInterpolation.swift; sourceTree = ""; }; @@ -1548,6 +1551,7 @@ children = ( 5665F865203EF4590084C6C0 /* ColumnInfoTests.swift */, 56DAA2C41DE99D8D006E10C8 /* Cursor */, + 56E4F7F72392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift */, 564448821EF56B1B00DD2861 /* DatabaseAfterNextTransactionCommitTests.swift */, 564F9C1D1F069B4E00877A00 /* DatabaseAggregateTests.swift */, 564A50C61BFF4B7F00B3A3A2 /* DatabaseCollationTests.swift */, @@ -2337,6 +2341,7 @@ F3BA812A1CFB3063003DC1BA /* RecordSubClassTests.swift in Sources */, 569BBA40229065CF00478429 /* InflectionsTests.swift in Sources */, 5653EB7F20961FB200F46237 /* AssociationChainSQLTests.swift in Sources */, + 56E4F7FA2392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 561CFAA22376EF4F000C8BAA /* AssociationHasManyOrderingTests.swift in Sources */, 6340BF871E5E3F7900832805 /* RecordPersistenceConflictPolicy.swift in Sources */, 56057C662291C7E500A7CB10 /* AssociationHasManyThroughRowScopeTests.swift in Sources */, @@ -2675,6 +2680,7 @@ F3BA80D01CFB2FEC003DC1BA /* SchedulingWatchdogTests.swift in Sources */, 569BBA41229065CF00478429 /* InflectionsTests.swift in Sources */, 5653EB7E20961FB200F46237 /* AssociationChainSQLTests.swift in Sources */, + 56E4F7F92392E2EE00A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 561CFAA12376EF4F000C8BAA /* AssociationHasManyOrderingTests.swift in Sources */, F3BA80EC1CFB3017003DC1BA /* RowCopiedFromStatementTests.swift in Sources */, 56057C652291C7E500A7CB10 /* AssociationHasManyThroughRowScopeTests.swift in Sources */, diff --git a/README.md b/README.md index bb52fc4a9d..e6007c61d1 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ Documentation - [Database Changes Observation](#database-changes-observation): Observe database changes and transactions. - [Encryption](#encryption): Encrypt your database with SQLCipher. - [Backup](#backup): Dump the content of a database to another. +- [Interrupt a Database](#interrupt-a-database): Abort any pending database operation. #### Good to Know @@ -6640,6 +6641,50 @@ The `backup` method blocks the current thread until the destination database con When the source is a [database pool](#database-pools), concurrent writes can happen during the backup. Those writes may, or may not, be reflected in the backup, but they won't trigger any error. +## Interrupt a Database + +**The `interrupt()` method** causes any pending database operation to abort and return at its earliest opportunity. + +It can be called from any thread. + +```swift +dbQueue.interrupt() +dbPool.interrupt() +``` + +A call to `interrupt()` that occurs when there are no running SQL statements is a no-op and has no effect on SQL statements that are started after `interrupt()` returns. + +A database operation that is interrupted will throw a DatabaseError with code `SQLITE_INTERRUPT`. If the interrupted SQL operation is an INSERT, UPDATE, or DELETE that is inside an explicit transaction, then the entire transaction will be rolled back automatically. If the rolled back transaction was started by a transaction-wrapping method such as `DatabaseWriter.write` or `Database.inTransaction`, then all database accesses will throw a DatabaseError with code `SQLITE_ABORT` until the wrapping method returns. + +For example: + +```swift +try dbQueue.write { db in + // interrupted: + try Player(...).insert(db) // throws SQLITE_INTERRUPT + // not executed: + try Player(...).insert(db) +} // throws SQLITE_INTERRUPT + +try dbQueue.write { db in + do { + // interrupted: + try Player(...).insert(db) // throws SQLITE_INTERRUPT + } catch { } + try Player(...).insert(db) // throws SQLITE_ABORT +} // throws SQLITE_ABORT + +try dbQueue.write { db in + do { + // interrupted: + try Player(...).insert(db) // throws SQLITE_INTERRUPT + } catch { } +} // throws SQLITE_ABORT +``` + +For more information, see [Interrupt A Long-Running Query](https://www.sqlite.org/c3ref/interrupt.html). + + ## Avoiding SQL Injection SQL injection is a technique that lets an attacker nuke your database. diff --git a/TODO.md b/TODO.md index c4a9a5601c..76d9e3139d 100644 --- a/TODO.md +++ b/TODO.md @@ -74,45 +74,6 @@ ``` - [ ] new.updateChanges(from: old) vs. old.updateChanges(with: { old.a = new.a }). This is confusing. -- [ ] Support for OR ROLLBACK, and mismatch between the Swift depth and the SQLite depth of nested transactions/savepoint: - - ```swift - try db.inTransaction { // Swift depth: 1, SQLite depth: 1 - try db.execute("COMMIT") // Swift depth: 1, SQLite depth: 0 - try db.execute("INSERT ...") // Should throw an error since this statement is no longer protected by a transaction - try db.execute("SELECT ...") // Should throw an error since this statement is no longer protected by a transaction - return .commit - } - ``` - - ```swift - try db.inTransaction { - try db.execute("INSERT OR ROLLBACK ...") // throws - return .commit // not executed because of error - } // Should not ROLLBACK since transaction has already been rollbacked - ``` - - ```swift - try db.inTransaction { - do { - try db.execute("INSERT OR ROLLBACK ...") // throws - } catch { - } - try db.execute("INSERT ...") // Should throw an error since this statement is no longer protected by a transaction - try db.execute("SELECT ...") // Should throw an error since this statement is no longer protected by a transaction - return .commit - } - ``` - - ```swift - try db.inTransaction { - do { - try db.execute("INSERT OR ROLLBACK ...") // throws - } catch { - } - return .commit // Should throw an error since transaction has been rollbacked and user's intent can not be applied - } - ``` ## Reading list diff --git a/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj b/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj index ea7567292e..bff9133519 100644 --- a/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj +++ b/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj @@ -401,6 +401,8 @@ 56DF0028228DE00900D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0025228DE00900D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; 56DF0029228DE00900D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0026228DE00900D611F3 /* AssociationPrefetchingRowTests.swift */; }; 56DF002A228DE00900D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0026228DE00900D611F3 /* AssociationPrefetchingRowTests.swift */; }; + 56E4F7FF2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7FE2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift */; }; + 56E4F8002392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7FE2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift */; }; 5B33E6E34F941B4C839A714F /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 98AB0B01EB11B33719AE412E /* Pods_GRDBTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE2436BF42B9FCD6552E7076 /* Pods_GRDBTests.framework */; }; F2B3C4250D67969FF3948955 /* Pods_GRDBTestsEncrypted.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D5C7999E7D9CE7145687F5D /* Pods_GRDBTestsEncrypted.framework */; }; @@ -610,6 +612,7 @@ 569BBA30228DF91000478429 /* AssociationPrefetchingFetchableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingFetchableRecordTests.swift; sourceTree = ""; }; 56DF0025228DE00900D611F3 /* AssociationPrefetchingCodableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingCodableRecordTests.swift; sourceTree = ""; }; 56DF0026228DE00900D611F3 /* AssociationPrefetchingRowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingRowTests.swift; sourceTree = ""; }; + 56E4F7FE2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseAbortedTransactionTests.swift; sourceTree = ""; }; 6A4788C0F815F6C5E4EBDE12 /* Pods-GRDBTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTests.debug.xcconfig"; path = "Target Support Files/Pods-GRDBTests/Pods-GRDBTests.debug.xcconfig"; sourceTree = ""; }; 7D5C7999E7D9CE7145687F5D /* Pods_GRDBTestsEncrypted.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GRDBTestsEncrypted.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 83BFB5733A86DAA3D0BEE684 /* Pods-GRDBTestsEncrypted.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTestsEncrypted.debug.xcconfig"; path = "Target Support Files/Pods-GRDBTestsEncrypted/Pods-GRDBTestsEncrypted.debug.xcconfig"; sourceTree = ""; }; @@ -699,6 +702,7 @@ 564A1F36226B89CF001F64F1 /* CompilationProtocolTests.swift */, 564A1F74226B89D6001F64F1 /* CompilationSubClassTests.swift */, 564A1F6B226B89D5001F64F1 /* CursorTests.swift */, + 56E4F7FE2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift */, 564A1FD7226B89E0001F64F1 /* DatabaseAfterNextTransactionCommitTests.swift */, 564A1F31226B89CF001F64F1 /* DatabaseAggregateTests.swift */, 564A1F53226B89D2001F64F1 /* DatabaseCollationTests.swift */, @@ -1246,6 +1250,7 @@ 564A206D226B89E1001F64F1 /* ValueObservationCombineTests.swift in Sources */, 564A2022226B89E1001F64F1 /* CursorTests.swift in Sources */, 564A203A226B89E1001F64F1 /* DatabaseDateDecodingStrategyTests.swift in Sources */, + 56E4F7FF2392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 560432A9228F1752009D3FE2 /* AssociationPrefetchingObservationTests.swift in Sources */, 564A2036226B89E1001F64F1 /* SchedulingWatchdogTests.swift in Sources */, 564A2052226B89E1001F64F1 /* RowTestCase.swift in Sources */, @@ -1449,6 +1454,7 @@ 564A213C226B8E18001F64F1 /* ValueObservationCombineTests.swift in Sources */, 564A213D226B8E18001F64F1 /* CursorTests.swift in Sources */, 564A213E226B8E18001F64F1 /* DatabaseDateDecodingStrategyTests.swift in Sources */, + 56E4F8002392E6D200A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 560432AA228F1752009D3FE2 /* AssociationPrefetchingObservationTests.swift in Sources */, 564A213F226B8E18001F64F1 /* SchedulingWatchdogTests.swift in Sources */, 564A2140226B8E18001F64F1 /* RowTestCase.swift in Sources */, diff --git a/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj b/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj index 7096268b20..563a277f17 100644 --- a/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj +++ b/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj @@ -403,6 +403,8 @@ 56DF0022228DDFF000D611F3 /* AssociationPrefetchingRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF001F228DDFF000D611F3 /* AssociationPrefetchingRowTests.swift */; }; 56DF0023228DDFF000D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0020228DDFF000D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; 56DF0024228DDFF000D611F3 /* AssociationPrefetchingCodableRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DF0020228DDFF000D611F3 /* AssociationPrefetchingCodableRecordTests.swift */; }; + 56E4F7FC2392E6C400A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7FB2392E6C300A611F6 /* DatabaseAbortedTransactionTests.swift */; }; + 56E4F7FD2392E6C400A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E4F7FB2392E6C300A611F6 /* DatabaseAbortedTransactionTests.swift */; }; 5B33E6E34F941B4C839A714F /* (null) in Frameworks */ = {isa = PBXBuildFile; }; E158370AAEED49ECD53CE24A /* Pods_GRDBTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67377FE4CDAD675809F93AD4 /* Pods_GRDBTests.framework */; }; F2B3C4250D67969FF3948955 /* Pods_GRDBTestsEncrypted.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D5C7999E7D9CE7145687F5D /* Pods_GRDBTestsEncrypted.framework */; }; @@ -613,6 +615,7 @@ 569BBA2D228DF90200478429 /* AssociationPrefetchingFetchableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingFetchableRecordTests.swift; sourceTree = ""; }; 56DF001F228DDFF000D611F3 /* AssociationPrefetchingRowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingRowTests.swift; sourceTree = ""; }; 56DF0020228DDFF000D611F3 /* AssociationPrefetchingCodableRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationPrefetchingCodableRecordTests.swift; sourceTree = ""; }; + 56E4F7FB2392E6C300A611F6 /* DatabaseAbortedTransactionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseAbortedTransactionTests.swift; sourceTree = ""; }; 67377FE4CDAD675809F93AD4 /* Pods_GRDBTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GRDBTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6A4788C0F815F6C5E4EBDE12 /* Pods-GRDBTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTests.debug.xcconfig"; path = "Target Support Files/Pods-GRDBTests/Pods-GRDBTests.debug.xcconfig"; sourceTree = ""; }; 7D5C7999E7D9CE7145687F5D /* Pods_GRDBTestsEncrypted.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GRDBTestsEncrypted.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -702,6 +705,7 @@ 564A1F36226B89CF001F64F1 /* CompilationProtocolTests.swift */, 564A1F74226B89D6001F64F1 /* CompilationSubClassTests.swift */, 564A1F6B226B89D5001F64F1 /* CursorTests.swift */, + 56E4F7FB2392E6C300A611F6 /* DatabaseAbortedTransactionTests.swift */, 564A1FD7226B89E0001F64F1 /* DatabaseAfterNextTransactionCommitTests.swift */, 564A1F31226B89CF001F64F1 /* DatabaseAggregateTests.swift */, 564A1F53226B89D2001F64F1 /* DatabaseCollationTests.swift */, @@ -1252,6 +1256,7 @@ 564A2022226B89E1001F64F1 /* CursorTests.swift in Sources */, 564A203A226B89E1001F64F1 /* DatabaseDateDecodingStrategyTests.swift in Sources */, 564A2036226B89E1001F64F1 /* SchedulingWatchdogTests.swift in Sources */, + 56E4F7FC2392E6C400A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 560432AC228F1761009D3FE2 /* AssociationPrefetchingObservationTests.swift in Sources */, 564A2052226B89E1001F64F1 /* RowTestCase.swift in Sources */, 564A1FF3226B89E1001F64F1 /* TruncateOptimizationTests.swift in Sources */, @@ -1455,6 +1460,7 @@ 564A213D226B8E18001F64F1 /* CursorTests.swift in Sources */, 564A213E226B8E18001F64F1 /* DatabaseDateDecodingStrategyTests.swift in Sources */, 564A213F226B8E18001F64F1 /* SchedulingWatchdogTests.swift in Sources */, + 56E4F7FD2392E6C400A611F6 /* DatabaseAbortedTransactionTests.swift in Sources */, 560432AD228F1761009D3FE2 /* AssociationPrefetchingObservationTests.swift in Sources */, 564A2140226B8E18001F64F1 /* RowTestCase.swift in Sources */, 564A2141226B8E18001F64F1 /* TruncateOptimizationTests.swift in Sources */, diff --git a/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift b/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift new file mode 100644 index 0000000000..5111f18d5a --- /dev/null +++ b/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift @@ -0,0 +1,359 @@ +import XCTest +#if GRDBCUSTOMSQLITE +import GRDBCustomSQLite +#else +import GRDB +#endif + +class DatabaseAbortedTransactionTests : GRDBTestCase { + + func testReadTransactionAbortedByInterrupt() throws { + func test(_ dbReader: DatabaseReader) throws { + let semaphore1 = DispatchSemaphore(value: 0) + let semaphore2 = DispatchSemaphore(value: 0) + + dbReader.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) + let block1 = { + do { + _ = try dbReader.read { + try Row.fetchAll($0, sql: "SELECT wait()") + } + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_INTERRUPT) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + let block2 = { + semaphore1.wait() + dbReader.interrupt() + semaphore2.signal() + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } + } + + try test(DatabaseQueue()) + try test(makeDatabaseQueue()) + try test(makeDatabasePool()) + try test(makeDatabasePool().makeSnapshot()) + } + + func testReadTransactionAbortedByInterruptDoesNotPreventFurtherDatabaseAccess() throws { + func test(_ dbReader: DatabaseReader) throws { + let semaphore1 = DispatchSemaphore(value: 0) + let semaphore2 = DispatchSemaphore(value: 0) + + dbReader.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) + let block1 = { + try! dbReader.read { db in + do { + _ = try Row.fetchAll(db, sql: "SELECT wait()") + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_INTERRUPT) + } catch { + XCTFail("Unexpected error: \(error)") + } + dbReader.interrupt() + try XCTAssertTrue(Bool.fetchOne(db, sql: "SELECT 1")!) + } + } + let block2 = { + semaphore1.wait() + dbReader.interrupt() + semaphore2.signal() + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } + } + + try test(DatabaseQueue()) + try test(makeDatabaseQueue()) + try test(makeDatabasePool()) + try test(makeDatabasePool().makeSnapshot()) + } + + func testWriteTransactionAbortedByInterrupt() throws { + func setup(_ dbWriter: T) throws -> T { + try dbWriter.write { db in + try db.execute(sql: "CREATE TABLE t(a);") + } + return dbWriter + } + func test(_ dbWriter: DatabaseWriter) throws { + let semaphore1 = DispatchSemaphore(value: 0) + let semaphore2 = DispatchSemaphore(value: 0) + + dbWriter.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) + let block1 = { + do { + try dbWriter.write { db in + try db.execute(sql: "INSERT INTO t SELECT wait()") + } + XCTFail("Expected error") + } catch let error as DatabaseError { + // Transactions throw the first uncatched error: SQLITE_INTERRUPT + XCTAssertEqual(error.resultCode, .SQLITE_INTERRUPT) + XCTAssertEqual(error.message, "interrupted") + XCTAssertEqual(error.sql, "INSERT INTO t SELECT wait()") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + let block2 = { + semaphore1.wait() + dbWriter.interrupt() + semaphore2.signal() + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } + } + + try test(setup(DatabaseQueue())) + try test(setup(makeDatabaseQueue())) + try test(setup(makeDatabasePool())) + } + + func testWriteTransactionAbortedByInterruptPreventsFurtherDatabaseAccess() throws { + func setup(_ dbWriter: T) throws -> T { + try dbWriter.write { db in + try db.execute(sql: "CREATE TABLE t(a);") + } + return dbWriter + } + func test(_ dbWriter: DatabaseWriter) throws { + let semaphore1 = DispatchSemaphore(value: 0) + let semaphore2 = DispatchSemaphore(value: 0) + + dbWriter.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) + let block1 = { + do { + try dbWriter.write { db in + do { + try db.execute(sql: "INSERT INTO t SELECT wait()") + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_INTERRUPT) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertFalse(db.isInsideTransaction) + + do { + try db.execute(sql: "INSERT INTO t (a) VALUES (0)") + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_ABORT) + XCTAssertEqual(error.message, "Transaction was aborted") + XCTAssertEqual(error.sql, "INSERT INTO t (a) VALUES (0)") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + XCTFail("Expected error") + } catch let error as DatabaseError { + // SQLITE_INTERRUPT has been caught. So we get SQLITE_ABORT + // from the last commit. + XCTAssertEqual(error.resultCode, .SQLITE_ABORT) + XCTAssertEqual(error.message, "Transaction was aborted") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + let block2 = { + semaphore1.wait() + dbWriter.interrupt() + semaphore2.signal() + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } + } + + try test(setup(DatabaseQueue())) + try test(setup(makeDatabaseQueue())) + try test(setup(makeDatabasePool())) + } + + func testWriteTransactionAbortedByInterruptDoesNotPreventRollback() throws { + func setup(_ dbWriter: T) throws -> T { + try dbWriter.write { db in + try db.execute(sql: "CREATE TABLE t(a);") + } + return dbWriter + } + func test(_ dbWriter: DatabaseWriter) throws { + let semaphore1 = DispatchSemaphore(value: 0) + let semaphore2 = DispatchSemaphore(value: 0) + + dbWriter.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) + let block1 = { + try! dbWriter.writeWithoutTransaction { db in + try db.inTransaction { + do { + try db.execute(sql: "INSERT INTO t SELECT wait()") + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_INTERRUPT) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertFalse(db.isInsideTransaction) + return .rollback + } + } + } + let block2 = { + semaphore1.wait() + dbWriter.interrupt() + semaphore2.signal() + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } + } + + try test(setup(DatabaseQueue())) + try test(setup(makeDatabaseQueue())) + try test(setup(makeDatabasePool())) + } + + func testTransactionAbortedByConflictPreventsFurtherDatabaseAccess() throws { + func setup(_ dbWriter: T) throws -> T { + try dbWriter.write { db in + try db.execute(sql: """ + CREATE TABLE t(a UNIQUE ON CONFLICT ROLLBACK); + """) + } + return dbWriter + } + func test(_ dbWriter: DatabaseWriter) throws { + do { + try dbWriter.write { db in + do { + try db.execute(sql: """ + INSERT INTO t (a) VALUES (1); + INSERT INTO t (a) VALUES (1); + """) + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) + XCTAssertEqual(error.message, "UNIQUE constraint failed: t.a") + XCTAssertEqual(error.sql, "INSERT INTO t (a) VALUES (1)") + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertFalse(db.isInsideTransaction) + + try db.execute(sql: "INSERT INTO t (a) VALUES (2)") + } + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_ABORT) + XCTAssertEqual(error.message, "Transaction was aborted") + XCTAssertEqual(error.sql, "INSERT INTO t (a) VALUES (2)") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + try test(setup(DatabaseQueue())) + try test(setup(makeDatabaseQueue())) + try test(setup(makeDatabasePool())) + } + + func testTransactionAbortedByUser() throws { + func setup(_ dbWriter: T) throws -> T { + try dbWriter.write { db in + try db.execute(sql: "CREATE TABLE t(a);") + } + return dbWriter + } + func test(_ dbReader: DatabaseReader) throws { + do { + try dbReader.unsafeRead { db in + try db.inTransaction { + try db.execute(sql: """ + SELECT * FROM t; + ROLLBACK; + SELECT * FROM t; + """) + return .commit + } + } + XCTFail("Expected error") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_ABORT) + XCTAssertEqual(error.message, "Transaction was aborted") + XCTAssertEqual(error.sql, "SELECT * FROM t") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + try test(setup(DatabaseQueue())) + try test(setup(makeDatabaseQueue())) + try test(setup(makeDatabasePool())) + } + + func testReadTransactionRestartHack() throws { + // Here we test that the "ROLLBACK; BEGIN TRANSACTION;" hack which + // "refreshes" a DatabaseSnaphot works. + // See https://github.com/groue/GRDB.swift/issues/619 + // This hack puts temporarily the transaction in the aborded + // state. Here we test that we don't throw SQLITE_ABORT. + + let dbPool = try makeDatabasePool() + try dbPool.write { db in + try db.execute(sql: "CREATE TABLE t(a);") + } + let snapshot = try dbPool.makeSnapshot() + try snapshot.read { db in + try db.execute(sql: """ + ROLLBACK; + BEGIN TRANSACTION; + """) + } + try snapshot.read { db in + try db.execute(sql: """ + SELECT * FROM t; + ROLLBACK; + BEGIN TRANSACTION; + SELECT * FROM t; + """) + } + } +} diff --git a/Tests/GRDBTests/SelectStatementTests.swift b/Tests/GRDBTests/SelectStatementTests.swift index b2b0884784..d5039de87b 100644 --- a/Tests/GRDBTests/SelectStatementTests.swift +++ b/Tests/GRDBTests/SelectStatementTests.swift @@ -37,7 +37,7 @@ class SelectStatementTests : GRDBTestCase { try dbQueue.inDatabase { db in let sql = "SELECT 'Arthur' AS firstName, 'Martin' AS lastName UNION ALL SELECT 'Barbara', 'Gourde'" let statement = try db.makeSelectStatement(sql: sql) - let cursor = statement.makeCursor() + let cursor = try statement.makeCursor() // Check that StatementCursor gives access to the raw SQLite API XCTAssertEqual(String(cString: sqlite3_column_name(cursor._statement.sqliteStatement, 0)), "firstName")