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 {