From 02e486d5a55e9e048afe0c62bc34ff87ec8adcc4 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Sat, 21 Feb 2026 20:45:36 +0100 Subject: [PATCH 1/6] Support for Undo --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Examples/Reminders/README.md | 4 + Examples/Reminders/ReminderForm.swift | 3 +- Examples/Reminders/ReminderRow.swift | 10 +- Examples/Reminders/RemindersApp.swift | 101 ++- Examples/Reminders/RemindersDetail.swift | 5 +- Examples/Reminders/RemindersListForm.swift | 4 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 57 +- Examples/Reminders/Schema.swift | 14 +- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 4 +- Examples/Reminders/UndoToolbarButtons.swift | 117 +++ .../CloudKit/Internal/UserDatabase.swift | 43 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 72 +- .../SQLiteData/Undo/DatabaseWriter+Undo.swift | 29 + .../SQLiteData/Undo/DefaultUndoManager.swift | 30 + .../SQLiteData/Undo/Internal/UndoEntry.swift | 11 + .../Undo/Internal/UndoFunctions.swift | 21 + .../SQLiteData/Undo/Internal/UndoLog.swift | 17 + .../Undo/Internal/UndoTriggers.swift | 195 +++++ .../SQLiteData/Undo/SQLiteUndoManager.swift | 2 + Sources/SQLiteData/Undo/UndoAction.swift | 7 + Sources/SQLiteData/Undo/UndoEvent.swift | 47 ++ Sources/SQLiteData/Undo/UndoGroup.swift | 30 + Sources/SQLiteData/Undo/UndoManager.swift | 666 ++++++++++++++++ .../SQLiteData/Undo/UndoManagerDelegate.swift | 34 + Sources/SQLiteData/Undo/WithoutUndo.swift | 23 + .../UndoTests/UndoManagerTests.swift | 754 ++++++++++++++++++ 30 files changed, 2258 insertions(+), 54 deletions(-) create mode 100644 Examples/Reminders/UndoToolbarButtons.swift create mode 100644 Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift create mode 100644 Sources/SQLiteData/Undo/DefaultUndoManager.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoEntry.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoFunctions.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoLog.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoTriggers.swift create mode 100644 Sources/SQLiteData/Undo/SQLiteUndoManager.swift create mode 100644 Sources/SQLiteData/Undo/UndoAction.swift create mode 100644 Sources/SQLiteData/Undo/UndoEvent.swift create mode 100644 Sources/SQLiteData/Undo/UndoGroup.swift create mode 100644 Sources/SQLiteData/Undo/UndoManager.swift create mode 100644 Sources/SQLiteData/Undo/UndoManagerDelegate.swift create mode 100644 Sources/SQLiteData/Undo/WithoutUndo.swift create mode 100644 Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e5aa49c9..d2fae421 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", + "originHash" : "e7966629f2319c6882643a1f8848263b5757a7fd8747840f570db1f7f8a83c6b", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "xcode-26-4", - "revision" : "31dd6609d8398016011b418374d82fb6f716c944" + "revision" : "20db4a2a446f51e67e1207d54a23ad0a03471a7b", + "version" : "0.31.0" } }, { diff --git a/Examples/Reminders/README.md b/Examples/Reminders/README.md index 9ee145e4..60bd5554 100644 --- a/Examples/Reminders/README.md +++ b/Examples/Reminders/README.md @@ -4,6 +4,10 @@ A rebuild of many of the features from Apple's [Reminders app][reminders-app-sto for reminders, lists and tags in a SQLite database, and uses foreign keys to express one-to-many and many-to-many relationships between the entities. +The sample configures a default undo manager so local and synced changes can be undone/redone from +the screen menu using Undo/Redo entries that trigger immediately. It also binds to Apple's +`UndoManager` so system undo gestures (including shake to undo) work with the same stack. + It also demonstrates how to perform very advanced queries in SQLite that would be impossible in SwiftData, such as using SQLite's `group_concat` function to fetch all reminders along with a comma-separated list of all of its tags. SQLite is an incredibly powerful language, and one should diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a41e28e0..8e34af64 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -169,7 +169,8 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup(reminder.id == nil ? "Create reminder" : "Edit reminder") { + db in let reminderID = try Reminder.upsert { reminder } .returning(\.id) .fetchOne(db)! diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index c112eaa3..9e3b3567 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -76,14 +76,16 @@ struct ReminderRow: View { .swipeActions { Button("Delete", role: .destructive) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete reminder") { db in try Reminder.delete(reminder).execute(db) } } } Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup( + reminder.isFlagged ? "Unflag reminder" : "Flag reminder" + ) { db in try Reminder .find(reminder.id) .update { $0.isFlagged.toggle() } @@ -106,7 +108,9 @@ struct ReminderRow: View { private func completeButtonTapped() { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup( + reminder.isCompleted ? "Mark reminder incomplete" : "Mark reminder complete" + ) { db in try Reminder .find(reminder.id) .update { $0.toggleStatus() } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 93b7b845..0b83d240 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -14,11 +14,15 @@ struct RemindersApp: App { static let model = RemindersListsModel() @State var syncEngineDelegate = RemindersSyncEngineDelegate() + @State var undoManagerDelegate = RemindersUndoManagerDelegate() init() { if context == .live { try! prepareDependencies { - try $0.bootstrapDatabase(syncEngineDelegate: syncEngineDelegate) + try $0.bootstrapDatabase( + syncEngineDelegate: syncEngineDelegate, + undoManagerDelegate: undoManagerDelegate + ) } } } @@ -29,6 +33,7 @@ struct RemindersApp: App { NavigationStack { RemindersListsView(model: Self.model) } + .bindSQLiteUndoManagerToSystemUndo() .alert( "Reset local data?", isPresented: $syncEngineDelegate.isDeleteLocalDataAlertPresented @@ -46,6 +51,18 @@ struct RemindersApp: App { """ ) } + .alert(item: $undoManagerDelegate.confirmationRequest) { request in + Alert( + title: Text(request.title), + message: Text(request.message), + primaryButton: .destructive(Text(request.confirmButtonTitle)) { + undoManagerDelegate.respondToConfirmation(confirmed: true) + }, + secondaryButton: .cancel { + undoManagerDelegate.respondToConfirmation(confirmed: false) + } + ) + } } } } @@ -70,6 +87,88 @@ class RemindersSyncEngineDelegate: SyncEngineDelegate { } } +@MainActor +@Observable +final class RemindersUndoManagerDelegate: UndoManagerDelegate { + struct ConfirmationRequest: Identifiable { + let action: UndoAction + let group: UndoGroup + var id: UUID { group.id } + var title: String { + switch action { + case .undo: "Undo \"\(group.description)\"?" + case .redo: "Redo \"\(group.description)\"?" + } + } + var message: String { + "This change came from \(originDescription). Are you sure you want to continue?" + } + var confirmButtonTitle: String { + switch action { + case .undo: "Undo" + case .redo: "Redo" + } + } + + private var originDescription: String { + var parts: [String] = [] + if group.deviceID != SQLiteUndoManager.defaultDeviceID { + if group.deviceID == "sqlitedata-sync" { + parts.append("another device") + } else { + parts.append("device \(group.deviceID)") + } + } + if + let userRecordName = group.userRecordName + { + parts.append("user \(userRecordName)") + } + return parts.isEmpty ? "this device" : parts.joined(separator: " and ") + } + } + + var confirmationRequest: ConfirmationRequest? + private var confirmationContinuation: CheckedContinuation? + + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + guard shouldConfirm(for: group) else { + try await performAction() + return + } + if await requestConfirmation(action: action, group: group) { + try await performAction() + } + } + + func respondToConfirmation(confirmed: Bool) { + confirmationContinuation?.resume(returning: confirmed) + confirmationContinuation = nil + confirmationRequest = nil + } + + private func shouldConfirm(for group: UndoGroup) -> Bool { + let isOtherDevice = group.deviceID != SQLiteUndoManager.defaultDeviceID + let isOtherUser = group.userRecordName != nil + return isOtherDevice || isOtherUser + } + + private func requestConfirmation(action: UndoAction, group: UndoGroup) async -> Bool { + if confirmationContinuation != nil { + respondToConfirmation(confirmed: false) + } + return await withCheckedContinuation { continuation in + confirmationContinuation = continuation + confirmationRequest = ConfirmationRequest(action: action, group: group) + } + } +} + class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 164443b7..a8a095a0 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject { func move(from source: IndexSet, to destination: Int) async { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Reorder reminders") { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder @@ -252,6 +252,9 @@ struct RemindersDetailView: View { } } Menu { + UndoMenuItems() + .tint(model.detailType.color) + Divider() Group { Menu { ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 98d4a022..44dc7df8 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -77,7 +77,9 @@ struct RemindersListForm: View { Button("Save") { Task { [remindersList, coverImageData] in await withErrorReporting { - try await database.write { db in + try await database.writeWithUndoGroup( + remindersList.id == nil ? "Create list" : "Edit list" + ) { db in let remindersListID = try RemindersList .upsert { remindersList } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index c2778bf8..8f4e6cbf 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -35,7 +35,7 @@ struct RemindersListRow: View { .swipeActions { Button { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete list") { db in try RemindersList.delete(remindersList) .execute(db) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 11080ec3..4a3d9ece 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -55,6 +55,10 @@ class RemindersListsModel { @ObservationIgnored @Dependency(\.defaultDatabase) private var database + @ObservationIgnored + @Dependency(\.defaultUndoManager) private var undoManager + @ObservationIgnored + private var undoEventsTask: Task? func statTapped(_ detailType: RemindersDetailModel.DetailType) { destination = .detail(RemindersDetailModel(detailType: detailType)) @@ -81,7 +85,7 @@ class RemindersListsModel { func deleteTags(atOffsets offsets: IndexSet) { withErrorReporting { let tagTitles = offsets.map { tags[$0].title } - try database.write { db in + try database.writeWithUndoGroup("Delete tags") { db in try Tag .where { $0.title.in(tagTitles) } .delete() @@ -91,6 +95,7 @@ class RemindersListsModel { } func onAppear() { + observeUndoEventsIfNeeded() withErrorReporting { try Tips.configure() } @@ -121,7 +126,7 @@ class RemindersListsModel { func move(from source: IndexSet, to destination: Int) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Reorder lists") { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList @@ -149,6 +154,36 @@ class RemindersListsModel { } #endif + deinit { + undoEventsTask?.cancel() + } + + private func observeUndoEventsIfNeeded() { + guard undoEventsTask == nil, let undoManager else { return } + undoEventsTask = Task { [weak self] in + guard let self else { return } + for await event in undoManager.events { + await self.handleUndoEvent(event) + } + } + } + + private func handleUndoEvent(_ event: UndoEvent) async { + guard event.kind == .undo else { return } + guard event.affectedRows.contains(where: { $0.tableName == RemindersList.tableName }) else { return } + guard case let .detail(detailModel)? = destination else { return } + guard case let .remindersList(remindersList) = detailModel.detailType else { return } + + await withErrorReporting { + let isStillPresent = try await database.read { db in + try RemindersList.find(remindersList.id).fetchOne(db) != nil + } + if !isStillPresent { + destination = nil + } + } + } + @CasePathable enum Destination { case detail(RemindersDetailModel) @@ -312,9 +347,11 @@ struct RemindersListsView: View { } .listStyle(.insetGrouped) .toolbar { - #if DEBUG - ToolbarItem(placement: .automatic) { - Menu { + ToolbarItem(placement: .primaryAction) { + Menu { + UndoMenuItems() + #if DEBUG + Divider() Button { model.seedDatabaseButtonTapped() } label: { @@ -335,12 +372,12 @@ struct RemindersListsView: View { Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") Image(systemName: syncEngine.isRunning ? "stop" : "play") } - } label: { - Image(systemName: "ellipsis.circle") - } - .popoverTip(model.seedDatabaseTip) + #endif + } label: { + Image(systemName: "ellipsis.circle") } - #endif + .popoverTip(model.seedDatabaseTip) + } ToolbarItem(placement: .bottomBar) { HStack { Button { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 0247c9ea..763e8e36 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -116,8 +116,20 @@ struct ReminderText: FTS5 { } extension DependencyValues { - mutating func bootstrapDatabase(syncEngineDelegate: (any SyncEngineDelegate)? = nil) throws { + mutating func bootstrapDatabase( + syncEngineDelegate: (any SyncEngineDelegate)? = nil, + undoManagerDelegate: (any UndoManagerDelegate)? = nil + ) throws { defaultDatabase = try Reminders.appDatabase() + defaultUndoManager = try UndoManager( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self, + delegate: undoManagerDelegate + ) defaultSyncEngine = try SyncEngine( for: defaultDatabase, tables: RemindersList.self, diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index be22878f..0fce00c6 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -64,7 +64,7 @@ class SearchRemindersModel { func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Clear completed reminders") { db in try Reminder .where { $0.isCompleted diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 37ae1e0b..32444e5a 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -18,7 +18,7 @@ struct TagRow: View { .swipeActions { Button { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.delete(tag) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index ae97a2b4..989c52dc 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -80,7 +80,7 @@ struct TagsView: View { func deleteButtonTapped(tag: Tag) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.find(tag.title).delete().execute(db) } } @@ -95,7 +95,7 @@ struct TagsView: View { defer { tagTitle = "" } let tag = Tag(title: tagTitle) withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup(editingTag == nil ? "Create tag" : "Edit tag") { db in if let existingTagTitle = editingTag?.title { selectedTags.removeAll(where: { $0.title == existingTagTitle }) try Tag diff --git a/Examples/Reminders/UndoToolbarButtons.swift b/Examples/Reminders/UndoToolbarButtons.swift new file mode 100644 index 00000000..4ad5ee0f --- /dev/null +++ b/Examples/Reminders/UndoToolbarButtons.swift @@ -0,0 +1,117 @@ +import SQLiteData +import SwiftUI + +struct UndoMenuItems: View { + @Dependency(\.defaultUndoManager) private var undoManager + + var body: some View { + if let undoManager { + ControlGroup { + Button { + performUndo() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .disabled(!undoManager.canUndo) + + Button { + performRedo() + } label: { + Label("Redo", systemImage: "arrow.uturn.forward") + } + .disabled(!undoManager.canRedo) + + } + .controlGroupStyle(.menu) + + if !undoManager.undoStack.isEmpty { + Menu { + ForEach(undoManager.undoStack) { group in + Button("Undo \(group.description)") { + performUndo(to: group) + } + } + } label: { + Label("Undo", systemImage: "arrow.uturn.backward.square") + } + } + + if !undoManager.redoStack.isEmpty { + Menu { + ForEach(undoManager.redoStack) { group in + Button("Redo \(group.description)") { + performRedo(to: group) + } + } + } label: { + Label("Redo", systemImage: "arrow.uturn.forward.square") + } + } + } + } + + private func performUndo(to group: UndoGroup? = nil) { + perform(.undo, to: group) + } + + private func performRedo(to group: UndoGroup? = nil) { + perform(.redo, to: group) + } + + private func perform(_ action: UndoAction, to targetGroup: UndoGroup?) { + guard let undoManager else { return } + Task { + await withErrorReporting { + let stack: [UndoGroup] + switch action { + case .undo: stack = undoManager.undoStack + case .redo: stack = undoManager.redoStack + } + let count = + targetGroup + .flatMap { target in stack.firstIndex { $0.id == target.id }.map { $0 + 1 } } + ?? 1 + guard count > 0 else { return } + for _ in 0.. some View { + content + .task(id: foundationUndoManager.map(ObjectIdentifier.init)) { + sqliteUndoManager?.bind(to: foundationUndoManager) + } + } +} + +extension View { + func bindSQLiteUndoManagerToSystemUndo() -> some View { + modifier(BindSQLiteUndoManagerToSystemUndo()) + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift index b0fb5edc..e1950a5c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift @@ -1,4 +1,5 @@ #if canImport(CloudKit) + import CloudKit import Dependencies package struct UserDatabase { @@ -18,7 +19,22 @@ package func write( _ updates: @Sendable (Database) throws -> T ) async throws -> T { - try await database.write { db in + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: database) + if let undoManager { + return try await undoManager.withGroup( + "Sync iCloud changes", + deviceID: UndoManager.syncDeviceID, + userRecordName: syncUndoUserRecordName + ) { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + return try await database.write { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } @@ -37,7 +53,22 @@ package func write( _ updates: (Database) throws -> T ) throws -> T { - try database.write { db in + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: database) + if let undoManager { + return try undoManager.withGroup( + "Sync iCloud changes", + deviceID: UndoManager.syncDeviceID, + userRecordName: syncUndoUserRecordName + ) { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + return try database.write { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } @@ -52,5 +83,13 @@ try updates(db) } } + + private var syncUndoUserRecordName: String? { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return _currentZoneID?.ownerName + } else { + return nil + } + } } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..1a9a49bb 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1481,21 +1481,30 @@ if let table = tablesByName[recordType] { func open(_: some SynchronizableTable) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - try T - .where { - #sql("\($0.primaryKey)").in( - SyncMetadata.findAll(recordIDs) - .select(\.recordPrimaryKey) - ) - } - .delete() - .execute(db) + let write = { + try await self.userDatabase.write { db in + try T + .where { + #sql("\($0.primaryKey)").in( + SyncMetadata.findAll(recordIDs) + .select(\.recordPrimaryKey) + ) + } + .delete() + .execute(db) - try UnsyncedRecordID - .findAll(recordIDs) - .delete() - .execute(db) + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) + } + } + if let zoneID = recordIDs.first?.zoneID { + try await $_currentZoneID.withValue(zoneID) { + try await write() + } + } else { + try await write() } } } @@ -1585,19 +1594,28 @@ } let shares: [ShareOrReference] = await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - var shares: [ShareOrReference] = [] - for record in modifications { - if let share = record as? CKShare { - shares.append(.share(share)) - } else { - upsertFromServerRecord(record, db: db) - if let shareReference = record.share { - shares.append(.reference(shareReference)) + let write = { + try await self.userDatabase.write { db in + var shares: [ShareOrReference] = [] + for record in modifications { + if let share = record as? CKShare { + shares.append(.share(share)) + } else { + self.upsertFromServerRecord(record, db: db) + if let shareReference = record.share { + shares.append(.reference(shareReference)) + } } } + return shares + } + } + if let zoneID = modifications.first?.recordID.zoneID { + return try await $_currentZoneID.withValue(zoneID) { + try await write() } - return shares + } else { + return try await write() } } ?? [] @@ -1892,8 +1910,10 @@ force: Bool = false ) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - upsertFromServerRecord(serverRecord, force: force, db: db) + try await $_currentZoneID.withValue(serverRecord.recordID.zoneID) { + try await userDatabase.write { db in + upsertFromServerRecord(serverRecord, force: force, db: db) + } } } } diff --git a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift new file mode 100644 index 00000000..4735a3ee --- /dev/null +++ b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift @@ -0,0 +1,29 @@ +public extension DatabaseWriter { + func writeWithUndoGroup( + _ description: String, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + + func writeWithUndoGroup( + _ description: String, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } +} diff --git a/Sources/SQLiteData/Undo/DefaultUndoManager.swift b/Sources/SQLiteData/Undo/DefaultUndoManager.swift new file mode 100644 index 00000000..1937520a --- /dev/null +++ b/Sources/SQLiteData/Undo/DefaultUndoManager.swift @@ -0,0 +1,30 @@ +import Dependencies + +extension DependencyValues { + /// The default SQLiteData undo manager used by integrations when available. + /// + /// Configure this as early as possible in your app's lifetime, for example with + /// `prepareDependencies`: + /// + /// ```swift + /// prepareDependencies { + /// $0.defaultDatabase = try! appDatabase() + /// $0.defaultUndoManager = try! UndoManager( + /// for: $0.defaultDatabase, + /// tables: Item.self + /// ) + /// } + /// ``` + /// + /// If no default undo manager is set, SQLiteData continues to work without undo support. + public var defaultUndoManager: UndoManager? { + get { self[DefaultUndoManagerKey.self] } + set { self[DefaultUndoManagerKey.self] = newValue } + } + + private enum DefaultUndoManagerKey: DependencyKey { + static let liveValue: UndoManager? = nil + static let previewValue: UndoManager? = nil + static let testValue: UndoManager? = nil + } +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoEntry.swift b/Sources/SQLiteData/Undo/Internal/UndoEntry.swift new file mode 100644 index 00000000..cc0a0f5c --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoEntry.swift @@ -0,0 +1,11 @@ +import Foundation + +/// An in-memory record of a single undo or redo group's position in the undo log. +package struct UndoEntry: Sendable { + /// The lowest `seq` value written to `sqlitedata_undo_log` by this group. + package let begin: Int + /// The highest `seq` value written to `sqlitedata_undo_log` by this group. + package let end: Int + /// The public metadata associated with this group. + package let group: UndoGroup +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift b/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift new file mode 100644 index 00000000..f8715bf5 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift @@ -0,0 +1,21 @@ +import StructuredQueriesCore + +/// A task-local flag set to `true` while the undo manager is executing inverse SQL so that +/// the undo triggers do not record the inverse operations as new undo entries. +@TaskLocal package var _isUndoingOrRedoing = false +@TaskLocal package var _isUndoRecordingDisabled = false + +@DatabaseFunction("sqlitedata_undo_isReplaying") +package func _isReplaying() -> Bool { + _isUndoingOrRedoing +} + +/// A SQLite scalar function registered on every database connection managed by ``UndoManager``. +/// +/// Triggers use `WHEN sqlitedata_undo_shouldRecord()` to decide whether to record an inverse +/// SQL statement. Returns `false` only when recording is explicitly disabled. +@DatabaseFunction("sqlitedata_undo_shouldRecord") +package func _shouldRecord() -> Bool { + if _isUndoRecordingDisabled { return false } + return true +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoLog.swift b/Sources/SQLiteData/Undo/Internal/UndoLog.swift new file mode 100644 index 00000000..bd077da6 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoLog.swift @@ -0,0 +1,17 @@ +import StructuredQueriesCore + +/// A row in the temporary `sqlitedata_undo_log` table, which stores inverse SQL statements +/// generated by undo triggers. +@Table("sqlitedata_undo_log") +package struct UndoLog { + package static var schemaName: String? { "temp" } + + /// Auto-incremented sequence number (the SQLite rowid alias). + package let seq: Int + /// The table name whose row change produced this inverse SQL entry. + package let tableName: String + /// The affected rowid in `tableName`. + package let trackedRowID: Int + /// A SQL statement that inverts the original change. + package let sql: String +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift new file mode 100644 index 00000000..cf2faaf9 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -0,0 +1,195 @@ +import Foundation +import GRDB + +// MARK: - Column info + +/// Reads writable column names for `tableName`. +/// +/// Prefer `pragma_table_xinfo` so hidden/generated columns can be filtered with `hidden = 0`. +/// Fall back to `pragma_table_info` on older SQLite builds where `table_xinfo` is unavailable. +package func undoColumnNames(for tableName: String, in db: Database) throws -> [String] { + // Use pragma table-valued functions. The table name is embedded as a quoted + // SQL identifier, not as a bound parameter, because some SQLite versions do not support bound + // parameters in table-valued-function arguments. + let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" + do { + return try String.fetchAll( + db, + sql: """ + SELECT name FROM pragma_table_xinfo(\(tableLiteral)) + WHERE hidden = 0 + ORDER BY cid + """ + ) + } catch { + return try String.fetchAll( + db, + sql: """ + SELECT name FROM pragma_table_info(\(tableLiteral)) + ORDER BY cid + """ + ) + } +} + +// MARK: - Undo log table + +/// The DDL that creates the per-connection temporary undo log table. +package let undoLogTableSQL = """ + CREATE TEMP TABLE IF NOT EXISTS "sqlitedata_undo_log" ( + "seq" INTEGER PRIMARY KEY AUTOINCREMENT, + "tableName" TEXT NOT NULL, + "trackedRowID" INTEGER NOT NULL DEFAULT 0, + "sql" TEXT NOT NULL + ) + """ + +// MARK: - Trigger SQL + +/// Generates the three undo triggers for a single table. +/// +/// - Parameters: +/// - tableName: The name of the user table to observe. +/// - columns: The writable column names obtained from `undoColumnNames(for:in:)`. +/// - Returns: Three `CREATE TEMP TRIGGER` statements (insert, update, delete). +package func undoTriggerSQL(for tableName: String, columns: [String]) -> [String] { + let qt: String = undoDoubleQuotedIdentifier(tableName) + let logTable = "\"sqlitedata_undo_log\"" + let whenClause = "WHEN sqlitedata_undo_shouldRecord()" + let triggerPrefix = "_sqlitedata_undo_" + + // INSERT → log a DELETE that removes the new row + let insertTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)insert_\(tableName)")) + AFTER INSERT ON \(qt) + \(whenClause) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + NEW.rowid, + 'DELETE FROM \(qt) WHERE rowid='||NEW.rowid + ); + END + """ + + // UPDATE → log an UPDATE that restores all old column values + // Only fire when at least one column actually changed. + let changedCondition: String = columns + .map { col -> String in + let qc: String = undoDoubleQuotedIdentifier(col) + return "OLD.\(qc) IS NOT NEW.\(qc)" + } + .joined(separator: " OR ") + let setClause: String = columns + .map { col -> String in + let qc: String = undoDoubleQuotedIdentifier(col) + return "\(qc)='||quote(OLD.\(qc))||'" + } + .joined(separator: ",") + let updateTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)update_\(tableName)")) + BEFORE UPDATE ON \(qt) + WHEN \(whenClause.dropFirst("WHEN ".count)) AND (\(changedCondition)) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + OLD.rowid, + 'UPDATE \(qt) SET \(setClause) WHERE rowid='||OLD.rowid + ); + END + """ + + // DELETE → log an INSERT that restores the deleted row + let colList: String = columns.map { undoDoubleQuotedIdentifier($0) }.joined(separator: ",") + let valList: String = columns + .map { col -> String in "'||quote(OLD.\(undoDoubleQuotedIdentifier(col)))||'" } + .joined(separator: ",") + let deleteTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)delete_\(tableName)")) + BEFORE DELETE ON \(qt) + \(whenClause) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + OLD.rowid, + 'INSERT INTO \(qt)(rowid,\(colList)) VALUES('||OLD.rowid||',\(valList))' + ); + END + """ + + return [insertTrigger, updateTrigger, deleteTrigger] +} + +/// Drop SQL for the three undo triggers of a table. +package func undoTriggerDropSQL(for tableName: String) -> [String] { + let prefix = "_sqlitedata_undo_" + return ["insert", "update", "delete"].map { kind in + "DROP TEMP TRIGGER IF EXISTS \(undoDoubleQuotedIdentifier("\(prefix)\(kind)_\(tableName)"))" + } +} + +// MARK: - Undo log analysis + +package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { + Set( + try String.fetchAll( + db, + sql: """ + SELECT DISTINCT "tableName" + FROM "sqlitedata_undo_log" + WHERE "seq" >= ? AND "seq" <= ? + """, + arguments: [startSeq, endSeq] + ) + ) +} + +package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { + let entries = try UndoLog + .where { $0.seq >= startSeq && $0.seq <= endSeq } + .order { $0.seq.asc() } + .fetchAll(db) + + var grouped = [String: [UndoLog]]() + for entry in entries where entry.trackedRowID != 0 { + grouped["\(entry.tableName):\(entry.trackedRowID)", default: []].append(entry) + } + + var seqsToDelete: [Int] = [] + for (_, group) in grouped where group.count > 1 { + let first = group[0] + let last = group[group.count - 1] + + let firstIsDeleteReverse = first.sql.uppercased().hasPrefix("DELETE FROM") + let lastIsInsertReverse = last.sql.uppercased().hasPrefix("INSERT INTO") + + if firstIsDeleteReverse && lastIsInsertReverse { + seqsToDelete.append(contentsOf: group.map(\.seq)) + continue + } + + if firstIsDeleteReverse { + seqsToDelete.append(contentsOf: group.dropFirst().map(\.seq)) + continue + } + + seqsToDelete.append( + contentsOf: group.dropFirst().compactMap { + $0.sql.uppercased().hasPrefix("UPDATE") ? $0.seq : nil + } + ) + } + + guard !seqsToDelete.isEmpty else { return } + let sqlList = seqsToDelete.map(String.init).joined(separator: ",") + try db.execute(sql: "DELETE FROM \"sqlitedata_undo_log\" WHERE \"seq\" IN (\(sqlList))") +} + +// MARK: - Helpers + +private func undoDoubleQuotedIdentifier(_ identifier: String) -> String { + "\"" + identifier.replacingOccurrences(of: "\"", with: "\"\"") + "\"" +} diff --git a/Sources/SQLiteData/Undo/SQLiteUndoManager.swift b/Sources/SQLiteData/Undo/SQLiteUndoManager.swift new file mode 100644 index 00000000..87e4a49e --- /dev/null +++ b/Sources/SQLiteData/Undo/SQLiteUndoManager.swift @@ -0,0 +1,2 @@ +/// Preferred name for SQLiteData's undo manager to avoid clashing with Foundation's type. +public typealias SQLiteUndoManager = UndoManager diff --git a/Sources/SQLiteData/Undo/UndoAction.swift b/Sources/SQLiteData/Undo/UndoAction.swift new file mode 100644 index 00000000..e383db98 --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoAction.swift @@ -0,0 +1,7 @@ +/// Whether an undo manager operation is an undo or a redo. +public enum UndoAction: Sendable { + /// An undo operation that reverts the most-recently-recorded change. + case undo + /// A redo operation that re-applies the most-recently-undone change. + case redo +} diff --git a/Sources/SQLiteData/Undo/UndoEvent.swift b/Sources/SQLiteData/Undo/UndoEvent.swift new file mode 100644 index 00000000..a20a8fce --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoEvent.swift @@ -0,0 +1,47 @@ +import Foundation +import StructuredQueriesCore + +public struct UndoAffectedRow: Sendable, Hashable { + public let tableName: String + public let rowID: Int + + package init(tableName: String, rowID: Int) { + self.tableName = tableName + self.rowID = rowID + } + + public init(table: T.Type, rowID: Int) { + self.tableName = T.tableName + self.rowID = rowID + } + + public func id( + as type: T.Type + ) -> T.ID? where T.ID: BinaryInteger { + tableName == T.tableName ? T.ID(rowID) : nil + } +} + +public struct UndoEvent: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + case undo + case redo + } + + public let kind: Kind + public let group: UndoGroup + public let affectedRows: Set + + public init(kind: Kind, group: UndoGroup, affectedRows: Set) { + self.kind = kind + self.group = group + self.affectedRows = affectedRows + } + + public func ids( + for type: T.Type + ) -> Set? where T.ID: BinaryInteger { + let ids = Set(affectedRows.compactMap { $0.id(as: type) }) + return ids.isEmpty ? nil : ids + } +} diff --git a/Sources/SQLiteData/Undo/UndoGroup.swift b/Sources/SQLiteData/Undo/UndoGroup.swift new file mode 100644 index 00000000..b935cfda --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoGroup.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A named group of database changes that can be undone or redone as a single unit. +public struct UndoGroup: Sendable, Identifiable, Equatable { + /// A unique identifier for this group. + public let id: UUID + /// A human-readable description of the change, e.g. "Add reminder". + public let description: String + /// An identifier for the device that originated the change. + public let deviceID: String + /// The iCloud record name of the user who made the change, or `nil` if this is the current user + /// or sync is not configured. + public let userRecordName: String? + /// The date the change was recorded. + public let date: Date + + package init( + id: UUID = UUID(), + description: String, + deviceID: String, + userRecordName: String?, + date: Date + ) { + self.id = id + self.description = description + self.deviceID = deviceID + self.userRecordName = userRecordName + self.date = date + } +} diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift new file mode 100644 index 00000000..73b255bc --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -0,0 +1,666 @@ +import ConcurrencyExtras +import Foundation +import GRDB +import IssueReporting +import Perception +#if canImport(Observation) + import Observation +#endif +import StructuredQueriesCore + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Tracks changes made to a SQLite database and lets you undo and redo them. +/// +/// Prefer ``SQLiteUndoManager`` in your code when you also work with `Foundation.UndoManager`. +/// +/// Create an `UndoManager` after the database is open, supplying the table names whose changes +/// you want to track. The manager installs lightweight SQLite triggers that record inverse SQL +/// statements into a temporary log table. +/// +/// ```swift +/// let undoManager = try UndoManager( +/// for: database, +/// tables: Reminder.self, ReminderTag.self, +/// deviceID: UIDevice.current.identifierForVendor?.uuidString ?? "" +/// ) +/// +/// // Record a named group of changes +/// try await undoManager.withGroup("Add reminder") { db in +/// try Reminder.insert { Reminder.Draft(title: "Buy milk") }.execute(db) +/// } +/// +/// // Undo the most-recent group +/// try await undoManager.undo() +/// ``` +/// +/// ## CloudKit sync compatibility +/// +/// Changes written by a `SyncEngine` can be recorded as undo groups, including synced-origin +/// metadata. +public final class UndoManager: Perceptible, @unchecked Sendable { + private final class WeakUndoManager: @unchecked Sendable { + weak var value: UndoManager? + init(_ value: UndoManager) { + self.value = value + } + } + + private static let _managersByID = LockIsolated([ObjectIdentifier: WeakUndoManager]()) + package static let syncDeviceID = "sqlitedata-sync" + + // MARK: - Internal state + + private struct State { + var undoEntries: [UndoEntry] = [] + var redoEntries: [UndoEntry] = [] + var activeBarrier: (id: UUID, barrier: OpenBarrier)? + /// The next `seq` value that will begin a new undo group. + var firstLog: Int = 1 + /// The first log sequence captured by the outermost freeze. + var freezePoint: Int = -1 + /// Nesting count for `freeze()`/`unfreeze()`. + var freezeDepth: Int = 0 + } + + private struct OpenBarrier: Sendable { + var group: UndoGroup + var firstLog: Int + } + + public enum BarrierError: Error { + case alreadyOpen + case notFound + } + + private let _state = LockIsolated(State()) + private let database: any DatabaseWriter + private let databaseID: ObjectIdentifier + private let deviceID: String + private let userRecordName: @Sendable () -> String? + private let trackedTableNames: Set + private let delegate: (any UndoManagerDelegate)? + private let eventsContinuation: AsyncStream.Continuation + public let events: AsyncStream + #if canImport(ObjectiveC) + private weak var foundationUndoManager: Foundation.UndoManager? + #endif + + // MARK: - Observable conformance (Perception) + + private let _$perceptionRegistrar = PerceptionRegistrar() + + nonisolated public func access( + keyPath: KeyPath + ) { + _$perceptionRegistrar.access(self, keyPath: keyPath) + } + + nonisolated public func withMutation( + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + try _$perceptionRegistrar.withMutation(of: self, keyPath: keyPath, mutation) + } + + // MARK: - Observable state + + /// The groups that can be undone, most-recent-first. + public var undoStack: [UndoGroup] { + _$perceptionRegistrar.access(self, keyPath: \.undoStack) + return _state.value.undoEntries.reversed().map(\.group) + } + + /// The groups that can be redone, most-recent-first. + public var redoStack: [UndoGroup] { + _$perceptionRegistrar.access(self, keyPath: \.redoStack) + return _state.value.redoEntries.reversed().map(\.group) + } + + /// Whether there is at least one group that can be undone. + public var canUndo: Bool { !undoStack.isEmpty } + + /// Whether there is at least one group that can be redone. + public var canRedo: Bool { !redoStack.isEmpty } + + // MARK: - Init + + /// Creates an undo manager and installs undo triggers on the database. + /// + /// The triggers and the temporary log table are created immediately on the writer connection. + /// + /// - Parameters: + /// - database: The database to observe. + /// - tables: The names of the tables whose changes should be undoable. + /// - deviceID: An identifier for this device shown in ``UndoGroup/deviceID``. + /// Defaults to the system device identifier. + /// - userRecordName: A closure returning the current user's iCloud record name, or `nil`. + /// - delegate: An optional delegate that can intercept and confirm undo/redo operations. + public init< + each T: PrimaryKeyedTable & _SendableMetatype + >( + for database: any DatabaseWriter, + tables: repeat (each T).Type, + deviceID: String = UndoManager.defaultDeviceID, + userRecordName: @Sendable @escaping () -> String? = { nil }, + delegate: (any UndoManagerDelegate)? = nil + ) throws { + var trackedTableNames = Set() + for table in repeat each tables { + trackedTableNames.insert(table.tableName) + } + (self.events, self.eventsContinuation) = AsyncStream.makeStream() + self.database = database + self.databaseID = ObjectIdentifier(database as AnyObject) + self.deviceID = deviceID + self.userRecordName = userRecordName + self.delegate = delegate + self.trackedTableNames = trackedTableNames + + // One-time setup on the writer connection: register the custom function, + // create the temp log table, and install triggers for each observed table. + try database.write { db in + db.add(function: $_shouldRecord) + db.add(function: $_isReplaying) + + try db.execute(sql: undoLogTableSQL) + + for table in repeat each tables { + let tableName = table.tableName + let columns = try undoColumnNames(for: tableName, in: db) + guard !columns.isEmpty else { continue } + for sql in undoTriggerSQL(for: tableName, columns: columns) { + try db.execute(sql: sql) + } + } + } + + Self._managersByID.withValue { + $0[self.databaseID] = WeakUndoManager(self) + } + } + + deinit { + Self._managersByID.withValue { + if $0[self.databaseID]?.value === self { + $0.removeValue(forKey: self.databaseID) + } + } + } + + package static func manager(for database: any DatabaseWriter) -> UndoManager? { + _managersByID.withValue { + $0 = $0.filter { $0.value.value != nil } + return $0[ObjectIdentifier(database as AnyObject)]?.value + } + } + + package func manages(database: any DatabaseWriter) -> Bool { + databaseID == ObjectIdentifier(database as AnyObject) + } + + #if canImport(ObjectiveC) + /// Binds this manager to Foundation's undo manager for seamless system undo/redo integration. + /// + /// When bound, SQLiteData undo/redo operations are registered with the Foundation manager so + /// keyboard shortcuts and responder-chain undo work with the same stack. + public func bind(to foundationUndoManager: Foundation.UndoManager?) { + self.foundationUndoManager = foundationUndoManager + } + #endif + + // MARK: - Static helpers + + /// A device identifier suitable for use with ``init(for:tables:deviceID:userRecordName:delegate:)``. + /// + /// On iOS this is `UIDevice.identifierForVendor`; on macOS it is the machine's host name. + public static var defaultDeviceID: String { + #if canImport(UIKit) + return UIDevice.current.identifierForVendor?.uuidString ?? ProcessInfo.processInfo.hostName + #else + return ProcessInfo.processInfo.hostName + #endif + } + + /// A SQL expression that reports whether undo/redo replay is currently executing. + /// + /// Use this in application trigger `WHEN` clauses to suppress side-effect writes during replay. + public static func isReplaying() -> some QueryExpression { + $_isReplaying() + } + + // MARK: - Group recording + + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// Use this API when an undoable action spans multiple writes or async boundaries. + @discardableResult + public func beginBarrier( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + let group = UndoGroup( + description: description, + deviceID: deviceID ?? self.deviceID, + userRecordName: userRecordName ?? self.userRecordName(), + date: Date() + ) + let barrierID = UUID() + try _state.withValue { state in + guard state.activeBarrier == nil else { throw BarrierError.alreadyOpen } + state.activeBarrier = ( + id: barrierID, + barrier: OpenBarrier(group: group, firstLog: state.firstLog) + ) + } + return barrierID + } + + /// Ends a previously opened barrier and pushes it to undo history if changes were recorded. + @discardableResult + public func endBarrier(_ barrierID: UUID) throws -> UndoGroup? { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + let summary = try database.write { db -> (maxSeq: Int, modifiedTables: Set)? in + guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + maxSeq >= barrier.firstLog + else { + return nil + } + try undoReconcileEntries(in: db, from: barrier.firstLog, to: maxSeq) + maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + guard maxSeq >= barrier.firstLog else { return nil } + return (maxSeq, try undoModifiedTableNames(in: db, from: barrier.firstLog, to: maxSeq)) + } + guard let summary else { return nil } + return finalizeBarrier( + barrier, + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + } + + /// Async variant of ``endBarrier(_:)``. + @discardableResult + public func endBarrier(_ barrierID: UUID) async throws -> UndoGroup? { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + let summary = try await database.write { db -> (maxSeq: Int, modifiedTables: Set)? in + guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + maxSeq >= barrier.firstLog + else { + return nil + } + try undoReconcileEntries(in: db, from: barrier.firstLog, to: maxSeq) + maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + guard maxSeq >= barrier.firstLog else { return nil } + return (maxSeq, try undoModifiedTableNames(in: db, from: barrier.firstLog, to: maxSeq)) + } + guard let summary else { return nil } + return finalizeBarrier( + barrier, + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + } + + /// Cancels a previously opened barrier and discards any undo log entries captured for it. + public func cancelBarrier(_ barrierID: UUID) throws { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + try database.write { db in + try UndoLog + .where { $0.seq >= barrier.firstLog } + .delete() + .execute(db) + } + _state.withValue { state in + state.firstLog = barrier.firstLog + } + } + + /// Async variant of ``cancelBarrier(_:)``. + public func cancelBarrier(_ barrierID: UUID) async throws { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + try await database.write { db in + try UndoLog + .where { $0.seq >= barrier.firstLog } + .delete() + .execute(db) + } + _state.withValue { state in + state.firstLog = barrier.firstLog + } + } + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @discardableResult + public func withGroup( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + let barrierID = try beginBarrier( + description, + deviceID: deviceID, + userRecordName: userRecordName + ) + do { + let result = try await database.write { db in + try body(db) + } + _ = try await endBarrier(barrierID) + return result + } catch { + try await cancelBarrier(barrierID) + throw error + } + } + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @discardableResult + public func withGroup( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + let barrierID = try beginBarrier( + description, + deviceID: deviceID, + userRecordName: userRecordName + ) + do { + let result = try database.write { db in + try body(db) + } + _ = try endBarrier(barrierID) + return result + } catch { + try cancelBarrier(barrierID) + throw error + } + } + + // MARK: - Undo / Redo + + /// Reverts the most-recently-recorded undo group. + /// + /// The delegate (if any) is called before the operation is performed so that you can present a + /// confirmation prompt. + public func undo() async throws { + try await perform(.undo) + } + + /// Re-applies the most-recently-undone group. + /// + /// The delegate (if any) is called before the operation is performed so that you can present a + /// confirmation prompt. + public func redo() async throws { + try await perform(.redo) + } + + // MARK: - Freeze / Unfreeze + + /// Suspends undo recording. + /// + /// Changes made while recording is frozen are not added to the undo stack. Call ``unfreeze()`` + /// to resume recording. Calls to ``freeze()`` and ``unfreeze()`` may be nested. + public func freeze() async throws { + try await database.write { _ in + self._state.withValue { state in + if state.freezeDepth == 0 { + state.freezePoint = state.firstLog + } + state.freezeDepth += 1 + } + } + } + + /// Resumes undo recording after a call to ``freeze()``. + /// + /// Any log entries written while frozen are discarded, and ``firstLog`` is advanced past them. + public func unfreeze() async throws { + let shouldFinalizeFreeze = _state.withValue { state in + guard state.freezeDepth > 0 else { return false } + state.freezeDepth -= 1 + return state.freezeDepth == 0 + } + guard shouldFinalizeFreeze else { return } + + let maxSeq = try await database.write { db in + try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + } + _state.withValue { state in + guard state.freezeDepth == 0, state.freezePoint >= 0 else { return } + state.firstLog = maxSeq + 1 + state.freezePoint = -1 + } + } + + // MARK: - Private helpers + + private func finalizeBarrier( + _ barrier: OpenBarrier, + maxSeq: Int, + modifiedTables: Set + ) -> UndoGroup? { + guard maxSeq >= barrier.firstLog else { + return nil + } + let unknownTables = modifiedTables.subtracting(trackedTableNames) + if !unknownTables.isEmpty { + reportIssue( + """ + Undo group '\(barrier.group.description)' recorded changes for unexpected tables: \ + \(unknownTables.sorted().joined(separator: ", ")). + """ + ) + } + let entry = UndoEntry(begin: barrier.firstLog, end: maxSeq, group: barrier.group) + let shouldRecord = _state.withValue { $0.freezePoint < 0 } + _$perceptionRegistrar.withMutation(of: self, keyPath: \.undoStack) { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.redoStack) { + _state.withValue { state in + if shouldRecord { + state.undoEntries.append(entry) + state.redoEntries = [] + state.firstLog = maxSeq + 1 + } + } + } + } + if shouldRecord { + registerFoundationAction(.undo, group: barrier.group) + return barrier.group + } + return nil + } + + private func perform(_ action: UndoAction) async throws { + // Peek at the entry to pass to the delegate. + let entry: UndoEntry? = _state.withValue { state in + switch action { + case .undo: return state.undoEntries.last + case .redo: return state.redoEntries.last + } + } + guard let entry else { return } + + let performAction: @Sendable () async throws -> Void = { [weak self] in + guard let self else { return } + try await self.applyInverse(of: entry, action: action) + } + + if let delegate { + try await delegate.undoManager(self, willPerform: action, for: entry.group, performAction: performAction) + } else { + try await performAction() + } + } + + private func applyInverse(of entry: UndoEntry, action: UndoAction) async throws { + let firstLog = _state.value.firstLog + + // Execute inverse SQL inside a write transaction. + // Triggers must run so that inverse-of-inverse statements are recorded for the opposite stack. + let affectedRows = try await $_isUndoingOrRedoing.withValue(true) { + try await database.write { db in + // Fetch inverse SQL rows in reverse order (highest seq first = undo in LIFO order). + let rows = try UndoLog + .where { $0.seq >= entry.begin && $0.seq <= entry.end } + .order { $0.seq.desc() } + .fetchAll(db) + + let affectedRows = Set( + rows + .filter { $0.trackedRowID != 0 } + .map { UndoAffectedRow(tableName: $0.tableName, rowID: $0.trackedRowID) } + ) + + // Remove these rows from the log before executing so re-entrant calls don't see them. + try UndoLog + .where { $0.seq >= entry.begin && $0.seq <= entry.end } + .delete() + .execute(db) + + // Replayed statements can include child-before-parent row restoration from cascading + // deletes. Deferring FK checks until commit lets the full inverse set restore first. + try db.execute(sql: "PRAGMA defer_foreign_keys = ON") + + // Execute each inverse SQL statement in order. + for row in rows { + try db.execute(sql: row.sql) + } + return affectedRows + } + } + + // The triggers fired during `applyInverse` will have added new rows to the log. + let newEnd = try await database.write { db -> Int in + guard var newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq else { return 0 } + if newEnd >= firstLog { + try undoReconcileEntries(in: db, from: firstLog, to: newEnd) + newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + } + return newEnd + } + let didAppend = newEnd >= firstLog + + let newEntry = UndoEntry(begin: firstLog, end: newEnd, group: entry.group) + + _$perceptionRegistrar.withMutation(of: self, keyPath: \.undoStack) { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.redoStack) { + _state.withValue { state in + switch action { + case .undo: + state.undoEntries.removeLast() + if didAppend { + state.redoEntries.append(newEntry) + } + case .redo: + state.redoEntries.removeLast() + if didAppend { + state.undoEntries.append(newEntry) + } + } + state.firstLog = newEnd + 1 + } + } + } + + guard didAppend else { return } + let eventKind: UndoEvent.Kind + switch action { + case .undo: eventKind = .undo + case .redo: eventKind = .redo + } + eventsContinuation.yield( + UndoEvent( + kind: eventKind, + group: entry.group, + affectedRows: affectedRows + ) + ) + } + + #if canImport(ObjectiveC) + private func registerFoundationAction(_ action: UndoAction, group: UndoGroup) { + Task { @MainActor [weak self] in + guard let self else { return } + self.registerFoundationActionOnMain(action, group: group) + } + } + + @MainActor + private func registerFoundationActionOnMain(_ action: UndoAction, group: UndoGroup) { + guard let foundationUndoManager else { return } + foundationUndoManager.registerUndo(withTarget: self) { target in + let inverseAction: UndoAction + switch action { + case .undo: inverseAction = .redo + case .redo: inverseAction = .undo + } + target.registerFoundationActionOnMain(inverseAction, group: group) + Task { + do { + switch action { + case .undo: + try await target.undo() + case .redo: + try await target.redo() + } + } catch { + assertionFailure("SQLiteUndoManager failed to perform Foundation undo action: \(error)") + } + } + } + foundationUndoManager.setActionName(group.description) + } + #else + private func registerFoundationAction(_ action: UndoAction, group: UndoGroup) {} + #endif +} + +#if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension UndoManager: Observable {} +#endif diff --git a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift new file mode 100644 index 00000000..5f2eaa3e --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift @@ -0,0 +1,34 @@ +/// A delegate that an ``UndoManager`` calls before performing an undo or redo operation. +/// +/// The delegate can present a confirmation prompt, or perform any async work, before calling +/// `performAction` to commit the operation. If `performAction` is not called, the operation is +/// cancelled and the undo/redo stacks remain unchanged. +/// +/// The default implementation performs the action immediately without any confirmation. +public protocol UndoManagerDelegate: AnyObject, Sendable { + /// Called before the undo manager performs an undo or redo operation. + /// + /// - Parameters: + /// - undoManager: The undo manager requesting the action. + /// - action: Whether this is an undo or redo. + /// - group: The group of changes that will be undone or redone. + /// - performAction: Call this to commit the operation. Omitting this call cancels it. + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws +} + +extension UndoManagerDelegate { + /// Default implementation: immediately performs the action without confirmation. + public func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + try await performAction() + } +} diff --git a/Sources/SQLiteData/Undo/WithoutUndo.swift b/Sources/SQLiteData/Undo/WithoutUndo.swift new file mode 100644 index 00000000..55cd718c --- /dev/null +++ b/Sources/SQLiteData/Undo/WithoutUndo.swift @@ -0,0 +1,23 @@ +/// Executes work while undo trigger recording is disabled. +/// +/// Use this to perform writes that should not become undoable entries. +@discardableResult +public func withoutUndo( + _ operation: () throws -> T +) rethrows -> T { + try $_isUndoRecordingDisabled.withValue(true) { + try operation() + } +} + +/// Executes async work while undo trigger recording is disabled. +/// +/// Use this to perform writes that should not become undoable entries. +@discardableResult +public func withoutUndo( + _ operation: @Sendable () async throws -> T +) async rethrows -> T { + try await $_isUndoRecordingDisabled.withValue(true) { + try await operation() + } +} diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift new file mode 100644 index 00000000..ff593ebe --- /dev/null +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -0,0 +1,754 @@ +import Foundation +import Dependencies +import SQLiteData +import Testing +#if canImport(CloudKit) + import CloudKit +#endif + +// MARK: - Schema + +@Table private struct Item: Equatable, Identifiable { + let id: Int + var title: String +} + +@Table("notes") private struct Note: Equatable, Identifiable { + let id: Int + var body: String? +} + +@Table("audits") private struct Audit: Equatable, Identifiable { + let id: Int + var message: String +} + +@Table("parents") private struct Parent: Equatable, Identifiable { + let id: Int + var name: String +} + +@Table("children") private struct Child: Equatable, Identifiable { + let id: Int + var parentID: Int + var name: String +} + +// MARK: - Database helpers + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func undoDatabase() throws -> DatabaseQueue { + let database = try DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create items") { db in + try #sql( + """ + CREATE TABLE "items" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL DEFAULT '' + ) + """ + ) + .execute(db) + } + try migrator.migrate(database) + return database + } +} + +// MARK: - Tests + +@Suite struct UndoManagerTests { + @Test func defaultUndoManagerDependencyDefaultsToNil() { + @Dependency(\.defaultUndoManager) var defaultUndoManager + #expect(defaultUndoManager == nil) + } + + + // 1. Basic undo removes the inserted row and leaves canUndo false. + @Test func basicUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + #expect(undoManager.canUndo) + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + #expect(!undoManager.canUndo) + #expect(undoManager.undoStack.isEmpty) + } + + // 2. After undo, redo restores the row and leaves canRedo false. + @Test func basicRedo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + try await undoManager.undo() + #expect(undoManager.canRedo) + + try await undoManager.redo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + #expect(items[0].title == "Hello") + #expect(!undoManager.canRedo) + #expect(undoManager.redoStack.isEmpty) + } + + // 3. Two inserts in one withGroup are undone together. + @Test func undoGroup() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Batch insert") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + // 4. Separate groups produce separate undo entries; undoing removes only the last one. + @Test func multipleGroups() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.withGroup("Insert B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + #expect(undoManager.undoStack.count == 2) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + #expect(items[0].title == "A") + #expect(undoManager.undoStack.count == 1) + } + + // 5. Sync-origin writes can be grouped, undone, and carry synced-origin metadata. + @Test func syncIncludedWithMetadata() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await $_isSynchronizingChanges.withValue(true) { + try await undoManager.withGroup( + "Sync insert", + deviceID: UndoManager.syncDeviceID, + userRecordName: "collaborator-user" + ) { db in + _ = try Item.insert { Item.Draft(title: "Sync item") }.execute(db) + } + } + + #expect(undoManager.canUndo) + #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) + #expect(undoManager.undoStack.first?.userRecordName == "collaborator-user") + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + // 6. Inverse SQL executed during undo is not added to the undo stack; it goes to redo. + @Test func undoingNotRecorded() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "X") }.execute(db) + } + try await undoManager.undo() + + // Only the redo entry should exist; no additional undo entry. + #expect(undoManager.undoStack.isEmpty) + #expect(undoManager.redoStack.count == 1) + } + + // 7. Changes made while frozen are not undoable. + @Test func freeze() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.freeze() + // Direct write (not through withGroup) so we can test the trigger suppression via freeze. + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Frozen") }.execute(db) + } + try await undoManager.unfreeze() + + #expect(!undoManager.canUndo) + #expect(undoManager.undoStack.isEmpty) + + // The row should still be in the database. + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + // 8. When the delegate does not call performAction, the undo is cancelled. + @Test func explicitBarrierLifecycle() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + let barrierID = try undoManager.beginBarrier("Insert via barrier") + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Barrier item") }.execute(db) + } + let group = try await undoManager.endBarrier(barrierID) + + #expect(group?.description == "Insert via barrier") + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + @Test func cancelBarrierDropsUndoRegistration() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + let barrierID = try undoManager.beginBarrier("Cancelled barrier") + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Not undoable") }.execute(db) + } + try await undoManager.cancelBarrier(barrierID) + + #expect(undoManager.undoStack.isEmpty) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + @Test func withoutUndoSuppressesRecording() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await withoutUndo { + try await undoManager.withGroup("Suppressed insert") { db in + _ = try Item.insert { Item.Draft(title: "Suppressed") }.execute(db) + } + } + + #expect(undoManager.undoStack.isEmpty) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + @Test func replayFunctionSuppressesAppTriggersDuringUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "audit_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "message" TEXT NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE TEMP TRIGGER "item_delete_audit" + AFTER DELETE ON "items" + WHEN NOT "sqlitedata_undo_isReplaying"() + BEGIN + INSERT INTO "audit_log" ("message") VALUES ('delete ' || OLD."title"); + END + """ + ) + } + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Guarded") }.execute(db) + } + try await undoManager.undo() + + let auditCount = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "audit_log""#) ?? 0 + } + #expect(auditCount == 0) + } + + @Test func undoEventEmittedAfterUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + var iterator = undoManager.events.makeAsyncIterator() + + try await undoManager.withGroup("Insert event") { db in + _ = try Item.insert { Item.Draft(title: "Event item") }.execute(db) + } + try await undoManager.undo() + + let event = await iterator.next() + #expect(event?.kind == .undo) + #expect(event?.group.description == "Insert event") + #expect(event?.ids(for: Item.self) == [1]) + } + + @Test func noOpGroupIsReconciledAway() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert then delete") { db in + try db.execute(sql: #"INSERT INTO "items" ("title") VALUES ('Temp')"#) + let id = db.lastInsertedRowID + try db.execute(sql: #"DELETE FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + + #expect(undoManager.undoStack.isEmpty) + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(count == 0) + } + + @Test func undoDeleteWithCascadeRestoresParentAndChild() async throws { + let db = try DatabaseQueue() + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ) + try db.execute( + sql: """ + CREATE TABLE "children" ( + "id" INTEGER PRIMARY KEY, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE CASCADE, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ) + } + let undoManager = try UndoManager(for: db, tables: Parent.self, Child.self) + + try await withoutUndo { + try await db.write { db in + try db.execute(sql: #"INSERT INTO "parents" ("id","name") VALUES (1,'P')"#) + try db.execute(sql: #"INSERT INTO "children" ("id","parentID","name") VALUES (1,1,'C')"#) + } + } + + try await undoManager.withGroup("Delete parent") { db in + try db.execute(sql: #"DELETE FROM "parents" WHERE "id" = 1"#) + } + + let deletedCounts = try await db.read { db in + ( + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "parents""#) ?? 0, + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "children""#) ?? 0 + ) + } + #expect(deletedCounts.0 == 0) + #expect(deletedCounts.1 == 0) + + try await undoManager.undo() + + let restoredCounts = try await db.read { db in + ( + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "parents""#) ?? 0, + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "children""#) ?? 0 + ) + } + #expect(restoredCounts.0 == 1) + #expect(restoredCounts.1 == 1) + } + + @Test func warnsOnUnexpectedTrackedTableNames() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "audits" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "message" TEXT NOT NULL DEFAULT '' + ) + """ + ) + let columns = try undoColumnNames(for: "audits", in: db) + for sql in undoTriggerSQL(for: "audits", columns: columns) { + try db.execute(sql: sql) + } + } + + try await withKnownIssue { + try await undoManager.withGroup("Mixed tracked tables") { db in + _ = try Item.insert { Item.Draft(title: "Item") }.execute(db) + _ = try Audit.insert { Audit.Draft(message: "Audit") }.execute(db) + } + } matching: { issue in + issue.description.contains("unexpected tables: audits") + } + } + + @Test func delegateCancel() async throws { + final class CancelDelegate: UndoManagerDelegate { + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + // Intentionally do NOT call performAction — cancel the undo. + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = CancelDelegate() + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Persistent") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + // Stack unchanged; row still present. + #expect(undoManager.undoStack.count == 1) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + // 9. The delegate receives metadata matching what was passed to withGroup. + @Test func delegateReceivesMetadata() async throws { + actor MetadataCapture { + var capturedGroup: UndoGroup? + func capture(_ group: UndoGroup) { capturedGroup = group } + } + let capture = MetadataCapture() + + final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: MetadataCapture + init(_ capture: MetadataCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.capture(group) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = MetadataDelegate(capture) + let undoManager = try UndoManager( + for: db, + tables: Item.self, + deviceID: "test-device", + delegate: delegate + ) + + try await undoManager.withGroup("My operation") { db in + _ = try Item.insert { Item.Draft(title: "Hi") }.execute(db) + } + try await undoManager.undo() + + let group = await capture.capturedGroup + #expect(group?.description == "My operation") + #expect(group?.deviceID == "test-device") + } + + // 10. The delegate receives `.undo` for undo and `.redo` for redo. + @Test func delegateActionType() async throws { + actor ActionCapture { + var actions: [UndoAction] = [] + func append(_ action: UndoAction) { actions.append(action) } + } + let capture = ActionCapture() + + final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: ActionCapture + init(_ capture: ActionCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.append(action) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = ActionDelegate(capture) + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Z") }.execute(db) + } + try await undoManager.undo() + try await undoManager.redo() + + let actions = await capture.actions + #expect(actions == [.undo, .redo]) + } + + // 11. The description from withGroup appears in undoStack. + @Test func undoDescriptionRoundtrip() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Delete all items") { db in + _ = try Item.insert { Item.Draft(title: "Temp") }.execute(db) + } + + #expect(undoManager.undoStack.first?.description == "Delete all items") + } + + // 12. Nested freeze calls require matching unfreeze calls before recording resumes. + @Test func nestedFreezeRequiresMatchingUnfreeze() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.freeze() + try await undoManager.freeze() + + try await undoManager.withGroup("Frozen A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.unfreeze() + + try await undoManager.withGroup("Frozen B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + + #expect(!undoManager.canUndo) + + try await undoManager.unfreeze() + + try await undoManager.withGroup("Tracked C") { db in + _ = try Item.insert { Item.Draft(title: "C") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let titles = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titles == ["A", "B"]) + } + + // 13. Undo/redo round-trips updates containing SQL-sensitive quoting characters. + @Test func updateUndoRedoQuotedText() async throws { + let db = try DatabaseQueue.undoDatabase() + let id = try await db.write { db in + try db.execute(sql: #"INSERT INTO "items" ("title") VALUES (?)"#, arguments: ["Before"]) + return db.lastInsertedRowID + } + let undoManager = try UndoManager(for: db, tables: Item.self) + let updatedTitle = #"O'Reilly "Book""# + + try await undoManager.withGroup("Quoted update") { db in + try db.execute( + sql: #"UPDATE "items" SET "title" = ? WHERE "id" = ?"#, + arguments: [updatedTitle, id] + ) + } + + let titleAfterUpdate = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterUpdate == updatedTitle) + + try await undoManager.undo() + let titleAfterUndo = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterUndo == "Before") + + try await undoManager.redo() + let titleAfterRedo = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterRedo == updatedTitle) + } + + // 14. Deleting rows with NULL values can be undone/redone correctly. + @Test func deleteUndoRedoNullColumn() async throws { + let db = try DatabaseQueue() + try await db.write { db in + try db.execute(sql: #"CREATE TABLE "notes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT)"#) + } + let id = try await db.write { db in + try db.execute(sql: #"INSERT INTO "notes" ("body") VALUES (NULL)"#) + return db.lastInsertedRowID + } + let undoManager = try UndoManager(for: db, tables: Note.self) + + try await undoManager.withGroup("Delete null row") { db in + try db.execute(sql: #"DELETE FROM "notes" WHERE "id" = ?"#, arguments: [id]) + } + + let countAfterDelete = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + #expect(countAfterDelete == 0) + + try await undoManager.undo() + let countAfterUndo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + let restoredIsNull = try await db.read { db in + try Int.fetchOne( + db, + sql: #"SELECT "body" IS NULL FROM "notes" WHERE "id" = ?"#, + arguments: [id] + ) ?? 0 + } + #expect(countAfterUndo == 1) + #expect(restoredIsNull == 1) + + try await undoManager.redo() + let countAfterRedo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + #expect(countAfterRedo == 0) + } + + #if canImport(ObjectiveC) + @Test func foundationUndoBridgeRoundTrip() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + sqliteUndoManager.bind(to: foundationUndoManager) + + try await sqliteUndoManager.withGroup("Insert via bridge") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await waitUntil { + await MainActor.run { foundationUndoManager.canUndo } + } + #expect(await MainActor.run { foundationUndoManager.canUndo }) + + await MainActor.run { foundationUndoManager.undo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 0 && sqliteUndoManager.canRedo + } + + let countAfterUndo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterUndo == 0) + #expect(sqliteUndoManager.canRedo) + #expect(await MainActor.run { foundationUndoManager.canRedo }) + + await MainActor.run { foundationUndoManager.redo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 1 && sqliteUndoManager.canUndo + } + + let countAfterRedo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterRedo == 1) + #expect(sqliteUndoManager.canUndo) + } + + @Test func foundationUndoBridgeUnboundFallback() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + + try await sqliteUndoManager.withGroup("Standalone insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(sqliteUndoManager.canUndo) + #expect(!(await MainActor.run { foundationUndoManager.canUndo })) + } + + private func waitUntil( + _ condition: @escaping @Sendable () async throws -> Bool + ) async throws { + for _ in 0..<200 { + if try await condition() { + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(Bool(false)) + } + #endif + + #if canImport(CloudKit) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWrappedByUserDatabaseIsUndoable() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + #expect(undoManager.undoStack.count == 1) + #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) + #expect(undoManager.undoStack.first?.userRecordName == zoneID.ownerName) + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWithoutUndoManagerStillWorks() async throws { + try await withDependencies { + $0.defaultUndoManager = nil + } operation: { + let db = try DatabaseQueue.undoDatabase() + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + } + #endif +} From 2da7fa741b2d0db5ca78a849965960666ccc419c Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Sun, 22 Feb 2026 01:06:13 +0100 Subject: [PATCH 2/6] Fix tests and warnings --- Sources/SQLiteData/Undo/UndoManager.swift | 6 +++--- Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 73b255bc..266c91a4 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -272,7 +272,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { return activeBarrier.barrier } let summary = try database.write { db -> (maxSeq: Int, modifiedTables: Set)? in - guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + guard var maxSeq = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq, maxSeq >= barrier.firstLog else { return nil @@ -301,7 +301,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { return activeBarrier.barrier } let summary = try await database.write { db -> (maxSeq: Int, modifiedTables: Set)? in - guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + guard var maxSeq = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq, maxSeq >= barrier.firstLog else { return nil @@ -576,7 +576,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // The triggers fired during `applyInverse` will have added new rows to the log. let newEnd = try await database.write { db -> Int in - guard var newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq else { return 0 } + guard var newEnd = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq else { return 0 } if newEnd >= firstLog { try undoReconcileEntries(in: db, from: firstLog, to: newEnd) newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index ff593ebe..54a3f018 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -23,11 +23,6 @@ import Testing var message: String } -@Table("parents") private struct Parent: Equatable, Identifiable { - let id: Int - var name: String -} - @Table("children") private struct Child: Equatable, Identifiable { let id: Int var parentID: Int From cb003b0d88dcf3b9e31eb39bf550049bba7ec12b Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Mon, 23 Feb 2026 23:18:20 +0100 Subject: [PATCH 3/6] Refactor undo SQL execution Use #sql macro-based execution/fetch paths in undo setup, replay, and trigger helpers for safer SQL handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Undo/Internal/UndoTriggers.swift | 51 ++++++++++--------- Sources/SQLiteData/Undo/UndoManager.swift | 8 +-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index cf2faaf9..b0418454 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -1,5 +1,6 @@ import Foundation import GRDB +import StructuredQueriesCore // MARK: - Column info @@ -13,22 +14,24 @@ package func undoColumnNames(for tableName: String, in db: Database) throws -> [ // parameters in table-valued-function arguments. let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" do { - return try String.fetchAll( - db, - sql: """ - SELECT name FROM pragma_table_xinfo(\(tableLiteral)) + return try #sql( + """ + SELECT name FROM pragma_table_xinfo(\(raw: tableLiteral)) WHERE hidden = 0 ORDER BY cid - """ - ) + """, + as: String.self + ) + .fetchAll(db) } catch { - return try String.fetchAll( - db, - sql: """ - SELECT name FROM pragma_table_info(\(tableLiteral)) - ORDER BY cid + return try #sql( """ - ) + SELECT name FROM pragma_table_info(\(raw: tableLiteral)) + ORDER BY cid + """, + as: String.self + ) + .fetchAll(db) } } @@ -134,17 +137,14 @@ package func undoTriggerDropSQL(for tableName: String) -> [String] { // MARK: - Undo log analysis package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { - Set( - try String.fetchAll( - db, - sql: """ - SELECT DISTINCT "tableName" - FROM "sqlitedata_undo_log" - WHERE "seq" >= ? AND "seq" <= ? - """, - arguments: [startSeq, endSeq] - ) - ) + Set(try #sql( + """ + SELECT DISTINCT "tableName" + FROM "sqlitedata_undo_log" + WHERE "seq" >= \(startSeq) AND "seq" <= \(endSeq) + """, + as: String.self + ).fetchAll(db)) } package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { @@ -185,7 +185,10 @@ package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq guard !seqsToDelete.isEmpty else { return } let sqlList = seqsToDelete.map(String.init).joined(separator: ",") - try db.execute(sql: "DELETE FROM \"sqlitedata_undo_log\" WHERE \"seq\" IN (\(sqlList))") + try #sql( + #"DELETE FROM "sqlitedata_undo_log" WHERE "seq" IN (\#(raw: sqlList))"# + ) + .execute(db) } // MARK: - Helpers diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 266c91a4..177c5f7c 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -167,14 +167,14 @@ public final class UndoManager: Perceptible, @unchecked Sendable { db.add(function: $_shouldRecord) db.add(function: $_isReplaying) - try db.execute(sql: undoLogTableSQL) + try #sql("\(raw: undoLogTableSQL)").execute(db) for table in repeat each tables { let tableName = table.tableName let columns = try undoColumnNames(for: tableName, in: db) guard !columns.isEmpty else { continue } for sql in undoTriggerSQL(for: tableName, columns: columns) { - try db.execute(sql: sql) + try #sql("\(raw: sql)").execute(db) } } } @@ -564,11 +564,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // Replayed statements can include child-before-parent row restoration from cascading // deletes. Deferring FK checks until commit lets the full inverse set restore first. - try db.execute(sql: "PRAGMA defer_foreign_keys = ON") + try #sql("PRAGMA defer_foreign_keys = ON").execute(db) // Execute each inverse SQL statement in order. for row in rows { - try db.execute(sql: row.sql) + try #sql("\(raw: row.sql)").execute(db) } return affectedRows } From f1b5883b7d461efee1cbfeaa50662efae7a70c95 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Mon, 23 Feb 2026 23:36:22 +0100 Subject: [PATCH 4/6] Refactor undo trigger SQL with StructuredQueries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Undo/Internal/UndoTriggers.swift | 255 +++++++++--------- Sources/SQLiteData/Undo/UndoManager.swift | 7 +- .../UndoTests/UndoManagerTests.swift | 5 +- 3 files changed, 128 insertions(+), 139 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index b0418454..e7348373 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -2,39 +2,6 @@ import Foundation import GRDB import StructuredQueriesCore -// MARK: - Column info - -/// Reads writable column names for `tableName`. -/// -/// Prefer `pragma_table_xinfo` so hidden/generated columns can be filtered with `hidden = 0`. -/// Fall back to `pragma_table_info` on older SQLite builds where `table_xinfo` is unavailable. -package func undoColumnNames(for tableName: String, in db: Database) throws -> [String] { - // Use pragma table-valued functions. The table name is embedded as a quoted - // SQL identifier, not as a bound parameter, because some SQLite versions do not support bound - // parameters in table-valued-function arguments. - let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" - do { - return try #sql( - """ - SELECT name FROM pragma_table_xinfo(\(raw: tableLiteral)) - WHERE hidden = 0 - ORDER BY cid - """, - as: String.self - ) - .fetchAll(db) - } catch { - return try #sql( - """ - SELECT name FROM pragma_table_info(\(raw: tableLiteral)) - ORDER BY cid - """, - as: String.self - ) - .fetchAll(db) - } -} - // MARK: - Undo log table /// The DDL that creates the per-connection temporary undo log table. @@ -47,104 +14,135 @@ package let undoLogTableSQL = """ ) """ -// MARK: - Trigger SQL - -/// Generates the three undo triggers for a single table. -/// -/// - Parameters: -/// - tableName: The name of the user table to observe. -/// - columns: The writable column names obtained from `undoColumnNames(for:in:)`. -/// - Returns: Three `CREATE TEMP TRIGGER` statements (insert, update, delete). -package func undoTriggerSQL(for tableName: String, columns: [String]) -> [String] { - let qt: String = undoDoubleQuotedIdentifier(tableName) - let logTable = "\"sqlitedata_undo_log\"" - let whenClause = "WHEN sqlitedata_undo_shouldRecord()" - let triggerPrefix = "_sqlitedata_undo_" - - // INSERT → log a DELETE that removes the new row - let insertTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)insert_\(tableName)")) - AFTER INSERT ON \(qt) - \(whenClause) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - NEW.rowid, - 'DELETE FROM \(qt) WHERE rowid='||NEW.rowid - ); - END - """ - - // UPDATE → log an UPDATE that restores all old column values - // Only fire when at least one column actually changed. - let changedCondition: String = columns - .map { col -> String in - let qc: String = undoDoubleQuotedIdentifier(col) - return "OLD.\(qc) IS NOT NEW.\(qc)" - } - .joined(separator: " OR ") - let setClause: String = columns - .map { col -> String in - let qc: String = undoDoubleQuotedIdentifier(col) - return "\(qc)='||quote(OLD.\(qc))||'" - } - .joined(separator: ",") - let updateTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)update_\(tableName)")) - BEFORE UPDATE ON \(qt) - WHEN \(whenClause.dropFirst("WHEN ".count)) AND (\(changedCondition)) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - OLD.rowid, - 'UPDATE \(qt) SET \(setClause) WHERE rowid='||OLD.rowid - ); - END - """ - - // DELETE → log an INSERT that restores the deleted row - let colList: String = columns.map { undoDoubleQuotedIdentifier($0) }.joined(separator: ",") - let valList: String = columns - .map { col -> String in "'||quote(OLD.\(undoDoubleQuotedIdentifier(col)))||'" } - .joined(separator: ",") - let deleteTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)delete_\(tableName)")) - BEFORE DELETE ON \(qt) - \(whenClause) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - OLD.rowid, - 'INSERT INTO \(qt)(rowid,\(colList)) VALUES('||OLD.rowid||',\(valList))' - ); - END - """ - - return [insertTrigger, updateTrigger, deleteTrigger] -} +// MARK: - Trigger installation + +extension PrimaryKeyedTable { + package static func installUndoTriggers(in db: Database) throws { + guard !undoWritableColumnNames.isEmpty else { return } + try undoInsertTrigger.execute(db) + try undoUpdateTrigger.execute(db) + try undoDeleteTrigger.execute(db) + } + + fileprivate static var undoInsertTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_insert_\(tableName)", + ifNotExists: true, + after: .insert { new in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + new.rowid, + #sql( + "'DELETE FROM \(raw: undoQuotedTableName) WHERE rowid=' || \(new.rowid)", + as: String.self + ) + ) + } + } when: { _ in + $_shouldRecord() + } + ) + } + + fileprivate static var undoUpdateTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_update_\(tableName)", + ifNotExists: true, + before: .update { old, _ in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + old.rowid, + #sql( + "'UPDATE \(raw: undoQuotedTableName) SET \(raw: undoSetClause) WHERE rowid=' || \(old.rowid)", + as: String.self + ) + ) + } + } when: { _, _ in + $_shouldRecord() && #sql("\(raw: undoChangedCondition)", as: Bool.self) + } + ) + } + + fileprivate static var undoDeleteTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_delete_\(tableName)", + ifNotExists: true, + before: .delete { old in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + old.rowid, + #sql( + "'INSERT INTO \(raw: undoQuotedTableName)(rowid,\(raw: undoColumnList)) VALUES(' || \(old.rowid) || ',\(raw: undoValueList))'", + as: String.self + ) + ) + } + } when: { _ in + $_shouldRecord() + } + ) + } + + fileprivate static var undoWritableColumnNames: [String] { + Self.TableColumns.writableColumns.map(\.name) + } -/// Drop SQL for the three undo triggers of a table. -package func undoTriggerDropSQL(for tableName: String) -> [String] { - let prefix = "_sqlitedata_undo_" - return ["insert", "update", "delete"].map { kind in - "DROP TEMP TRIGGER IF EXISTS \(undoDoubleQuotedIdentifier("\(prefix)\(kind)_\(tableName)"))" + fileprivate static var undoQuotedTableName: String { + undoDoubleQuotedIdentifier(tableName) + } + + fileprivate static var undoChangedCondition: String { + undoWritableColumnNames + .map { column in + let columnIdentifier = undoDoubleQuotedIdentifier(column) + return "OLD.\(columnIdentifier) IS NOT NEW.\(columnIdentifier)" + } + .joined(separator: " OR ") + } + + fileprivate static var undoSetClause: String { + undoWritableColumnNames + .map { column in + let columnIdentifier = undoDoubleQuotedIdentifier(column) + return "\(columnIdentifier)='||quote(OLD.\(columnIdentifier))||'" + } + .joined(separator: ",") + } + + fileprivate static var undoColumnList: String { + undoWritableColumnNames + .map(undoDoubleQuotedIdentifier) + .joined(separator: ",") + } + + fileprivate static var undoValueList: String { + undoWritableColumnNames + .map { column in + "'||quote(OLD.\(undoDoubleQuotedIdentifier(column)))||'" + } + .joined(separator: ",") } } // MARK: - Undo log analysis package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { - Set(try #sql( - """ - SELECT DISTINCT "tableName" - FROM "sqlitedata_undo_log" - WHERE "seq" >= \(startSeq) AND "seq" <= \(endSeq) - """, - as: String.self - ).fetchAll(db)) + Set( + try UndoLog + .where { $0.seq >= startSeq && $0.seq <= endSeq } + .select(\.tableName) + .fetchAll(db) + ) } package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { @@ -184,11 +182,10 @@ package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq } guard !seqsToDelete.isEmpty else { return } - let sqlList = seqsToDelete.map(String.init).joined(separator: ",") - try #sql( - #"DELETE FROM "sqlitedata_undo_log" WHERE "seq" IN (\#(raw: sqlList))"# - ) - .execute(db) + try UndoLog + .where { $0.seq.in(seqsToDelete) } + .delete() + .execute(db) } // MARK: - Helpers diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 177c5f7c..be2fc19c 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -170,12 +170,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { try #sql("\(raw: undoLogTableSQL)").execute(db) for table in repeat each tables { - let tableName = table.tableName - let columns = try undoColumnNames(for: tableName, in: db) - guard !columns.isEmpty else { continue } - for sql in undoTriggerSQL(for: tableName, columns: columns) { - try #sql("\(raw: sql)").execute(db) - } + try table.installUndoTriggers(in: db) } } diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 54a3f018..7822cd3d 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -382,10 +382,7 @@ extension DatabaseWriter where Self == DatabaseQueue { ) """ ) - let columns = try undoColumnNames(for: "audits", in: db) - for sql in undoTriggerSQL(for: "audits", columns: columns) { - try db.execute(sql: sql) - } + try Audit.installUndoTriggers(in: db) } try await withKnownIssue { From 7747e3a4f8c41d610c6897f40fd6a83f2f1c60cf Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:07:08 +0100 Subject: [PATCH 5/6] Add LocalizedStringKey undo overloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/ReminderForm.swift | 7 +- Examples/Reminders/ReminderRow.swift | 10 +- Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersListForm.swift | 4 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 21 ++- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 8 +- .../SQLiteData/Undo/DatabaseWriter+Undo.swift | 73 +++++++++ Sources/SQLiteData/Undo/UndoManager.swift | 152 ++++++++++++++++++ 11 files changed, 267 insertions(+), 16 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 8e34af64..2dbb4ae5 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -169,8 +169,11 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { - try database.writeWithUndoGroup(reminder.id == nil ? "Create reminder" : "Edit reminder") { - db in + try database.writeWithUndoGroup( + reminder.id == nil + ? LocalizedStringKey("Create reminder") + : LocalizedStringKey("Edit reminder") + ) { db in let reminderID = try Reminder.upsert { reminder } .returning(\.id) .fetchOne(db)! diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 9e3b3567..02f6f6f0 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -76,7 +76,7 @@ struct ReminderRow: View { .swipeActions { Button("Delete", role: .destructive) { withErrorReporting { - try database.writeWithUndoGroup("Delete reminder") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete reminder")) { db in try Reminder.delete(reminder).execute(db) } } @@ -84,7 +84,9 @@ struct ReminderRow: View { Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { try database.writeWithUndoGroup( - reminder.isFlagged ? "Unflag reminder" : "Flag reminder" + reminder.isFlagged + ? LocalizedStringKey("Unflag reminder") + : LocalizedStringKey("Flag reminder") ) { db in try Reminder .find(reminder.id) @@ -109,7 +111,9 @@ struct ReminderRow: View { private func completeButtonTapped() { withErrorReporting { try database.writeWithUndoGroup( - reminder.isCompleted ? "Mark reminder incomplete" : "Mark reminder complete" + reminder.isCompleted + ? LocalizedStringKey("Mark reminder incomplete") + : LocalizedStringKey("Mark reminder complete") ) { db in try Reminder .find(reminder.id) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index a8a095a0..8ac468bd 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject { func move(from source: IndexSet, to destination: Int) async { withErrorReporting { - try database.writeWithUndoGroup("Reorder reminders") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Reorder reminders")) { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 44dc7df8..c635b614 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -78,7 +78,9 @@ struct RemindersListForm: View { Task { [remindersList, coverImageData] in await withErrorReporting { try await database.writeWithUndoGroup( - remindersList.id == nil ? "Create list" : "Edit list" + remindersList.id == nil + ? LocalizedStringKey("Create list") + : LocalizedStringKey("Edit list") ) { db in let remindersListID = try RemindersList diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 8f4e6cbf..0786c3e2 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -35,7 +35,7 @@ struct RemindersListRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup("Delete list") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete list")) { db in try RemindersList.delete(remindersList) .execute(db) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 4a3d9ece..788b40f0 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -85,7 +85,7 @@ class RemindersListsModel { func deleteTags(atOffsets offsets: IndexSet) { withErrorReporting { let tagTitles = offsets.map { tags[$0].title } - try database.writeWithUndoGroup("Delete tags") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tags")) { db in try Tag .where { $0.title.in(tagTitles) } .delete() @@ -126,7 +126,7 @@ class RemindersListsModel { func move(from source: IndexSet, to destination: Int) { withErrorReporting { - try database.writeWithUndoGroup("Reorder lists") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Reorder lists")) { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList @@ -148,8 +148,21 @@ class RemindersListsModel { #if DEBUG func seedDatabaseButtonTapped() { - withErrorReporting { - try database.seedSampleData() + Task { + await withErrorReporting { + if let undoManager { + try await undoManager.freeze() + do { + try database.seedSampleData() + try await undoManager.unfreeze() + } catch { + try await undoManager.unfreeze() + throw error + } + } else { + try database.seedSampleData() + } + } } } #endif diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 0fce00c6..b9a8a6fd 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -64,7 +64,7 @@ class SearchRemindersModel { func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { - try database.writeWithUndoGroup("Clear completed reminders") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Clear completed reminders")) { db in try Reminder .where { $0.isCompleted diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 32444e5a..adb801b2 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -18,7 +18,7 @@ struct TagRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup("Delete tag") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in try Tag.delete(tag) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 989c52dc..7dbc9df9 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -80,7 +80,7 @@ struct TagsView: View { func deleteButtonTapped(tag: Tag) { withErrorReporting { - try database.writeWithUndoGroup("Delete tag") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in try Tag.find(tag.title).delete().execute(db) } } @@ -95,7 +95,11 @@ struct TagsView: View { defer { tagTitle = "" } let tag = Tag(title: tagTitle) withErrorReporting { - try database.writeWithUndoGroup(editingTag == nil ? "Create tag" : "Edit tag") { db in + try database.writeWithUndoGroup( + editingTag == nil + ? LocalizedStringKey("Create tag") + : LocalizedStringKey("Edit tag") + ) { db in if let existingTagTitle = editingTag?.title { selectedTags.removeAll(where: { $0.title == existingTagTitle }) try Tag diff --git a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift index 4735a3ee..0db10bfa 100644 --- a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift +++ b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift @@ -1,4 +1,43 @@ +import Foundation +#if canImport(SwiftUI) + import SwiftUI +#endif + public extension DatabaseWriter { + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + func writeWithUndoGroup( + _ description: LocalizedStringKey, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + #endif + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @_disfavoredOverload + func writeWithUndoGroup( + _ description: LocalizedStringResource, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + + @_disfavoredOverload func writeWithUndoGroup( _ description: String, _ updates: (Database) throws -> T @@ -13,6 +52,40 @@ public extension DatabaseWriter { return try write(updates) } + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + func writeWithUndoGroup( + _ description: LocalizedStringKey, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } + #endif + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + func writeWithUndoGroup( + _ description: LocalizedStringResource, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } + + @_disfavoredOverload func writeWithUndoGroup( _ description: String, _ updates: @Sendable (Database) throws -> T diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index be2fc19c..d50c4c4d 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -6,6 +6,9 @@ import Perception #if canImport(Observation) import Observation #endif +#if canImport(SwiftUI) + import SwiftUI +#endif import StructuredQueriesCore #if canImport(UIKit) @@ -230,9 +233,47 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // MARK: - Group recording + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// This overload accepts a localized resource so group names can participate in localization. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func beginBarrier( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + try beginBarrier( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName + ) + } + + #if canImport(SwiftUI) + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// This overload accepts a localized key and resolves it using the app's main bundle. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func beginBarrier( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + try beginBarrier( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName + ) + } + #endif + /// Begins recording a barrier that can later be ended or cancelled. /// /// Use this API when an undoable action spans multiple writes or async boundaries. + @_disfavoredOverload @discardableResult public func beginBarrier( _ description: String, @@ -366,6 +407,66 @@ public final class UndoManager: Perceptible, @unchecked Sendable { /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. /// - body: A closure that performs database writes. Receives a `Database` connection. /// - Returns: The value returned by `body`. + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func withGroup( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + try await withGroup( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + #endif + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func withGroup( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + try await withGroup( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @_disfavoredOverload @discardableResult public func withGroup( _ description: String, @@ -391,6 +492,44 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func withGroup( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + try withGroup( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + #endif + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func withGroup( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + try withGroup( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @_disfavoredOverload @discardableResult public func withGroup( _ description: String, @@ -655,6 +794,19 @@ public final class UndoManager: Perceptible, @unchecked Sendable { #endif } +#if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + private extension LocalizedStringKey { + var undoGroupKeyString: String { + Mirror(reflecting: self) + .children + .first { $0.label == "key" } + .flatMap { $0.value as? String } + ?? String(describing: self) + } + } +#endif + #if canImport(Observation) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension UndoManager: Observable {} From e61a14ac7927b97c0b64518fa05770b265136520 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:11:04 +0100 Subject: [PATCH 6/6] Fix temp trigger undo log writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SQLiteData/Undo/Internal/UndoTriggers.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index e7348373..5518ff59 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -4,6 +4,15 @@ import StructuredQueriesCore // MARK: - Undo log table +/// Trigger-local mapping to avoid qualifying temp table names inside trigger DML. +@Table("sqlitedata_undo_log") +private struct TriggerUndoLog { + let seq: Int + let tableName: String + let trackedRowID: Int + let sql: String +} + /// The DDL that creates the per-connection temporary undo log table. package let undoLogTableSQL = """ CREATE TEMP TABLE IF NOT EXISTS "sqlitedata_undo_log" ( @@ -29,7 +38,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_insert_\(tableName)", ifNotExists: true, after: .insert { new in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values( @@ -52,7 +61,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_update_\(tableName)", ifNotExists: true, before: .update { old, _ in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values( @@ -75,7 +84,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_delete_\(tableName)", ifNotExists: true, before: .delete { old in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values(