import XCTest import GRDB func insertItem(_ db: Database, name: String) throws { try db.execute(sql: "INSERT INTO items (name) VALUES (?)", arguments: [name]) } func fetchAllItemNames(_ dbReader: some DatabaseReader) throws -> [String] { try dbReader.read { db in try String.fetchAll(db, sql: "SELECT * FROM items ORDER BY name") } } private class Observer : TransactionObserver { var allRecordedEvents: [DatabaseEvent] = [] func reset() { allRecordedEvents.removeAll() #if SQLITE_ENABLE_PREUPDATE_HOOK allRecordedPreUpdateEvents.removeAll() #endif } #if SQLITE_ENABLE_PREUPDATE_HOOK var allRecordedPreUpdateEvents: [DatabasePreUpdateEvent] = [] func databaseWillChange(with event: DatabasePreUpdateEvent) { allRecordedPreUpdateEvents.append(event.copy()) } #endif func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } func databaseDidChange(with event: DatabaseEvent) { allRecordedEvents.append(event.copy()) } func databaseDidCommit(_ db: Database) { } func databaseDidRollback(_ db: Database) { } } class DatabaseSavepointTests: GRDBTestCase { override func setup(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE items (name TEXT)") } } func testIsInsideTransaction() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.writeWithoutTransaction { db in XCTAssertFalse(db.isInsideTransaction) try db.inTransaction { XCTAssertTrue(db.isInsideTransaction) return .commit } XCTAssertFalse(db.isInsideTransaction) try db.execute(sql: "BEGIN DEFERRED TRANSACTION") XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "COMMIT") // does not trigger sqlite3_commit_hook, because transaction was not open yet. XCTAssertFalse(db.isInsideTransaction) try db.execute(sql: "BEGIN DEFERRED TRANSACTION") XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "ROLLBACK") // does trigger sqlite3_rollback_hook XCTAssertFalse(db.isInsideTransaction) try db.execute(sql: "BEGIN IMMEDIATE TRANSACTION") XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "COMMIT") // does trigger sqlite3_commit_hook XCTAssertFalse(db.isInsideTransaction) try db.execute(sql: "BEGIN IMMEDIATE TRANSACTION") XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "ROLLBACK") XCTAssertFalse(db.isInsideTransaction) } } func testIsInsideTransactionWithImplicitRollback() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.writeWithoutTransaction { db in try db.create(table: "test") { t in t.column("value", .integer).unique() } try db.execute(sql: "BEGIN TRANSACTION") XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "INSERT INTO test (value) VALUES (?)", arguments: [1]) XCTAssertTrue(db.isInsideTransaction) XCTAssertThrowsError(try db.execute(sql: "INSERT OR ROLLBACK INTO test (value) VALUES (?)", arguments: [1])) XCTAssertFalse(db.isInsideTransaction) XCTAssertThrowsError(try db.execute(sql: "COMMIT")) } } func testReleaseTopLevelSavepointFromDatabaseWithDefaultDeferredTransactions() throws { dbConfiguration.defaultTransactionKind = .deferred let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item3") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item3')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item3"]) XCTAssertEqual(observer.allRecordedEvents.count, 3) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 3) #endif } func testRollbackTopLevelSavepointFromDatabaseWithDefaultDeferredTransactions() throws { dbConfiguration.defaultTransactionKind = .deferred let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item3") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item3')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item3"]) XCTAssertEqual(observer.allRecordedEvents.count, 3) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 3) #endif } func testNestedSavepointFromDatabaseWithDefaultDeferredTransactions() throws { dbConfiguration.defaultTransactionKind = .deferred let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .commit } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item3", "item4", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 5) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 5) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .commit } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 5) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 5) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .rollback } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "ROLLBACK TRANSACTION TO SAVEPOINT grdb", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item4", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 4) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 4) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .rollback } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN DEFERRED TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "ROLLBACK TRANSACTION TO SAVEPOINT grdb", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 4) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 4) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() } func testReleaseTopLevelSavepointFromDatabaseWithDefaultImmediateTransactions() throws { dbConfiguration.defaultTransactionKind = .immediate let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item3") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item3')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item3"]) XCTAssertEqual(observer.allRecordedEvents.count, 3) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 3) #endif } func testRollbackTopLevelSavepointFromDatabaseWithDefaultImmediateTransactions() throws { dbConfiguration.defaultTransactionKind = .immediate let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item3") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item3')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item3"]) XCTAssertEqual(observer.allRecordedEvents.count, 3) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 3) #endif } func testNestedSavepointFromDatabaseWithDefaultImmediateTransactions() throws { dbConfiguration.defaultTransactionKind = .immediate let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .commit } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item3", "item4", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 5) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 5) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .commit } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 5) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 5) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .rollback } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .commit } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "ROLLBACK TRANSACTION TO SAVEPOINT grdb", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "COMMIT TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item2", "item4", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 4) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 4) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() sqlQueries.removeAll() try dbQueue.writeWithoutTransaction { db in try insertItem(db, name: "item1") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item2") try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item3") return .rollback } XCTAssertTrue(db.isInsideTransaction) try insertItem(db, name: "item4") return .rollback } XCTAssertFalse(db.isInsideTransaction) try insertItem(db, name: "item5") } XCTAssertEqual(sqlQueries, [ "INSERT INTO items (name) VALUES ('item1')", "BEGIN IMMEDIATE TRANSACTION", "INSERT INTO items (name) VALUES ('item2')", "SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item3')", "ROLLBACK TRANSACTION TO SAVEPOINT grdb", "RELEASE SAVEPOINT grdb", "INSERT INTO items (name) VALUES ('item4')", "ROLLBACK TRANSACTION", "INSERT INTO items (name) VALUES ('item5')" ]) XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item1", "item5"]) XCTAssertEqual(observer.allRecordedEvents.count, 4) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 4) #endif try dbQueue.inDatabase { db in try db.execute(sql: "DELETE FROM items") } observer.reset() } func testSubsequentSavepoints() throws { let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) try dbQueue.inTransaction { db in try db.inSavepoint { try insertItem(db, name: "item1") return .rollback } try db.inSavepoint { try insertItem(db, name: "item2") return .commit } return .commit } XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item2"]) XCTAssertEqual(observer.allRecordedEvents.count, 1) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 1) #endif } func testSubsequentSavepointsWithErrors() throws { let dbQueue = try makeDatabaseQueue() let observer = Observer() dbQueue.add(transactionObserver: observer) try dbQueue.inTransaction { db in do { try db.inSavepoint { try insertItem(db, name: "item1") throw DatabaseError(resultCode: ResultCode(rawValue: 123)) } XCTFail() } catch let error as DatabaseError { XCTAssertEqual(error.resultCode.rawValue, 123) } try db.inSavepoint { try insertItem(db, name: "item2") return .commit } return .commit } XCTAssertEqual(try fetchAllItemNames(dbQueue), ["item2"]) XCTAssertEqual(observer.allRecordedEvents.count, 1) #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.allRecordedPreUpdateEvents.count, 1) #endif } }