From d40d57b9938a8e1ffa45d5e912e1093220941941 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 7 Feb 2026 19:51:30 -0800 Subject: [PATCH] database.undoableWrite --- Sources/SQLiteUndo/Undoable.swift | 59 +++++++ .../SQLiteUndoTests/UndoableWriteTests.swift | 147 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 Tests/SQLiteUndoTests/UndoableWriteTests.swift diff --git a/Sources/SQLiteUndo/Undoable.swift b/Sources/SQLiteUndo/Undoable.swift index b1432f6..db16f56 100644 --- a/Sources/SQLiteUndo/Undoable.swift +++ b/Sources/SQLiteUndo/Undoable.swift @@ -1,5 +1,64 @@ import Dependencies import Foundation +import SQLiteData + +extension DatabaseWriter { + /// Execute a write operation within an undoable barrier. + /// + /// This combines barrier management with the database write in a single call: + /// + /// ```swift + /// try database.undoableWrite("Set Rating") { db in + /// try Article.find(id).update { $0.rating = 5 }.execute(db) + /// } + /// ``` + /// + /// The barrier is automatically cancelled if the operation throws. + public func undoableWrite( + _ actionName: String, + operation: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoEngine) var undoEngine + + let barrierId = try undoEngine.beginBarrier(actionName) + do { + let result = try write(operation) + try undoEngine.endBarrier(barrierId) + return result + } catch { + try undoEngine.cancelBarrier(barrierId) + throw error + } + } + + /// Execute an async write operation within an undoable barrier. + /// + /// This combines barrier management with the database write in a single call: + /// + /// ```swift + /// try await database.undoableWrite("Set Rating") { db in + /// try Article.find(id).update { $0.rating = 5 }.execute(db) + /// } + /// ``` + /// + /// The barrier is automatically cancelled if the operation throws. + public func undoableWrite( + _ actionName: String, + operation: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoEngine) var undoEngine + + let barrierId = try undoEngine.beginBarrier(actionName) + do { + let result = try await write(operation) + try undoEngine.endBarrier(barrierId) + return result + } catch { + try undoEngine.cancelBarrier(barrierId) + throw error + } + } +} /// Execute an async operation within an undoable barrier. /// diff --git a/Tests/SQLiteUndoTests/UndoableWriteTests.swift b/Tests/SQLiteUndoTests/UndoableWriteTests.swift new file mode 100644 index 0000000..15fddb4 --- /dev/null +++ b/Tests/SQLiteUndoTests/UndoableWriteTests.swift @@ -0,0 +1,147 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import SQLiteUndo +import StructuredQueries +import Testing + +@Suite +@MainActor +struct UndoableWriteTests { + + @Test + func undoableWriteCreatesBarrier() throws { + let testUndoManager = UndoManager() + + try withDependencies { + let database = try! makeDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .live(testUndoManager) + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: Item.self) + } operation: { + @Dependency(\.defaultDatabase) var database + + try database.undoableWrite("Insert Item") { db in + try Item.insert { Item(id: 1, name: "Test") }.execute(db) + } + + let count = try database.read { db in try Item.all.fetchCount(db) } + #expect(count == 1) + #expect(testUndoManager.canUndo == true) + #expect(testUndoManager.undoActionName == "Insert Item") + } + } + + @Test + func undoableWriteUndoWorks() throws { + let testUndoManager = UndoManager() + + try withDependencies { + let database = try! makeDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .live(testUndoManager) + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: Item.self) + } operation: { + @Dependency(\.defaultDatabase) var database + + try database.undoableWrite("Insert Item") { db in + try Item.insert { Item(id: 1, name: "Test") }.execute(db) + } + + #expect(try database.read { db in try Item.all.fetchCount(db) } == 1) + + testUndoManager.undo() + + #expect(try database.read { db in try Item.all.fetchCount(db) } == 0) + } + } + + @Test + func undoableWriteReturnsValue() throws { + try withDependencies { + let database = try! makeDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .testValue + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: Item.self) + } operation: { + @Dependency(\.defaultDatabase) var database + + let insertedName = try database.undoableWrite("Insert Item") { db in + try Item.insert { Item(id: 1, name: "Test") }.execute(db) + return "Test" + } + + #expect(insertedName == "Test") + } + } + + @Test + func undoableWriteCancelsBarrierOnError() throws { + try withDependencies { + let database = try! makeDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .testValue + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: Item.self) + } operation: { + @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultUndoStack) var undoStack + + struct TestError: Error {} + + do { + try database.undoableWrite("Will Fail") { db in + try Item.insert { Item(id: 1, name: "Test") }.execute(db) + throw TestError() + } + } catch is TestError { + // Expected + } + + // Barrier should be cancelled, not registered + #expect(undoStack.currentState() == []) + } + } + + @Test + func asyncUndoableWriteWorks() async throws { + let testUndoManager = UndoManager() + + try await withDependencies { + let database = try! makeDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .live(testUndoManager) + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: Item.self) + } operation: { + @Dependency(\.defaultDatabase) var database + + try await database.undoableWrite("Insert Item") { db in + try Item.insert { Item(id: 1, name: "Test") }.execute(db) + } + + let count = try await database.read { db in try Item.all.fetchCount(db) } + #expect(count == 1) + #expect(testUndoManager.canUndo == true) + } + } +} + +@Table +private struct Item: Identifiable { + @Column(primaryKey: true) var id: Int + var name: String = "" +} + +private func makeDatabase() throws -> any DatabaseWriter { + let database = try DatabaseQueue(configuration: Configuration()) + try database.write { db in + try #sql( + """ + CREATE TABLE "items" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ).execute(db) + } + return database +}