diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 51130fc..f95774c 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -77,10 +77,34 @@ extension Database { // The undo log already contains all effects (including cascades), // so replaying them individually is sufficient. try $_undoIsReplaying.withValue(true) { + try #sql("PRAGMA defer_foreign_keys = ON").execute(self) for entry in entries { logger.trace("Executing SQL: \(entry.sql)") try #sql("\(raw: entry.sql)").execute(self) } +#if DEBUG + // Check for FK violations that will cause the commit to fail. + let violations = try #sql( + """ + SELECT "table" || ' rowid=' || rowid || ' parent=' || "parent" || ' fkid=' || fkid + FROM pragma_foreign_key_check + """, + as: String.self + ).fetchAll(self) + if !violations.isEmpty { + logger.error( + """ + Undo replay will fail due to foreign key violations + + Ensure all tables involved in foreign key relationships are undo-tracked, + and that undo-tracked tables do not have foreign keys to non-tracked tables. + """ + ) + for v in violations { + logger.error(" FK violation after undo replay: \(v)") + } + } +#endif } // Get new seq range for captured entries diff --git a/Tests/SQLiteUndoTests/ForeignKeyTests.swift b/Tests/SQLiteUndoTests/ForeignKeyTests.swift new file mode 100644 index 0000000..7ab5e1c --- /dev/null +++ b/Tests/SQLiteUndoTests/ForeignKeyTests.swift @@ -0,0 +1,232 @@ +import Foundation +import SQLiteData +import StructuredQueries +import Testing + +@testable import SQLiteUndo + +@Suite(.serialized) +struct ForeignKeyTests { + + @Test + func undoDeleteChildThenParent() throws { + let (database, engine) = try makeFKDatabase() + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + INSERT INTO "parents" ("id", "name") VALUES (1, 'Parent'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (1, 1, 'Child'); + """) + } + } + + let barrierId = try engine.beginBarrier("Delete Both") + try database.write { db in + try db.execute(sql: """ + DELETE FROM "children" WHERE "id" = 1; + DELETE FROM "parents" WHERE "id" = 1; + """) + } + let barrier = try engine.endBarrier(barrierId)! + + try engine.performUndo(barrier: barrier) + + let (parentCount, childCount) = try database.read { db in + ( + try Parent.all.fetchCount(db), + try Child.all.fetchCount(db) + ) + } + #expect(parentCount == 1) + #expect(childCount == 1) + } + + @Test + func undoDeleteParentCascade() throws { + let (database, engine) = try makeFKDatabase() + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + INSERT INTO "parents" ("id", "name") VALUES (1, 'Parent'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (1, 1, 'Child'); + """) + } + } + + let barrierId = try engine.beginBarrier("Delete Parent") + try database.write { db in + try db.execute(sql: """ + DELETE FROM "parents" WHERE "id" = 1 + """) + } + let barrier = try engine.endBarrier(barrierId)! + + try engine.performUndo(barrier: barrier) + + let (parentCount, childCount) = try database.read { db in + ( + try Parent.all.fetchCount(db), + try Child.all.fetchCount(db) + ) + } + #expect(parentCount == 1) + #expect(childCount == 1) + } + + @Test + func undoCascadeDeleteMultipleChildren() throws { + let (database, engine) = try makeFKDatabase() + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + INSERT INTO "parents" ("id", "name") VALUES (1, 'Parent'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (1, 1, 'Child A'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (2, 1, 'Child B'); + """) + } + } + + let barrierId = try engine.beginBarrier("Delete Parent") + try database.write { db in + try db.execute(sql: """ + DELETE FROM "parents" WHERE "id" = 1 + """) + } + let barrier = try engine.endBarrier(barrierId)! + + let counts = try database.read { db in + (try Parent.all.fetchCount(db), try Child.all.fetchCount(db)) + } + #expect(counts.0 == 0) + #expect(counts.1 == 0) + + try engine.performUndo(barrier: barrier) + + let restored = try database.read { db in + ( + try Parent.all.fetchCount(db), + try Child.all.fetchCount(db), + try Child.all.order(by: \.id).fetchAll(db) + ) + } + #expect(restored.0 == 1) + #expect(restored.1 == 2) + #expect(restored.2.map(\.name) == ["Child A", "Child B"]) + } + + @Test + func undoRedoCascadeRoundTrip() throws { + let (database, engine) = try makeFKDatabase() + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + INSERT INTO "parents" ("id", "name") VALUES (1, 'Parent'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (1, 1, 'Child'); + """) + } + } + + let barrierId = try engine.beginBarrier("Delete Parent") + try database.write { db in + try db.execute(sql: """ + DELETE FROM "parents" WHERE "id" = 1 + """) + } + let barrier = try engine.endBarrier(barrierId)! + + // Undo — restore parent and child + try engine.performUndo(barrier: barrier) + var counts = try database.read { db in + (try Parent.all.fetchCount(db), try Child.all.fetchCount(db)) + } + #expect(counts == (1, 1)) + + // Redo — delete again + try engine.performRedo(barrier: barrier) + counts = try database.read { db in + (try Parent.all.fetchCount(db), try Child.all.fetchCount(db)) + } + #expect(counts == (0, 0)) + + // Undo again — restore once more + try engine.performUndo(barrier: barrier) + counts = try database.read { db in + (try Parent.all.fetchCount(db), try Child.all.fetchCount(db)) + } + #expect(counts == (1, 1)) + } + + @Test + func cascadeCaptureRespectsUndoDisabled() throws { + let (database, _) = try makeFKDatabase() + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + INSERT INTO "parents" ("id", "name") VALUES (1, 'Parent'); + INSERT INTO "children" ("id", "parentId", "name") VALUES (1, 1, 'Child'); + """) + } + } + + try withUndoDisabled { + try database.write { db in + try db.execute(sql: """ + DELETE FROM "parents" WHERE "id" = 1 + """) + } + } + + let undoLogCount = try database.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM undolog") + } + #expect(undoLogCount == 0) + } +} + +@Table("parents") +private struct Parent: Identifiable { + @Column(primaryKey: true) var id: Int + var name: String = "" +} + +@Table("children") +private struct Child: Identifiable { + @Column(primaryKey: true) var id: Int + var parentId: Int + var name: String = "" +} + +private func makeFKDatabase() throws -> (any DatabaseWriter, UndoCoordinator) { + var config = Configuration() + config.prepareDatabase { db in + try db.execute(sql: "PRAGMA foreign_keys = ON") + } + let database = try DatabaseQueue(configuration: config) + + try database.write { db in + try db.execute(sql: """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE "children" ( + "id" INTEGER PRIMARY KEY, + "parentId" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE CASCADE, + "name" TEXT NOT NULL DEFAULT '' + ); + """) + } + + try database.installUndoSystem() + try database.write { db in + try Parent.installUndoTriggers(db) + try Child.installUndoTriggers(db) + } + + return (database, UndoCoordinator(database: database)) +}