diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 9ba6dc7..26e1323 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" : "feff4b00db479b7ae15f9966241149072fa181f799b20ad15f8e23f3139b2b1d",
+ "originHash" : "09bc66dffe4811b4627872585613011627e6f6341a227183a35983c8f373e434",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -109,6 +109,15 @@
"version" : "2.6.0"
}
},
+ {
+ "identity" : "swift-parsing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-parsing",
+ "state" : {
+ "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b",
+ "version" : "0.14.1"
+ }
+ },
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
diff --git a/Package.resolved b/Package.resolved
index 709853e..cbecb8a 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "17abbefe93c2934020907b9e1ef014ff80f9b91d3dfa62543e300908db7fb00b",
+ "originHash" : "5df21c4820606a0f3908478533dea9f2e659abf5a877660ce76321eead546594",
"pins" : [
{
"identity" : "combine-schedulers",
diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift
new file mode 100644
index 0000000..7eb2883
--- /dev/null
+++ b/Sources/SQLiteUndo/SQLParser.swift
@@ -0,0 +1,170 @@
+
+// MARK: - Tab-delimited parsing
+
+extension UndoSQL {
+ /// Parse a tab-delimited undo entry.
+ ///
+ /// Format: `TYPE\tTABLE\tROWID[\tCOL\tVAL]*`
+ /// - `D\t
\t` → delete
+ /// - `I\t\t\t\t...` → insert
+ /// - `U\t\t\t\t...` → update
+ init?(tabDelimited sql: String) {
+ let parts = sql.split(separator: "\t", omittingEmptySubsequences: false)
+ guard parts.count >= 3 else { return nil }
+
+ let table = String(parts[1])
+ let rowid = String(parts[2])
+
+ switch parts[0] {
+ case "D":
+ self = .delete(DeleteSQL(table: table, rowids: [rowid]))
+
+ case "I":
+ var columns: [String] = []
+ var values: [String] = []
+ var i = 3
+ while i + 1 < parts.count {
+ columns.append(String(parts[i]))
+ values.append(String(parts[i + 1]))
+ i += 2
+ }
+ self = .insert(InsertSQL(
+ table: table, columns: columns,
+ rows: [InsertSQL.Row(rowid: rowid, values: values)]))
+
+ case "U":
+ var assignments: [UpdateSQL.Assignment] = []
+ var i = 3
+ while i + 1 < parts.count {
+ assignments.append(UpdateSQL.Assignment(
+ column: String(parts[i]), value: String(parts[i + 1])))
+ i += 2
+ }
+ self = .update(UpdateSQL(table: table, assignments: assignments, rowids: [rowid]))
+
+ default:
+ return nil
+ }
+ }
+
+ /// Convert to tab-delimited storage format.
+ var tabDelimited: String {
+ switch self {
+ case let .delete(d):
+ return "D\t" + d.table + "\t" + d.rowids[0]
+ case let .insert(ins):
+ let row = ins.rows[0]
+ var sql = "I\t" + ins.table + "\t" + row.rowid
+ for (col, val) in zip(ins.columns, row.values) {
+ sql += "\t" + col + "\t" + val
+ }
+ return sql
+ case let .update(upd):
+ var sql = "U\t" + upd.table + "\t" + upd.rowids[0]
+ for a in upd.assignments {
+ sql += "\t" + a.column + "\t" + a.value
+ }
+ return sql
+ }
+ }
+
+ /// Generate executable SQL.
+ var executableSQL: String {
+ switch self {
+ case let .delete(d):
+ if d.rowids.count == 1 {
+ return "DELETE FROM \"\(d.table)\" WHERE rowid=\(d.rowids[0])"
+ }
+ return "DELETE FROM \"\(d.table)\" WHERE rowid IN (\(d.rowids.joined(separator: ",")))"
+
+ case let .insert(ins):
+ var sql = "INSERT INTO \""
+ sql += ins.table
+ sql += "\"("
+ if ins.columns.isEmpty {
+ sql += "rowid"
+ } else {
+ sql += "rowid,"
+ sql += ins.columns.map { "\"" + $0 + "\"" }.joined(separator: ",")
+ }
+ sql += ") VALUES"
+ for (i, row) in ins.rows.enumerated() {
+ if i > 0 { sql += "," }
+ sql += "("
+ sql += row.rowid
+ for val in row.values {
+ sql += ","
+ sql += val
+ }
+ sql += ")"
+ }
+ return sql
+
+ case let .update(upd):
+ let set = upd.assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",")
+ if upd.rowids.count == 1 {
+ return "UPDATE \"\(upd.table)\" SET \(set) WHERE rowid=\(upd.rowids[0])"
+ }
+ return "UPDATE \"\(upd.table)\" SET \(set) WHERE rowid IN (\(upd.rowids.joined(separator: ",")))"
+ }
+ }
+}
+
+// MARK: - SQL Batching
+
+/// Maximum entries per batch to stay within SQLite limits.
+private let maxBatchSize = 500
+
+/// When true, disables batching so each entry executes individually.
+/// Used for benchmarking to compare batched vs unbatched performance.
+nonisolated(unsafe) var _undoBatchingDisabled = false
+
+/// Groups consecutive same-key entries into batched SQL.
+/// Key: table for DELETE/INSERT, table+assignments for UPDATE.
+func batchedSQL(from entries: [UndoLogEntry]) -> [String] {
+ if _undoBatchingDisabled {
+ return entries.map(\.sql.executableSQL)
+ }
+
+ var remaining = entries[...]
+ var result: [String] = []
+
+ while let first = remaining.popFirst() {
+ var current = first.sql
+
+ // Merge consecutive same-key entries
+ while !remaining.isEmpty {
+ guard let merged = current.merging(remaining.first!.sql) else { break }
+ current = merged
+ remaining.removeFirst()
+ }
+
+ result.append(current.executableSQL)
+ }
+
+ return result
+}
+
+extension UndoSQL {
+ /// Merge with another UndoSQL if they share the same grouping key.
+ func merging(_ other: UndoSQL) -> UndoSQL? {
+ switch (self, other) {
+ case let (.delete(l), .delete(r)):
+ guard l.table == r.table, l.rowids.count + r.rowids.count <= maxBatchSize else { return nil }
+ return .delete(DeleteSQL(table: l.table, rowids: l.rowids + r.rowids))
+
+ case let (.insert(l), .insert(r)):
+ guard l.table == r.table, l.rows.count + r.rows.count <= maxBatchSize else { return nil }
+ return .insert(InsertSQL(table: l.table, columns: l.columns, rows: l.rows + r.rows))
+
+ case let (.update(l), .update(r)):
+ guard l.table == r.table, l.rowids.count + r.rowids.count <= maxBatchSize else { return nil }
+ guard l.assignments == r.assignments else { return nil }
+ return .update(UpdateSQL(
+ table: l.table, assignments: l.assignments, rowids: l.rowids + r.rowids))
+
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift
index 51130fc..27245d3 100644
--- a/Sources/SQLiteUndo/UndoOperations.swift
+++ b/Sources/SQLiteUndo/UndoOperations.swift
@@ -76,10 +76,11 @@ extension Database {
// Set isReplaying so app-level triggers suppress cascading writes.
// The undo log already contains all effects (including cascades),
// so replaying them individually is sufficient.
+ // Batch consecutive same-table, same-type entries for efficiency.
try $_undoIsReplaying.withValue(true) {
- for entry in entries {
- logger.trace("Executing SQL: \(entry.sql)")
- try #sql("\(raw: entry.sql)").execute(self)
+ for sql in batchedSQL(from: entries) {
+ logger.trace("Executing SQL: \(sql)")
+ try #sql("\(raw: sql)").execute(self)
}
}
@@ -87,8 +88,8 @@ extension Database {
let seqAfter = try undoLogMaxSeq() ?? seqBefore
if seqAfter > seqBefore {
let newRange = UndoCoordinator.SeqRange(startSeq: seqBefore + 1, endSeq: seqAfter)
- // Reconcile duplicates from BEFORE triggers firing during replay
- try reconcileUndoLogEntries(from: newRange.startSeq, to: newRange.endSeq)
+ // No reconciliation needed during replay: _undoIsReplaying suppresses
+ // app-level cascade triggers, so each row produces exactly one reverse entry.
logger.debug("New seq range: \(newRange.startSeq)...\(newRange.endSeq)")
return UndoRedoResult(seqRange: newRange, affectedItems: affectedItems)
}
@@ -158,6 +159,7 @@ extension Database {
}
var seqsToDelete: [Int] = []
+ var seqsToUpdate: [(seq: Int, sql: UndoSQL)] = []
for (_, group) in groups {
guard group.count > 1 else { continue }
@@ -165,31 +167,60 @@ extension Database {
let first = group[0]
let last = group[group.count - 1]
- let firstIsDeleteReverse = first.sql.hasPrefix("DELETE FROM")
- let lastIsInsertReverse = last.sql.hasPrefix("INSERT INTO")
-
- if firstIsDeleteReverse && lastIsInsertReverse {
+ if case .delete = first.sql, case .insert = last.sql {
// INSERT then DELETE in same barrier → no-op, remove all
for entry in group {
seqsToDelete.append(entry.seq)
}
- } else if firstIsDeleteReverse {
+ } else if case .delete = first.sql {
// INSERT then UPDATEs → keep DELETE-reverse (undo = delete), remove rest
for entry in group.dropFirst() {
seqsToDelete.append(entry.seq)
}
} else {
// First is UPDATE-reverse or INSERT-reverse (pre-existing row).
- // Remove only subsequent UPDATE-reverses (cascade duplicates).
// Keep INSERT-reverses (from DELETE) since replay needs them for row re-creation.
+ // Merge subsequent UPDATE-reverses into the first UPDATE-reverse,
+ // adding any columns not already present (first entry's values win).
+ var mergedAssignments: [UndoSQL.UpdateSQL.Assignment]?
+ var existingColumns: Set?
+ if case let .update(upd) = first.sql {
+ mergedAssignments = upd.assignments
+ existingColumns = Set(upd.assignments.map(\.column))
+ }
+
for entry in group.dropFirst() {
- if entry.sql.hasPrefix("UPDATE") {
+ if case let .update(upd) = entry.sql {
+ if var assignments = mergedAssignments, var columns = existingColumns {
+ let additions = upd.assignments.filter { !columns.contains($0.column) }
+ if !additions.isEmpty {
+ for a in additions { columns.insert(a.column) }
+ assignments += additions
+ mergedAssignments = assignments
+ existingColumns = columns
+ }
+ }
seqsToDelete.append(entry.seq)
}
}
+
+ if case let .update(upd) = first.sql,
+ let assignments = mergedAssignments, assignments.count > upd.assignments.count
+ {
+ seqsToUpdate.append((
+ seq: first.seq,
+ sql: .update(UndoSQL.UpdateSQL(
+ table: upd.table, assignments: assignments, rowids: upd.rowids))
+ ))
+ }
}
}
+ for entry in seqsToUpdate {
+ let text = entry.sql.tabDelimited
+ try self.execute(sql: "UPDATE undolog SET sql = ? WHERE seq = ?", arguments: [text, entry.seq])
+ }
+
if !seqsToDelete.isEmpty {
let placeholders = seqsToDelete.map { "\($0)" }.joined(separator: ",")
try #sql("DELETE FROM undolog WHERE seq IN (\(raw: placeholders))").execute(self)
diff --git a/Sources/SQLiteUndo/UndoSchema.swift b/Sources/SQLiteUndo/UndoSchema.swift
index 4c8237e..c58c72f 100644
--- a/Sources/SQLiteUndo/UndoSchema.swift
+++ b/Sources/SQLiteUndo/UndoSchema.swift
@@ -13,8 +13,59 @@ struct UndoLogEntry: Sendable {
var tableName: String
/// The rowid of the tracked row, for deduplication during reconciliation.
var trackedRowid: Int = 0
- /// The SQL statement to reverse the change.
- var sql: String
+ /// The parsed undo entry to reverse the change.
+ var sql: UndoSQL
+}
+
+/// Parsed representation of trigger-generated undo entries.
+/// Single-element arrays when stored; batching merges consecutive same-key entries.
+enum UndoSQL: Equatable, Sendable {
+ case delete(DeleteSQL)
+ case insert(InsertSQL)
+ case update(UpdateSQL)
+
+ struct DeleteSQL: Equatable, Sendable {
+ var table: String
+ var rowids: [String]
+ }
+
+ struct InsertSQL: Equatable, Sendable {
+ var table: String
+ var columns: [String]
+ var rows: [Row]
+
+ struct Row: Equatable, Sendable {
+ var rowid: String
+ var values: [String]
+ }
+ }
+
+ struct UpdateSQL: Equatable, Sendable {
+ var table: String
+ var assignments: [Assignment]
+ var rowids: [String]
+
+ struct Assignment: Equatable, Sendable {
+ var column: String
+ var value: String
+ }
+ }
+}
+
+extension UndoSQL: QueryDecodable {
+ init(decoder: inout some QueryDecoder) throws {
+ guard
+ let text = try decoder.decode(String.self),
+ let parsed = UndoSQL(tabDelimited: text)
+ else {
+ throw QueryDecodingError.missingRequiredColumn
+ }
+ self = parsed
+ }
+}
+
+extension UndoSQL: QueryBindable {
+ var queryBinding: QueryBinding { .text(tabDelimited) }
}
extension DatabaseWriter {
diff --git a/Sources/SQLiteUndo/UndoTracked.swift b/Sources/SQLiteUndo/UndoTracked.swift
index f08806f..12c4014 100644
--- a/Sources/SQLiteUndo/UndoTracked.swift
+++ b/Sources/SQLiteUndo/UndoTracked.swift
@@ -26,7 +26,7 @@ extension StructuredQueries.Table {
]
}
- /// INSERT trigger: Records a DELETE statement to undo the insert.
+ /// INSERT trigger: Records a DELETE entry to undo the insert.
private static func generateInsertTrigger(table: String) -> String {
"""
CREATE TEMPORARY TRIGGER IF NOT EXISTS _undo_\(table)_insert
@@ -34,40 +34,44 @@ extension StructuredQueries.Table {
WHEN "sqliteundo_isActive"()
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('\(table)', NEW.rowid, 'DELETE FROM "\(table)" WHERE rowid='||NEW.rowid);
+ VALUES('\(table)', NEW.rowid, 'D'||char(9)||'\(table)'||char(9)||NEW.rowid);
END
"""
}
- /// UPDATE trigger: Records an UPDATE statement with old values.
+ /// UPDATE trigger: Records an UPDATE entry with only changed old values.
/// Uses BEFORE timing to capture true original values before cascading triggers fire.
+ /// The WHEN clause skips no-op updates entirely.
private static func generateUpdateTrigger(table: String, columns: [String]) -> String {
- // Build: col1='||quote(OLD.col1)||',col2='||quote(OLD.col2)||'...
- let setClauses = columns.map { col in
- "'\"\(col)\"='||quote(OLD.\"\(col)\")"
- }.joined(separator: "||','||")
+ let changeChecks = columns.map { col in
+ "OLD.\"\(col)\" IS NOT NEW.\"\(col)\""
+ }.joined(separator: " OR ")
+
+ let caseClauses = columns.map { col in
+ "CASE WHEN OLD.\"\(col)\" IS NOT NEW.\"\(col)\" THEN char(9)||'\(col)'||char(9)||quote(OLD.\"\(col)\") ELSE '' END"
+ }.joined(separator: "\n || ")
return """
CREATE TEMPORARY TRIGGER IF NOT EXISTS _undo_\(table)_update
BEFORE UPDATE ON "\(table)"
WHEN "sqliteundo_isActive"()
+ AND (\(changeChecks))
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('\(table)', OLD.rowid, 'UPDATE "\(table)" SET '||\(setClauses)||' WHERE rowid='||OLD.rowid);
+ VALUES('\(table)', OLD.rowid,
+ 'U'||char(9)||'\(table)'||char(9)||OLD.rowid
+ || \(caseClauses)
+ );
END
"""
}
- /// DELETE trigger: Records an INSERT statement with old values.
+ /// DELETE trigger: Records an INSERT entry with old values.
/// Uses BEFORE timing to capture true original values before cascading triggers fire.
private static func generateDeleteTrigger(table: String, columns: [String]) -> String {
- // Build column list: "col1","col2",...
- let columnList = columns.map { "\"\($0)\"" }.joined(separator: ",")
-
- // Build value expressions: quote(OLD.col1)||','||quote(OLD.col2)||...
- let valueExpressions = columns.map { col in
- "quote(OLD.\"\(col)\")"
- }.joined(separator: "||','||")
+ let colValuePairs = columns.map { col in
+ "char(9)||'\(col)'||char(9)||quote(OLD.\"\(col)\")"
+ }.joined(separator: "\n || ")
return """
CREATE TEMPORARY TRIGGER IF NOT EXISTS _undo_\(table)_delete
@@ -75,7 +79,10 @@ extension StructuredQueries.Table {
WHEN "sqliteundo_isActive"()
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('\(table)', OLD.rowid, 'INSERT INTO "\(table)"(rowid,\(columnList)) VALUES('||OLD.rowid||','||\(valueExpressions)||')');
+ VALUES('\(table)', OLD.rowid,
+ 'I'||char(9)||'\(table)'||char(9)||OLD.rowid
+ || \(colValuePairs)
+ );
END
"""
}
diff --git a/Tests/SQLiteUndoTests/SQLParserTests.swift b/Tests/SQLiteUndoTests/SQLParserTests.swift
new file mode 100644
index 0000000..5cee4c2
--- /dev/null
+++ b/Tests/SQLiteUndoTests/SQLParserTests.swift
@@ -0,0 +1,175 @@
+import Testing
+
+@testable import SQLiteUndo
+
+@Suite
+struct SQLParserTests {
+
+ @Test(arguments: [
+ ("D\ttestRecords\t42", #"DELETE FROM "testRecords" WHERE rowid=42"#),
+ ("D\ttestRecords\t999", #"DELETE FROM "testRecords" WHERE rowid=999"#),
+ ("D\tmy table\t42", #"DELETE FROM "my table" WHERE rowid=42"#),
+ ])
+ func deleteParseAndGenerate(_ input: String, _ expectedSQL: String) {
+ let parsed = UndoSQL(tabDelimited: input)!
+ #expect(parsed.executableSQL == expectedSQL)
+ }
+
+ @Test(arguments: [
+ ("I\ttestRecords\t1\tid\t1\tname\t'hello'\tvalue\tNULL",
+ #"INSERT INTO "testRecords"(rowid,"id","name","value") VALUES(1,1,'hello',NULL)"#),
+ ("I\ttestRecords\t1\tid\t1\tname\t'it''s'",
+ #"INSERT INTO "testRecords"(rowid,"id","name") VALUES(1,1,'it''s')"#),
+ ("I\ttestRecords\t1\tid\t1\tdata\tX'ABCD'",
+ #"INSERT INTO "testRecords"(rowid,"id","data") VALUES(1,1,X'ABCD')"#),
+ ("I\ttestRecords\t1\tid\t1\tvalue\t-42",
+ #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,-42)"#),
+ ("I\ttestRecords\t1\tid\t1\tvalue\t3.14",
+ #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,3.14)"#),
+ ("I\ttestRecords\t1\tid\t1\tvalue\t1.5e10",
+ #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,1.5e10)"#),
+ ("I\tt\t1",
+ #"INSERT INTO "t"(rowid) VALUES(1)"#),
+ ])
+ func insertParseAndGenerate(_ input: String, _ expectedSQL: String) {
+ let parsed = UndoSQL(tabDelimited: input)!
+ #expect(parsed.executableSQL == expectedSQL)
+ }
+
+ @Test(arguments: [
+ ("U\ttestRecords\t1\tname\t'hello'\tvalue\t42",
+ #"UPDATE "testRecords" SET "name"='hello',"value"=42 WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tname\tNULL",
+ #"UPDATE "testRecords" SET "name"=NULL WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tdata\tX'ABCD'",
+ #"UPDATE "testRecords" SET "data"=X'ABCD' WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tvalue\t-3.14",
+ #"UPDATE "testRecords" SET "value"=-3.14 WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tname\t'it''s a test'",
+ #"UPDATE "testRecords" SET "name"='it''s a test' WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tname\t'hello world'",
+ #"UPDATE "testRecords" SET "name"='hello world' WHERE rowid=1"#),
+ ("U\ttestRecords\t1\tname\t'comma,inside'",
+ #"UPDATE "testRecords" SET "name"='comma,inside' WHERE rowid=1"#),
+ ])
+ func updateParseAndGenerate(_ input: String, _ expectedSQL: String) {
+ let parsed = UndoSQL(tabDelimited: input)!
+ #expect(parsed.executableSQL == expectedSQL)
+ }
+
+ @Test
+ func deleteParseValues() {
+ let parsed = UndoSQL(tabDelimited: "D\tt\t42")!
+ guard case let .delete(d) = parsed else {
+ Issue.record("Expected delete, got \(parsed)")
+ return
+ }
+ #expect(d.table == "t")
+ #expect(d.rowids == ["42"])
+ }
+
+ @Test
+ func insertParseValues() {
+ let parsed = UndoSQL(tabDelimited: "I\tt\t1\ta\t'hello'\tb\tNULL")!
+ guard case let .insert(ins) = parsed else {
+ Issue.record("Expected insert, got \(parsed)")
+ return
+ }
+ #expect(ins.table == "t")
+ #expect(ins.columns == ["a", "b"])
+ #expect(ins.rows.count == 1)
+ #expect(ins.rows[0].rowid == "1")
+ #expect(ins.rows[0].values == ["'hello'", "NULL"])
+ }
+
+ @Test
+ func updateParseValues() {
+ let parsed = UndoSQL(tabDelimited: "U\tt\t1\ta\t'x'\tb\t42")!
+ guard case let .update(upd) = parsed else {
+ Issue.record("Expected update, got \(parsed)")
+ return
+ }
+ #expect(upd.table == "t")
+ #expect(upd.assignments.count == 2)
+ #expect(upd.assignments[0].column == "a")
+ #expect(upd.assignments[0].value == "'x'")
+ #expect(upd.assignments[1].column == "b")
+ #expect(upd.assignments[1].value == "42")
+ #expect(upd.rowids == ["1"])
+ }
+
+ @Test
+ func batchedDeleteGenerate() {
+ let batched = UndoSQL.delete(.init(table: "t", rowids: ["1", "2", "3"]))
+ #expect(batched.executableSQL == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#)
+ }
+
+ @Test
+ func batchedInsertGenerate() {
+ let batched = UndoSQL.insert(.init(
+ table: "t",
+ columns: ["a"],
+ rows: [.init(rowid: "1", values: ["'x'"]), .init(rowid: "2", values: ["'y'"])]
+ ))
+ #expect(batched.executableSQL == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#)
+ }
+
+ @Test
+ func batchedUpdateGenerate() {
+ let batched = UndoSQL.update(.init(
+ table: "t",
+ assignments: [.init(column: "a", value: "'x'")],
+ rowids: ["1", "2"]
+ ))
+ #expect(batched.executableSQL == #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#)
+ }
+
+ @Test
+ func updateDifferentAssignmentsNotBatched() {
+ let entries: [UndoLogEntry] = [
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["1"]))),
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'y'")], rowids: ["2"]))),
+ ]
+ let result = batchedSQL(from: entries)
+ #expect(result == [
+ #"UPDATE "t" SET "a"='x' WHERE rowid=1"#,
+ #"UPDATE "t" SET "a"='y' WHERE rowid=2"#,
+ ])
+ }
+
+ @Test
+ func updateSameAssignmentsBatched() {
+ let entries: [UndoLogEntry] = [
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["1"]))),
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["2"]))),
+ ]
+ let result = batchedSQL(from: entries)
+ #expect(result == [
+ #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#,
+ ])
+ }
+
+ @Test
+ func sparseUpdateOnlyChangedColumns() {
+ let entries: [UndoLogEntry] = [
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "value", value: "42")], rowids: ["1"]))),
+ UndoLogEntry(
+ seq: 0, tableName: "t",
+ sql: .update(.init(table: "t", assignments: [.init(column: "value", value: "42")], rowids: ["2"]))),
+ ]
+ let result = batchedSQL(from: entries)
+ #expect(result == [
+ #"UPDATE "t" SET "value"=42 WHERE rowid IN (1,2)"#,
+ ])
+ }
+}
diff --git a/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift
new file mode 100644
index 0000000..13c8dca
--- /dev/null
+++ b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift
@@ -0,0 +1,142 @@
+import Foundation
+import StructuredQueries
+import Testing
+
+@testable import SQLiteUndo
+
+@Suite(.serialized)
+struct UndoBenchmarkTests {
+
+ @Test(.disabled("Run manually for benchmarking"))
+ func benchmarkUndoRedo() throws {
+ let rowCounts = [100, 500, 1000, 2000]
+
+ print(" INSERT undo/redo:")
+ for count in rowCounts {
+ let unbatched = try measureInsert(rows: count, batched: false)
+ let batched = try measureInsert(rows: count, batched: true)
+ let speedup = unbatched / batched
+ print(
+ " \(count) rows — unbatched: \(fmt(unbatched)) batched: \(fmt(batched)) speedup: \(String(format: "%.1fx", speedup))"
+ )
+ }
+
+ print(" UPDATE undo/redo:")
+ for count in rowCounts {
+ let unbatched = try measureUpdate(rows: count, batched: false)
+ let batched = try measureUpdate(rows: count, batched: true)
+ let speedup = unbatched / batched
+ print(
+ " \(count) rows — unbatched: \(fmt(unbatched)) batched: \(fmt(batched)) speedup: \(String(format: "%.1fx", speedup))"
+ )
+ }
+ }
+}
+
+private func measureInsert(rows: Int, batched: Bool) throws -> Double {
+ let iterations = 3
+ var total: Double = 0
+
+ for _ in 0.. Double {
+ let iterations = 3
+ var total: Double = 0
+
+ for _ in 0.. String {
+ if seconds < 0.001 {
+ return String(format: "%.1f µs", seconds * 1_000_000)
+ } else if seconds < 1 {
+ return String(format: "%.1f ms", seconds * 1000)
+ } else {
+ return String(format: "%.2f s", seconds)
+ }
+}
+
+@Table("benchRecords")
+private struct BenchRecord: Identifiable {
+ @Column(primaryKey: true) var id: Int
+ var name: String = ""
+ var value: Int?
+}
+
+private func makeUndoBenchmarkDatabase() throws -> (any DatabaseWriter, UndoCoordinator) {
+ let database = try DatabaseQueue(configuration: Configuration())
+ try database.write { db in
+ try db.execute(
+ sql: """
+ CREATE TABLE "benchRecords" (
+ "id" INTEGER PRIMARY KEY,
+ "name" TEXT NOT NULL DEFAULT '',
+ "value" INTEGER
+ )
+ """
+ )
+ }
+ try database.installUndoSystem()
+ try database.write { db in
+ for sql in BenchRecord.generateUndoTriggers() {
+ try db.execute(sql: sql)
+ }
+ }
+ return (database, UndoCoordinator(database: database))
+}
diff --git a/Tests/SQLiteUndoTests/UndoEngineTests.swift b/Tests/SQLiteUndoTests/UndoEngineTests.swift
index b0315de..0e8702c 100644
--- a/Tests/SQLiteUndoTests/UndoEngineTests.swift
+++ b/Tests/SQLiteUndoTests/UndoEngineTests.swift
@@ -27,15 +27,21 @@ enum UndoEngineTests {
WHEN "sqliteundo_isActive"()
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('testRecords', NEW.rowid, 'DELETE FROM "testRecords" WHERE rowid='||NEW.rowid);
+ VALUES('testRecords', NEW.rowid, 'D'||char(9)||'testRecords'||char(9)||NEW.rowid);
END
CREATE TEMPORARY TRIGGER IF NOT EXISTS _undo_testRecords_update
BEFORE UPDATE ON "testRecords"
WHEN "sqliteundo_isActive"()
+ AND (OLD."id" IS NOT NEW."id" OR OLD."name" IS NOT NEW."name" OR OLD."value" IS NOT NEW."value")
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('testRecords', OLD.rowid, 'UPDATE "testRecords" SET '||'"id"='||quote(OLD."id")||','||'"name"='||quote(OLD."name")||','||'"value"='||quote(OLD."value")||' WHERE rowid='||OLD.rowid);
+ VALUES('testRecords', OLD.rowid,
+ 'U'||char(9)||'testRecords'||char(9)||OLD.rowid
+ || CASE WHEN OLD."id" IS NOT NEW."id" THEN char(9)||'id'||char(9)||quote(OLD."id") ELSE '' END
+ || CASE WHEN OLD."name" IS NOT NEW."name" THEN char(9)||'name'||char(9)||quote(OLD."name") ELSE '' END
+ || CASE WHEN OLD."value" IS NOT NEW."value" THEN char(9)||'value'||char(9)||quote(OLD."value") ELSE '' END
+ );
END
CREATE TEMPORARY TRIGGER IF NOT EXISTS _undo_testRecords_delete
@@ -43,7 +49,12 @@ enum UndoEngineTests {
WHEN "sqliteundo_isActive"()
BEGIN
INSERT INTO undolog(tableName, trackedRowid, sql)
- VALUES('testRecords', OLD.rowid, 'INSERT INTO "testRecords"(rowid,"id","name","value") VALUES('||OLD.rowid||','||quote(OLD."id")||','||quote(OLD."name")||','||quote(OLD."value")||')');
+ VALUES('testRecords', OLD.rowid,
+ 'I'||char(9)||'testRecords'||char(9)||OLD.rowid
+ || char(9)||'id'||char(9)||quote(OLD."id")
+ || char(9)||'name'||char(9)||quote(OLD."name")
+ || char(9)||'value'||char(9)||quote(OLD."value")
+ );
END
"""
}
@@ -666,6 +677,164 @@ enum UndoEngineTests {
#expect(undoStack.currentState() == [])
}
}
+ @Suite
+ struct BulkOperationTests {
+
+ @Test
+ func bulkInsertUndoRedo() throws {
+ let (database, engine) = try makeTestDatabaseWithUndo()
+
+ let barrierId = try engine.beginBarrier("Bulk Insert")
+ try database.write { db in
+ for i in 1...1000 {
+ try TestRecord.insert { TestRecord(id: i, name: "Item \(i)", value: i) }.execute(db)
+ }
+ }
+ let barrier = try engine.endBarrier(barrierId)!
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 1000)
+ }
+
+ try engine.performUndo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 0)
+ }
+
+ try engine.performRedo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 1000)
+ let first = try TestRecord.find(1).fetchOne(db)!
+ #expect(first.name == "Item 1")
+ #expect(first.value == 1)
+ let last = try TestRecord.find(1000).fetchOne(db)!
+ #expect(last.name == "Item 1000")
+ #expect(last.value == 1000)
+ }
+ }
+
+ @Test
+ func bulkDeleteUndoRedo() throws {
+ let (database, engine) = try makeTestDatabaseWithUndo()
+
+ try withUndoDisabled {
+ try database.write { db in
+ for i in 1...1000 {
+ try TestRecord.insert { TestRecord(id: i, name: "Item \(i)", value: i) }.execute(db)
+ }
+ }
+ }
+
+ let barrierId = try engine.beginBarrier("Bulk Delete")
+ try database.write { db in
+ try TestRecord.all.delete().execute(db)
+ }
+ let barrier = try engine.endBarrier(barrierId)!
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 0)
+ }
+
+ try engine.performUndo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 1000)
+ let first = try TestRecord.find(1).fetchOne(db)!
+ #expect(first.name == "Item 1")
+ #expect(first.value == 1)
+ }
+
+ try engine.performRedo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 0)
+ }
+ }
+
+ @Test
+ func bulkUpdateUndoRedo() throws {
+ let (database, engine) = try makeTestDatabaseWithUndo()
+
+ try withUndoDisabled {
+ try database.write { db in
+ for i in 1...1000 {
+ try TestRecord.insert { TestRecord(id: i, name: "Item \(i)", value: nil) }.execute(db)
+ }
+ }
+ }
+
+ let barrierId = try engine.beginBarrier("Bulk Update")
+ try database.write { db in
+ try TestRecord.all.update { $0.value = 42 }.execute(db)
+ }
+ let barrier = try engine.endBarrier(barrierId)!
+
+ try engine.performUndo(barrier: barrier)
+
+ try database.read { db in
+ let records = try TestRecord.all.fetchAll(db)
+ #expect(records.count == 1000)
+ #expect(records.allSatisfy { $0.value == nil })
+ }
+
+ try engine.performRedo(barrier: barrier)
+
+ try database.read { db in
+ let records = try TestRecord.all.fetchAll(db)
+ #expect(records.count == 1000)
+ #expect(records.allSatisfy { $0.value == 42 })
+ }
+ }
+
+ @Test
+ func bulkMixedOperations() throws {
+ let (database, engine) = try makeTestDatabaseWithUndo()
+
+ let barrierId = try engine.beginBarrier("Mixed Ops")
+ try database.write { db in
+ for i in 1...500 {
+ try TestRecord.insert { TestRecord(id: i, name: "Item \(i)") }.execute(db)
+ }
+ for i in 1...250 {
+ try TestRecord.find(i).update { $0.value = 99 }.execute(db)
+ }
+ for i in 251...500 {
+ try TestRecord.find(i).delete().execute(db)
+ }
+ }
+ let barrier = try engine.endBarrier(barrierId)!
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 250)
+ }
+
+ try engine.performUndo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 0)
+ }
+
+ try engine.performRedo(barrier: barrier)
+
+ try database.read { db in
+ let count = try TestRecord.all.fetchCount(db)
+ #expect(count == 250)
+ let record = try TestRecord.find(1).fetchOne(db)!
+ #expect(record.value == 99)
+ }
+ }
+ }
+
@Suite
struct UndoEventTests {