From 6e3be19064961a2bd57d7bbad57405a0fc0fd30b Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 11:44:00 -0700 Subject: [PATCH 01/11] batch undo operations for efficiency --- Sources/SQLiteUndo/UndoOperations.swift | 110 +++++++++++- .../SQLiteUndoTests/UndoBenchmarkTests.swift | 93 +++++++++++ Tests/SQLiteUndoTests/UndoEngineTests.swift | 158 ++++++++++++++++++ 3 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 Tests/SQLiteUndoTests/UndoBenchmarkTests.swift diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 51130fc..66f6c59 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) } } @@ -97,6 +98,109 @@ extension Database { } } +// MARK: - SQL Batching + +/// Classifies trigger-generated SQL for batching. +private enum UndoSQL { + case delete(table: Substring, rowid: Substring) + case insert(table: Substring, header: Substring, values: Substring) + case other(sql: String) +} + +private func classifySQL(_ sql: String) -> UndoSQL { + if sql.hasPrefix("DELETE FROM") { + // Format: DELETE FROM "table" WHERE rowid=N + let prefix = sql.index(sql.startIndex, offsetBy: 12) // past "DELETE FROM " + if let whereRange = sql.range(of: " WHERE rowid=") { + let table = sql[prefix.. Substring { + // Find first quote after the command prefix + guard let firstQuote = sql.firstIndex(of: "\"") else { return sql[...] } + let afterFirst = sql.index(after: firstQuote) + guard let secondQuote = sql[afterFirst...].firstIndex(of: "\"") else { return sql[...] } + return sql[firstQuote...secondQuote] +} + +/// 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-table, same-type entries into batched SQL. +private func batchedSQL(from entries: [UndoLogEntry]) -> [String] { + if _undoBatchingDisabled { + return entries.map(\.sql) + } + var result: [String] = [] + var i = 0 + + while i < entries.count { + let classified = classifySQL(entries[i].sql) + + switch classified { + case let .delete(table, rowid): + // Collect consecutive deletes for the same table + var rowids = [rowid] + var j = i + 1 + while j < entries.count, rowids.count < maxBatchSize { + if case let .delete(nextTable, nextRowid) = classifySQL(entries[j].sql), nextTable == table { + rowids.append(nextRowid) + j += 1 + } else { + break + } + } + let rowidList = rowids.joined(separator: ",") + result.append("DELETE FROM \(table) WHERE rowid IN (\(rowidList))") + i = j + + case let .insert(table, header, values): + // Collect consecutive inserts for the same table + var tuples = [values] + var j = i + 1 + while j < entries.count, tuples.count < maxBatchSize { + if case let .insert(nextTable, _, nextValues) = classifySQL(entries[j].sql), nextTable == table + { + tuples.append(nextValues) + j += 1 + } else { + break + } + } + let valuesList = tuples.map { "(\($0))" }.joined(separator: ",") + result.append("\(header) VALUES\(valuesList)") + i = j + + case let .other(sql): + result.append(sql) + i += 1 + } + } + + return result +} + extension Database { /// Get the current maximum sequence number in the undolog. func undoLogMaxSeq() throws -> Int? { diff --git a/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift new file mode 100644 index 0000000..0590f2e --- /dev/null +++ b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift @@ -0,0 +1,93 @@ +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] + + for count in rowCounts { + let unbatched = try measure(rows: count, batched: false) + let batched = try measure(rows: count, batched: true) + let speedup = unbatched / batched + print( + " \(count) rows — unbatched: \(fmt(unbatched)) batched: \(fmt(batched)) speedup: \(String(format: "%.1fx", speedup))" + ) + } + } +} + +private func measure(rows: Int, batched: Bool) throws -> 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..ecbdc9c 100644 --- a/Tests/SQLiteUndoTests/UndoEngineTests.swift +++ b/Tests/SQLiteUndoTests/UndoEngineTests.swift @@ -666,6 +666,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 { From a89ff19f45c8cb31f47f029b69c8f20887a37925 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 12:14:38 -0700 Subject: [PATCH 02/11] skip reconcilliation during replay --- Sources/SQLiteUndo/UndoOperations.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 66f6c59..50a17e7 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -88,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) } From 93f4e63ae34d70d7753e4fec08c2c1fb5103ccd6 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 12:37:38 -0700 Subject: [PATCH 03/11] batch updates over the value --- Sources/SQLiteUndo/UndoOperations.swift | 133 ++++++++++++++++++ .../SQLiteUndoTests/UndoBenchmarkTests.swift | 55 +++++++- 2 files changed, 185 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 50a17e7..370af78 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -104,6 +104,7 @@ extension Database { private enum UndoSQL { case delete(table: Substring, rowid: Substring) case insert(table: Substring, header: Substring, values: Substring) + case update(table: Substring, setClause: Substring, rowid: Substring) case other(sql: String) } @@ -126,6 +127,16 @@ private func classifySQL(_ sql: String) -> UndoSQL { let values = sql[valuesStart.. String in + let values: [String] = parseUpdateSetClause(entry.setClause).map { String($0.value) } + return "(\(entry.rowid),\(values.joined(separator: ",")))" + }.joined(separator: ",") + + // AS _v(_r,"col1","col2") + let aliases: String = (["_r"] + columns.map { "\"\($0)\"" as String }).joined(separator: ",") + + result.append( + "WITH _v(\(aliases)) AS (VALUES \(valueRows)) UPDATE \(table) SET \(setExprs) FROM _v WHERE \(table).rowid=_v._r" + ) + } + i = j + case let .other(sql): result.append(sql) i += 1 @@ -201,6 +253,87 @@ private func batchedSQL(from entries: [UndoLogEntry]) -> [String] { return result } +// MARK: - UPDATE SET Clause Parsing + +private struct ColumnValue { + var column: Substring + var value: Substring +} + +/// Parse a trigger-generated SET clause into column-value pairs. +/// Input format: `"col1"=val1,"col2"=val2,...` where values are `quote()` output. +private func parseUpdateSetClause(_ setClause: Substring) -> [ColumnValue] { + var result: [ColumnValue] = [] + var i = setClause.startIndex + + while i < setClause.endIndex { + // Skip comma between assignments + if setClause[i] == "," { + i = setClause.index(after: i) + } + + // Parse "column" + guard i < setClause.endIndex, setClause[i] == "\"" else { break } + let colStart = setClause.index(after: i) + guard let colEnd = setClause[colStart...].firstIndex(of: "\"") else { break } + let column = setClause[colStart.. String.Index { + var i = start + guard i < s.endIndex else { return i } + + switch s[i] { + case "'": + // String: 'text with ''escapes''' + i = s.index(after: i) + while i < s.endIndex { + if s[i] == "'" { + let next = s.index(after: i) + if next < s.endIndex, s[next] == "'" { + i = s.index(after: next) + } else { + return next + } + } else { + i = s.index(after: i) + } + } + return i + + case "X" where s.index(after: i) < s.endIndex && s[s.index(after: i)] == "'": + // Blob: X'hex' + i = s.index(i, offsetBy: 2) + if let end = s[i...].firstIndex(of: "'") { + return s.index(after: end) + } + return s.endIndex + + case "N" where s[i...].hasPrefix("NULL"): + return s.index(i, offsetBy: 4) + + default: + // Number: scan to comma or end + while i < s.endIndex, s[i] != "," { + i = s.index(after: i) + } + return i + } +} + extension Database { /// Get the current maximum sequence number in the undolog. func undoLogMaxSeq() throws -> Int? { diff --git a/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift index 0590f2e..13c8dca 100644 --- a/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift +++ b/Tests/SQLiteUndoTests/UndoBenchmarkTests.swift @@ -11,9 +11,20 @@ struct UndoBenchmarkTests { func benchmarkUndoRedo() throws { let rowCounts = [100, 500, 1000, 2000] + print(" INSERT undo/redo:") for count in rowCounts { - let unbatched = try measure(rows: count, batched: false) - let batched = try measure(rows: count, batched: true) + 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))" @@ -22,7 +33,7 @@ struct UndoBenchmarkTests { } } -private func measure(rows: Int, batched: Bool) throws -> Double { +private func measureInsert(rows: Int, batched: Bool) throws -> Double { let iterations = 3 var total: Double = 0 @@ -53,6 +64,44 @@ private func measure(rows: Int, batched: Bool) throws -> Double { return total / Double(iterations) } +private func measureUpdate(rows: Int, batched: Bool) throws -> 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) From 54c5b8db7637cec331b87a75ea46a8f3f3e097cd Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 13:38:05 -0700 Subject: [PATCH 04/11] initial parser impl --- .../xcshareddata/swiftpm/Package.resolved | 11 +- Package.resolved | 11 +- Package.swift | 2 + Sources/SQLiteUndo/SQLParser.swift | 426 ++++++++++++++++++ Sources/SQLiteUndo/UndoOperations.swift | 236 ---------- Tests/SQLiteUndoTests/SQLParserTests.swift | 136 ++++++ 6 files changed, 584 insertions(+), 238 deletions(-) create mode 100644 Sources/SQLiteUndo/SQLParser.swift create mode 100644 Tests/SQLiteUndoTests/SQLParserTests.swift 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..a3f8cad 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "17abbefe93c2934020907b9e1ef014ff80f9b91d3dfa62543e300908db7fb00b", + "originHash" : "39c0cd6a1e693bd45231e39e57aa4320e926003a308eb8bd7d1a27f5a3eba3b8", "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.swift b/Package.swift index 6c08ede..4a2cd9c 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.22.3"), .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.9.5"), + .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.14.1"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.18.7"), .package(url: "https://github.com/pointfreeco/sqlite-data.git", from: "1.3.0"), ], @@ -26,6 +27,7 @@ let package = Package( dependencies: [ .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Parsing", package: "swift-parsing"), .product(name: "SQLiteData", package: "sqlite-data"), ] ), diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift new file mode 100644 index 0000000..8621768 --- /dev/null +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -0,0 +1,426 @@ +import Parsing + +// MARK: - Data Types + +/// A double-quoted SQL identifier like `"tableName"` or `"columnName"`. +struct QuotedIdentifier: Equatable, Sendable { + var name: Substring +} + +/// An opaque value from SQLite `quote()` — boundaries found, content untouched. +struct QuotedValue: Equatable, Sendable { + var raw: Substring +} + +/// A column=value pair in an UPDATE SET clause. +struct ColumnAssignment: Equatable, Sendable { + var column: QuotedIdentifier + var value: QuotedValue +} + +/// Parsed representation of trigger-generated undo SQL. +enum UndoSQL: Equatable, Sendable { + case delete(table: QuotedIdentifier, rowid: Substring) + case insert( + table: QuotedIdentifier, columns: [QuotedIdentifier], rowid: Substring, values: [QuotedValue]) + case update(table: QuotedIdentifier, assignments: [ColumnAssignment], rowid: Substring) +} + +// MARK: - Component ParserPrinters + +struct QuotedIdentifierParser: ParserPrinter { + func parse(_ input: inout Substring) throws -> QuotedIdentifier { + guard input.first == "\"" else { + struct ExpectedQuote: Error {} + throw ExpectedQuote() + } + input.removeFirst() + guard let end = input.firstIndex(of: "\"") else { + struct UnterminatedIdentifier: Error {} + throw UnterminatedIdentifier() + } + let name = input[.. QuotedValue { + let start = input.startIndex + + switch input.first { + case "'": + // String: 'text with ''escapes''' + input.removeFirst() + while !input.isEmpty { + if input.first == "'" { + input.removeFirst() + if input.first == "'" { + input.removeFirst() + } else { + return QuotedValue(raw: input.base[start.. start else { + struct ExpectedValue: Error {} + throw ExpectedValue() + } + return QuotedValue(raw: input.base[start.. start else { + struct ExpectedValue: Error {} + throw ExpectedValue() + } + return QuotedValue(raw: input.base[start.. UndoSQL { + guard input.hasPrefix("DELETE FROM ") else { + struct NotDelete: Error {} + throw NotDelete() + } + input.removeFirst(12) + let table = try QuotedIdentifierParser().parse(&input) + guard input.hasPrefix(" WHERE rowid=") else { + struct ExpectedWhere: Error {} + throw ExpectedWhere() + } + input.removeFirst(13) + let rowid = input + input = input[input.endIndex...] + return .delete(table: table, rowid: rowid) + } + + func print(_ output: UndoSQL, into input: inout Substring) throws { + guard case let .delete(table, rowid) = output else { + struct NotDelete: Error {} + throw NotDelete() + } + var s = "DELETE FROM \"\(table.name)\" WHERE rowid=" as String + s.append(contentsOf: rowid) + s.append(contentsOf: input) + input = Substring(s) + } +} + +struct InsertSQLParser: ParserPrinter { + func parse(_ input: inout Substring) throws -> UndoSQL { + guard input.hasPrefix("INSERT INTO ") else { + struct NotInsert: Error {} + throw NotInsert() + } + input.removeFirst(12) + let table = try QuotedIdentifierParser().parse(&input) + + guard input.hasPrefix("(rowid") else { + struct ExpectedRowid: Error {} + throw ExpectedRowid() + } + input.removeFirst(6) + + // Parse optional column list after rowid + var columns: [QuotedIdentifier] = [] + if input.first == "," { + input.removeFirst() + while true { + let col = try QuotedIdentifierParser().parse(&input) + columns.append(col) + if input.first == "," { + input.removeFirst() + } else { + break + } + } + } + + guard input.hasPrefix(") VALUES(") else { + struct ExpectedValues: Error {} + throw ExpectedValues() + } + input.removeFirst(9) + + // Parse rowid value + let rowidStart = input.startIndex + while !input.isEmpty && input.first != "," && input.first != ")" { + input.removeFirst() + } + let rowid = input.base[rowidStart.. UndoSQL { + guard input.hasPrefix("UPDATE ") else { + struct NotUpdate: Error {} + throw NotUpdate() + } + input.removeFirst(7) + let table = try QuotedIdentifierParser().parse(&input) + + guard input.hasPrefix(" SET ") else { + struct ExpectedSet: Error {} + throw ExpectedSet() + } + input.removeFirst(5) + + // Parse assignments: "col"=val,"col2"=val2 + var assignments: [ColumnAssignment] = [] + while true { + let col = try QuotedIdentifierParser().parse(&input) + guard input.first == "=" else { + struct ExpectedEquals: Error {} + throw ExpectedEquals() + } + input.removeFirst() + let val = try QuotedValueParser().parse(&input) + assignments.append(ColumnAssignment(column: col, value: val)) + if input.first == "," { + input.removeFirst() + } else { + break + } + } + + guard input.hasPrefix(" WHERE rowid=") else { + struct ExpectedWhere: Error {} + throw ExpectedWhere() + } + input.removeFirst(13) + let rowid = input + input = input[input.endIndex...] + + return .update(table: table, assignments: assignments, rowid: rowid) + } + + func print(_ output: UndoSQL, into input: inout Substring) throws { + guard case let .update(table, assignments, rowid) = output else { + struct NotUpdate: Error {} + throw NotUpdate() + } + var s = "UPDATE \"\(table.name)\" SET " as String + s += assignments.map { "\"\($0.column.name)\"=\($0.value.raw)" as String }.joined(separator: ",") + s += " WHERE rowid=" + s.append(contentsOf: rowid) + s.append(contentsOf: input) + input = Substring(s) + } +} + +/// Top-level parser for trigger-generated undo SQL. +struct UndoSQLParser: ParserPrinter { + func parse(_ input: inout Substring) throws -> UndoSQL { + let saved = input + do { return try DeleteSQLParser().parse(&input) } + catch { input = saved } + do { return try InsertSQLParser().parse(&input) } + catch { input = saved } + return try UpdateSQLParser().parse(&input) + } + + func print(_ output: UndoSQL, into input: inout Substring) throws { + switch output { + case .delete: try DeleteSQLParser().print(output, into: &input) + case .insert: try InsertSQLParser().print(output, into: &input) + case .update: try UpdateSQLParser().print(output, into: &input) + } + } +} + +// 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-table, same-type entries into batched SQL. +func batchedSQL(from entries: [UndoLogEntry]) -> [String] { + if _undoBatchingDisabled { + return entries.map(\.sql) + } + + let items: [(sql: String, parsed: UndoSQL?)] = entries.map { entry in + var input = Substring(entry.sql) + let parsed = (try? UndoSQLParser().parse(&input)).flatMap { input.isEmpty ? $0 : nil } + return (entry.sql, parsed) + } + + var remaining = items[...] + var result: [String] = [] + + while let first = remaining.popFirst() { + guard let current = first.parsed else { + result.append(first.sql) + continue + } + + switch current { + case let .delete(table, rowid): + var rowids = [rowid] + while rowids.count < maxBatchSize { + guard case let .delete(t, r)? = remaining.first?.parsed, t == table else { break } + rowids.append(r) + remaining.removeFirst() + } + result.append(batchedDeleteSQL(table: table, rowids: rowids)) + + case let .insert(table, columns, rowid, values): + var rows: [(rowid: Substring, values: [QuotedValue])] = [(rowid, values)] + while rows.count < maxBatchSize { + guard case let .insert(t, _, r, v)? = remaining.first?.parsed, t == table else { break } + rows.append((r, v)) + remaining.removeFirst() + } + result.append(batchedInsertSQL(table: table, columns: columns, rows: rows)) + + case let .update(table, assignments, rowid): + let columns = assignments.map(\.column) + var rows: [(rowid: Substring, values: [QuotedValue])] = [(rowid, assignments.map(\.value))] + while rows.count < maxBatchSize { + guard case let .update(t, a, r)? = remaining.first?.parsed, t == table else { break } + rows.append((r, a.map(\.value))) + remaining.removeFirst() + } + if rows.count == 1 { + result.append(first.sql) + } else { + result.append(batchedUpdateSQL(table: table, columns: columns, rows: rows)) + } + } + } + + return result +} + +func batchedDeleteSQL(table: QuotedIdentifier, rowids: [Substring]) -> String { + let rowidList = rowids.joined(separator: ",") as String + return "DELETE FROM \"\(table.name)\" WHERE rowid IN (\(rowidList))" as String +} + +func batchedInsertSQL( + table: QuotedIdentifier, columns: [QuotedIdentifier], + rows: [(rowid: Substring, values: [QuotedValue])] +) -> String { + var colList = "rowid" as String + if !columns.isEmpty { + colList += "," + colList += columns.map { "\"\($0.name)\"" as String }.joined(separator: ",") + } + let valuesList = + rows.map { row -> String in + let vals = ([row.rowid] + row.values.map(\.raw)).joined(separator: ",") as String + return "(\(vals))" as String + }.joined(separator: ",") + return "INSERT INTO \"\(table.name)\"(\(colList)) VALUES\(valuesList)" as String +} + +func batchedUpdateSQL( + table: QuotedIdentifier, columns: [QuotedIdentifier], + rows: [(rowid: Substring, values: [QuotedValue])] +) -> String { + let setExprs = + columns.map { "\"\($0.name)\"=_v.\"\($0.name)\"" as String }.joined(separator: ",") as String + let valueRows = + rows.map { row -> String in + let vals = ([row.rowid] + row.values.map(\.raw)).joined(separator: ",") as String + return "(\(vals))" as String + }.joined(separator: ",") as String + let aliases = + (["_r"] + columns.map { "\"\($0.name)\"" as String }).joined(separator: ",") as String + return + "WITH _v(\(aliases)) AS (VALUES \(valueRows)) UPDATE \"\(table.name)\" SET \(setExprs) FROM _v WHERE \"\(table.name)\".rowid=_v._r" + as String +} diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 370af78..a92cdd0 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -98,242 +98,6 @@ extension Database { } } -// MARK: - SQL Batching - -/// Classifies trigger-generated SQL for batching. -private enum UndoSQL { - case delete(table: Substring, rowid: Substring) - case insert(table: Substring, header: Substring, values: Substring) - case update(table: Substring, setClause: Substring, rowid: Substring) - case other(sql: String) -} - -private func classifySQL(_ sql: String) -> UndoSQL { - if sql.hasPrefix("DELETE FROM") { - // Format: DELETE FROM "table" WHERE rowid=N - let prefix = sql.index(sql.startIndex, offsetBy: 12) // past "DELETE FROM " - if let whereRange = sql.range(of: " WHERE rowid=") { - let table = sql[prefix.. Substring { - // Find first quote after the command prefix - guard let firstQuote = sql.firstIndex(of: "\"") else { return sql[...] } - let afterFirst = sql.index(after: firstQuote) - guard let secondQuote = sql[afterFirst...].firstIndex(of: "\"") else { return sql[...] } - return sql[firstQuote...secondQuote] -} - -/// 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-table, same-type entries into batched SQL. -private func batchedSQL(from entries: [UndoLogEntry]) -> [String] { - if _undoBatchingDisabled { - return entries.map(\.sql) - } - var result: [String] = [] - var i = 0 - - while i < entries.count { - let classified = classifySQL(entries[i].sql) - - switch classified { - case let .delete(table, rowid): - // Collect consecutive deletes for the same table - var rowids = [rowid] - var j = i + 1 - while j < entries.count, rowids.count < maxBatchSize { - if case let .delete(nextTable, nextRowid) = classifySQL(entries[j].sql), nextTable == table { - rowids.append(nextRowid) - j += 1 - } else { - break - } - } - let rowidList = rowids.joined(separator: ",") - result.append("DELETE FROM \(table) WHERE rowid IN (\(rowidList))") - i = j - - case let .insert(table, header, values): - // Collect consecutive inserts for the same table - var tuples = [values] - var j = i + 1 - while j < entries.count, tuples.count < maxBatchSize { - if case let .insert(nextTable, _, nextValues) = classifySQL(entries[j].sql), nextTable == table - { - tuples.append(nextValues) - j += 1 - } else { - break - } - } - let valuesList = tuples.map { "(\($0))" }.joined(separator: ",") - result.append("\(header) VALUES\(valuesList)") - i = j - - case let .update(table, setClause, rowid): - // Collect consecutive updates for the same table - var updates = [(setClause: Substring, rowid: Substring)]() - updates.append((setClause, rowid)) - var j = i + 1 - while j < entries.count, updates.count < maxBatchSize { - if case let .update(nextTable, nextSet, nextRowid) = classifySQL(entries[j].sql), - nextTable == table - { - updates.append((nextSet, nextRowid)) - j += 1 - } else { - break - } - } - - if updates.count == 1 { - result.append(entries[i].sql) - } else { - // Parse column names from the first entry - let firstParsed = parseUpdateSetClause(updates[0].setClause) - let columns = firstParsed.map(\.column) - - // SET "col1"=_v."col1","col2"=_v."col2" - let setExprs: String = columns.map { ("\"\($0)\"=_v.\"\($0)\"" as String) }.joined(separator: ",") - - // VALUES (rowid1,v1,v2),(rowid2,v3,v4) - let valueRows: String = updates.map { (entry) -> String in - let values: [String] = parseUpdateSetClause(entry.setClause).map { String($0.value) } - return "(\(entry.rowid),\(values.joined(separator: ",")))" - }.joined(separator: ",") - - // AS _v(_r,"col1","col2") - let aliases: String = (["_r"] + columns.map { "\"\($0)\"" as String }).joined(separator: ",") - - result.append( - "WITH _v(\(aliases)) AS (VALUES \(valueRows)) UPDATE \(table) SET \(setExprs) FROM _v WHERE \(table).rowid=_v._r" - ) - } - i = j - - case let .other(sql): - result.append(sql) - i += 1 - } - } - - return result -} - -// MARK: - UPDATE SET Clause Parsing - -private struct ColumnValue { - var column: Substring - var value: Substring -} - -/// Parse a trigger-generated SET clause into column-value pairs. -/// Input format: `"col1"=val1,"col2"=val2,...` where values are `quote()` output. -private func parseUpdateSetClause(_ setClause: Substring) -> [ColumnValue] { - var result: [ColumnValue] = [] - var i = setClause.startIndex - - while i < setClause.endIndex { - // Skip comma between assignments - if setClause[i] == "," { - i = setClause.index(after: i) - } - - // Parse "column" - guard i < setClause.endIndex, setClause[i] == "\"" else { break } - let colStart = setClause.index(after: i) - guard let colEnd = setClause[colStart...].firstIndex(of: "\"") else { break } - let column = setClause[colStart.. String.Index { - var i = start - guard i < s.endIndex else { return i } - - switch s[i] { - case "'": - // String: 'text with ''escapes''' - i = s.index(after: i) - while i < s.endIndex { - if s[i] == "'" { - let next = s.index(after: i) - if next < s.endIndex, s[next] == "'" { - i = s.index(after: next) - } else { - return next - } - } else { - i = s.index(after: i) - } - } - return i - - case "X" where s.index(after: i) < s.endIndex && s[s.index(after: i)] == "'": - // Blob: X'hex' - i = s.index(i, offsetBy: 2) - if let end = s[i...].firstIndex(of: "'") { - return s.index(after: end) - } - return s.endIndex - - case "N" where s[i...].hasPrefix("NULL"): - return s.index(i, offsetBy: 4) - - default: - // Number: scan to comma or end - while i < s.endIndex, s[i] != "," { - i = s.index(after: i) - } - return i - } -} - extension Database { /// Get the current maximum sequence number in the undolog. func undoLogMaxSeq() throws -> Int? { diff --git a/Tests/SQLiteUndoTests/SQLParserTests.swift b/Tests/SQLiteUndoTests/SQLParserTests.swift new file mode 100644 index 0000000..e167673 --- /dev/null +++ b/Tests/SQLiteUndoTests/SQLParserTests.swift @@ -0,0 +1,136 @@ +import Testing + +@testable import SQLiteUndo + +@Suite +struct SQLParserTests { + + @Test(arguments: [ + #"DELETE FROM "testRecords" WHERE rowid=1"#, + #"DELETE FROM "testRecords" WHERE rowid=999"#, + #"DELETE FROM "my table" WHERE rowid=42"#, + ]) + func deleteRoundTrip(_ sql: String) throws { + try assertRoundTrip(sql) + } + + @Test(arguments: [ + #"INSERT INTO "testRecords"(rowid,"id","name","value") VALUES(1,1,'hello',NULL)"#, + #"INSERT INTO "testRecords"(rowid,"id","name") VALUES(1,1,'it''s')"#, + #"INSERT INTO "testRecords"(rowid,"id","data") VALUES(1,1,X'ABCD')"#, + #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,-42)"#, + #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,3.14)"#, + #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,1.5e10)"#, + #"INSERT INTO "t"(rowid) VALUES(1)"#, + ]) + func insertRoundTrip(_ sql: String) throws { + try assertRoundTrip(sql) + } + + @Test(arguments: [ + #"UPDATE "testRecords" SET "name"='hello',"value"=42 WHERE rowid=1"#, + #"UPDATE "testRecords" SET "name"=NULL WHERE rowid=1"#, + #"UPDATE "testRecords" SET "data"=X'ABCD' WHERE rowid=1"#, + #"UPDATE "testRecords" SET "value"=-3.14 WHERE rowid=1"#, + #"UPDATE "testRecords" SET "name"='it''s a test' WHERE rowid=1"#, + #"UPDATE "testRecords" SET "name"='hello world' WHERE rowid=1"#, + #"UPDATE "testRecords" SET "name"='comma,inside' WHERE rowid=1"#, + ]) + func updateRoundTrip(_ sql: String) throws { + try assertRoundTrip(sql) + } + + @Test + func deleteParseValues() throws { + let parsed = try parseSQL(#"DELETE FROM "t" WHERE rowid=42"#) + guard case let .delete(table, rowid) = parsed else { + Issue.record("Expected delete, got \(parsed)") + return + } + #expect(table.name == "t") + #expect(rowid == "42") + } + + @Test + func insertParseValues() throws { + let parsed = try parseSQL( + #"INSERT INTO "t"(rowid,"a","b") VALUES(1,'hello',NULL)"#) + guard case let .insert(table, columns, rowid, values) = parsed else { + Issue.record("Expected insert, got \(parsed)") + return + } + #expect(table.name == "t") + #expect(columns.map(\.name) == ["a", "b"]) + #expect(rowid == "1") + #expect(values.map(\.raw) == ["'hello'", "NULL"]) + } + + @Test + func updateParseValues() throws { + let parsed = try parseSQL( + #"UPDATE "t" SET "a"='x',"b"=42 WHERE rowid=1"#) + guard case let .update(table, assignments, rowid) = parsed else { + Issue.record("Expected update, got \(parsed)") + return + } + #expect(table.name == "t") + #expect(assignments.count == 2) + #expect(assignments[0].column.name == "a") + #expect(assignments[0].value.raw == "'x'") + #expect(assignments[1].column.name == "b") + #expect(assignments[1].value.raw == "42") + #expect(rowid == "1") + } + + @Test + func batchedDelete() { + let sql = batchedDeleteSQL( + table: QuotedIdentifier(name: "t"), + rowids: ["1", "2", "3"] + ) + #expect(sql == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) + } + + @Test + func batchedInsert() { + let sql = batchedInsertSQL( + table: QuotedIdentifier(name: "t"), + columns: [QuotedIdentifier(name: "a")], + rows: [ + (rowid: "1"[...], values: [QuotedValue(raw: "'x'")]), + (rowid: "2"[...], values: [QuotedValue(raw: "'y'")]), + ] + ) + #expect(sql == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) + } + + @Test + func batchedUpdate() { + let sql = batchedUpdateSQL( + table: QuotedIdentifier(name: "t"), + columns: [QuotedIdentifier(name: "a")], + rows: [ + (rowid: "1"[...], values: [QuotedValue(raw: "'x'")]), + (rowid: "2"[...], values: [QuotedValue(raw: "'y'")]), + ] + ) + #expect( + sql + == #"WITH _v(_r,"a") AS (VALUES (1,'x'),(2,'y')) UPDATE "t" SET "a"=_v."a" FROM _v WHERE "t".rowid=_v._r"# + ) + } +} + +private func parseSQL(_ sql: String) throws -> UndoSQL { + var input = Substring(sql) + let parsed = try UndoSQLParser().parse(&input) + #expect(input.isEmpty) + return parsed +} + +private func assertRoundTrip(_ sql: String) throws { + let parsed = try parseSQL(sql) + var output = Substring() + try UndoSQLParser().print(parsed, into: &output) + #expect(String(output) == sql) +} From c7259c8090afa8924212f1b85f6e9dd2bd64dcc2 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 13:52:26 -0700 Subject: [PATCH 05/11] refined parser/printer with improved update batching --- Sources/SQLiteUndo/SQLParser.swift | 238 +++++++++++---------- Tests/SQLiteUndoTests/SQLParserTests.swift | 74 +++++-- 2 files changed, 173 insertions(+), 139 deletions(-) diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift index 8621768..5df2001 100644 --- a/Sources/SQLiteUndo/SQLParser.swift +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -1,6 +1,59 @@ import Parsing -// MARK: - Data Types +/// Top-level parser for trigger-generated undo SQL. +struct UndoSQLParser: ParserPrinter { + func parse(_ input: inout Substring) throws -> UndoSQL { + let saved = input + do { return try DeleteSQLParser().parse(&input) } + catch { input = saved } + do { return try InsertSQLParser().parse(&input) } + catch { input = saved } + return try UpdateSQLParser().parse(&input) + } + + func print(_ output: UndoSQL, into input: inout Substring) throws { + switch output { + case .delete: try DeleteSQLParser().print(output, into: &input) + case .insert: try InsertSQLParser().print(output, into: &input) + case .update: try UpdateSQLParser().print(output, into: &input) + } + } +} + +/// Parsed representation of trigger-generated undo SQL. +/// Parsers produce single-element arrays; batching merges consecutive same-key entries. +enum UndoSQL: Equatable, Sendable { + case delete( + table: QuotedIdentifier, + rowids: [Substring] + ) + case insert( + table: QuotedIdentifier, columns: [QuotedIdentifier], + rows: [(rowid: Substring, values: [QuotedValue])] + ) + case update( + table: QuotedIdentifier, + assignments: [ColumnAssignment], + rowids: [Substring] + ) + + static func == (lhs: UndoSQL, rhs: UndoSQL) -> Bool { + switch (lhs, rhs) { + case let (.delete(lt, lr), .delete(rt, rr)): + return lt == rt && lr.map(String.init) == rr.map(String.init) + case let (.insert(lt, lc, lrows), .insert(rt, rc, rrows)): + guard lt == rt && lc == rc && lrows.count == rrows.count else { return false } + for (l, r) in zip(lrows, rrows) { + guard String(l.rowid) == String(r.rowid) && l.values == r.values else { return false } + } + return true + case let (.update(lt, la, lr), .update(rt, ra, rr)): + return lt == rt && la == ra && lr.map(String.init) == rr.map(String.init) + default: + return false + } + } +} /// A double-quoted SQL identifier like `"tableName"` or `"columnName"`. struct QuotedIdentifier: Equatable, Sendable { @@ -18,14 +71,6 @@ struct ColumnAssignment: Equatable, Sendable { var value: QuotedValue } -/// Parsed representation of trigger-generated undo SQL. -enum UndoSQL: Equatable, Sendable { - case delete(table: QuotedIdentifier, rowid: Substring) - case insert( - table: QuotedIdentifier, columns: [QuotedIdentifier], rowid: Substring, values: [QuotedValue]) - case update(table: QuotedIdentifier, assignments: [ColumnAssignment], rowid: Substring) -} - // MARK: - Component ParserPrinters struct QuotedIdentifierParser: ParserPrinter { @@ -135,16 +180,23 @@ struct DeleteSQLParser: ParserPrinter { input.removeFirst(13) let rowid = input input = input[input.endIndex...] - return .delete(table: table, rowid: rowid) + return .delete(table: table, rowids: [rowid]) } func print(_ output: UndoSQL, into input: inout Substring) throws { - guard case let .delete(table, rowid) = output else { + guard case let .delete(table, rowids) = output else { struct NotDelete: Error {} throw NotDelete() } - var s = "DELETE FROM \"\(table.name)\" WHERE rowid=" as String - s.append(contentsOf: rowid) + var s = "DELETE FROM \"\(table.name)\"" as String + if rowids.count == 1 { + s += " WHERE rowid=" + s.append(contentsOf: rowids[0]) + } else { + s += " WHERE rowid IN (" + s += rowids.map(String.init).joined(separator: ",") + s += ")" + } s.append(contentsOf: input) input = Substring(s) } @@ -214,11 +266,11 @@ struct InsertSQLParser: ParserPrinter { } input.removeFirst() - return .insert(table: table, columns: columns, rowid: rowid, values: values) + return .insert(table: table, columns: columns, rows: [(rowid: rowid, values: values)]) } func print(_ output: UndoSQL, into input: inout Substring) throws { - guard case let .insert(table, columns, rowid, values) = output else { + guard case let .insert(table, columns, rows) = output else { struct NotInsert: Error {} throw NotInsert() } @@ -227,13 +279,17 @@ struct InsertSQLParser: ParserPrinter { s += "," s += columns.map { "\"\($0.name)\"" as String }.joined(separator: ",") } - s += ") VALUES(" - s.append(contentsOf: rowid) - for val in values { - s += "," - s.append(contentsOf: val.raw) + s += ") VALUES" + for (i, row) in rows.enumerated() { + if i > 0 { s += "," } + s += "(" + s.append(contentsOf: row.rowid) + for val in row.values { + s += "," + s.append(contentsOf: val.raw) + } + s += ")" } - s += ")" s.append(contentsOf: input) input = Substring(s) } @@ -280,43 +336,29 @@ struct UpdateSQLParser: ParserPrinter { let rowid = input input = input[input.endIndex...] - return .update(table: table, assignments: assignments, rowid: rowid) + return .update(table: table, assignments: assignments, rowids: [rowid]) } func print(_ output: UndoSQL, into input: inout Substring) throws { - guard case let .update(table, assignments, rowid) = output else { + guard case let .update(table, assignments, rowids) = output else { struct NotUpdate: Error {} throw NotUpdate() } var s = "UPDATE \"\(table.name)\" SET " as String s += assignments.map { "\"\($0.column.name)\"=\($0.value.raw)" as String }.joined(separator: ",") - s += " WHERE rowid=" - s.append(contentsOf: rowid) + if rowids.count == 1 { + s += " WHERE rowid=" + s.append(contentsOf: rowids[0]) + } else { + s += " WHERE rowid IN (" + s += rowids.map(String.init).joined(separator: ",") + s += ")" + } s.append(contentsOf: input) input = Substring(s) } } -/// Top-level parser for trigger-generated undo SQL. -struct UndoSQLParser: ParserPrinter { - func parse(_ input: inout Substring) throws -> UndoSQL { - let saved = input - do { return try DeleteSQLParser().parse(&input) } - catch { input = saved } - do { return try InsertSQLParser().parse(&input) } - catch { input = saved } - return try UpdateSQLParser().parse(&input) - } - - func print(_ output: UndoSQL, into input: inout Substring) throws { - switch output { - case .delete: try DeleteSQLParser().print(output, into: &input) - case .insert: try InsertSQLParser().print(output, into: &input) - case .update: try UpdateSQLParser().print(output, into: &input) - } - } -} - // MARK: - SQL Batching /// Maximum entries per batch to stay within SQLite limits. @@ -326,15 +368,17 @@ private let maxBatchSize = 500 /// Used for benchmarking to compare batched vs unbatched performance. nonisolated(unsafe) var _undoBatchingDisabled = false -/// Groups consecutive same-table, same-type entries into batched SQL. +/// 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) } + let parser = UndoSQLParser() let items: [(sql: String, parsed: UndoSQL?)] = entries.map { entry in var input = Substring(entry.sql) - let parsed = (try? UndoSQLParser().parse(&input)).flatMap { input.isEmpty ? $0 : nil } + let parsed = (try? parser.parse(&input)).flatMap { input.isEmpty ? $0 : nil } return (entry.sql, parsed) } @@ -342,85 +386,45 @@ func batchedSQL(from entries: [UndoLogEntry]) -> [String] { var result: [String] = [] while let first = remaining.popFirst() { - guard let current = first.parsed else { + guard var current = first.parsed else { result.append(first.sql) continue } - switch current { - case let .delete(table, rowid): - var rowids = [rowid] - while rowids.count < maxBatchSize { - guard case let .delete(t, r)? = remaining.first?.parsed, t == table else { break } - rowids.append(r) - remaining.removeFirst() - } - result.append(batchedDeleteSQL(table: table, rowids: rowids)) - - case let .insert(table, columns, rowid, values): - var rows: [(rowid: Substring, values: [QuotedValue])] = [(rowid, values)] - while rows.count < maxBatchSize { - guard case let .insert(t, _, r, v)? = remaining.first?.parsed, t == table else { break } - rows.append((r, v)) - remaining.removeFirst() - } - result.append(batchedInsertSQL(table: table, columns: columns, rows: rows)) - - case let .update(table, assignments, rowid): - let columns = assignments.map(\.column) - var rows: [(rowid: Substring, values: [QuotedValue])] = [(rowid, assignments.map(\.value))] - while rows.count < maxBatchSize { - guard case let .update(t, a, r)? = remaining.first?.parsed, t == table else { break } - rows.append((r, a.map(\.value))) - remaining.removeFirst() - } - if rows.count == 1 { - result.append(first.sql) - } else { - result.append(batchedUpdateSQL(table: table, columns: columns, rows: rows)) - } + // Merge consecutive same-key entries + while remaining.first?.parsed != nil { + guard let merged = merge(current, remaining.first!.parsed!) else { break } + current = merged + remaining.removeFirst() } + + // Print via the parser-printer + var output = Substring() + try! parser.print(current, into: &output) + result.append(String(output)) } return result } -func batchedDeleteSQL(table: QuotedIdentifier, rowids: [Substring]) -> String { - let rowidList = rowids.joined(separator: ",") as String - return "DELETE FROM \"\(table.name)\" WHERE rowid IN (\(rowidList))" as String -} - -func batchedInsertSQL( - table: QuotedIdentifier, columns: [QuotedIdentifier], - rows: [(rowid: Substring, values: [QuotedValue])] -) -> String { - var colList = "rowid" as String - if !columns.isEmpty { - colList += "," - colList += columns.map { "\"\($0.name)\"" as String }.joined(separator: ",") +/// Merge two UndoSQL values if they share the same grouping key. +private func merge(_ lhs: UndoSQL, _ rhs: UndoSQL) -> UndoSQL? { + switch (lhs, rhs) { + case let (.delete(lt, lr), .delete(rt, rr)): + guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } + return .delete(table: lt, rowids: lr + rr) + + case let (.insert(lt, lc, lrows), .insert(rt, _, rrows)): + guard lt == rt, lrows.count + rrows.count <= maxBatchSize else { return nil } + // INSERT batches by table (columns come from the first entry) + return .insert(table: lt, columns: lc, rows: lrows + rrows) + + case let (.update(lt, la, lr), .update(rt, ra, rr)): + // UPDATE batches by table + assignments (values must be identical) + guard lt == rt, la == ra, lr.count + rr.count <= maxBatchSize else { return nil } + return .update(table: lt, assignments: la, rowids: lr + rr) + + default: + return nil } - let valuesList = - rows.map { row -> String in - let vals = ([row.rowid] + row.values.map(\.raw)).joined(separator: ",") as String - return "(\(vals))" as String - }.joined(separator: ",") - return "INSERT INTO \"\(table.name)\"(\(colList)) VALUES\(valuesList)" as String -} - -func batchedUpdateSQL( - table: QuotedIdentifier, columns: [QuotedIdentifier], - rows: [(rowid: Substring, values: [QuotedValue])] -) -> String { - let setExprs = - columns.map { "\"\($0.name)\"=_v.\"\($0.name)\"" as String }.joined(separator: ",") as String - let valueRows = - rows.map { row -> String in - let vals = ([row.rowid] + row.values.map(\.raw)).joined(separator: ",") as String - return "(\(vals))" as String - }.joined(separator: ",") as String - let aliases = - (["_r"] + columns.map { "\"\($0.name)\"" as String }).joined(separator: ",") as String - return - "WITH _v(\(aliases)) AS (VALUES \(valueRows)) UPDATE \"\(table.name)\" SET \(setExprs) FROM _v WHERE \"\(table.name)\".rowid=_v._r" - as String } diff --git a/Tests/SQLiteUndoTests/SQLParserTests.swift b/Tests/SQLiteUndoTests/SQLParserTests.swift index e167673..039f75e 100644 --- a/Tests/SQLiteUndoTests/SQLParserTests.swift +++ b/Tests/SQLiteUndoTests/SQLParserTests.swift @@ -43,33 +43,34 @@ struct SQLParserTests { @Test func deleteParseValues() throws { let parsed = try parseSQL(#"DELETE FROM "t" WHERE rowid=42"#) - guard case let .delete(table, rowid) = parsed else { + guard case let .delete(table, rowids) = parsed else { Issue.record("Expected delete, got \(parsed)") return } #expect(table.name == "t") - #expect(rowid == "42") + #expect(rowids.map(String.init) == ["42"]) } @Test func insertParseValues() throws { let parsed = try parseSQL( #"INSERT INTO "t"(rowid,"a","b") VALUES(1,'hello',NULL)"#) - guard case let .insert(table, columns, rowid, values) = parsed else { + guard case let .insert(table, columns, rows) = parsed else { Issue.record("Expected insert, got \(parsed)") return } #expect(table.name == "t") #expect(columns.map(\.name) == ["a", "b"]) - #expect(rowid == "1") - #expect(values.map(\.raw) == ["'hello'", "NULL"]) + #expect(rows.count == 1) + #expect(String(rows[0].rowid) == "1") + #expect(rows[0].values.map(\.raw).map(String.init) == ["'hello'", "NULL"]) } @Test func updateParseValues() throws { let parsed = try parseSQL( #"UPDATE "t" SET "a"='x',"b"=42 WHERE rowid=1"#) - guard case let .update(table, assignments, rowid) = parsed else { + guard case let .update(table, assignments, rowids) = parsed else { Issue.record("Expected update, got \(parsed)") return } @@ -79,21 +80,23 @@ struct SQLParserTests { #expect(assignments[0].value.raw == "'x'") #expect(assignments[1].column.name == "b") #expect(assignments[1].value.raw == "42") - #expect(rowid == "1") + #expect(rowids.map(String.init) == ["1"]) } @Test - func batchedDelete() { - let sql = batchedDeleteSQL( + func batchedDeletePrint() throws { + let batched = UndoSQL.delete( table: QuotedIdentifier(name: "t"), rowids: ["1", "2", "3"] ) - #expect(sql == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) + var output = Substring() + try UndoSQLParser().print(batched, into: &output) + #expect(String(output) == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) } @Test - func batchedInsert() { - let sql = batchedInsertSQL( + func batchedInsertPrint() throws { + let batched = UndoSQL.insert( table: QuotedIdentifier(name: "t"), columns: [QuotedIdentifier(name: "a")], rows: [ @@ -101,24 +104,51 @@ struct SQLParserTests { (rowid: "2"[...], values: [QuotedValue(raw: "'y'")]), ] ) - #expect(sql == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) + var output = Substring() + try UndoSQLParser().print(batched, into: &output) + #expect(String(output) == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) } @Test - func batchedUpdate() { - let sql = batchedUpdateSQL( + func batchedUpdatePrint() throws { + let batched = UndoSQL.update( table: QuotedIdentifier(name: "t"), - columns: [QuotedIdentifier(name: "a")], - rows: [ - (rowid: "1"[...], values: [QuotedValue(raw: "'x'")]), - (rowid: "2"[...], values: [QuotedValue(raw: "'y'")]), - ] + assignments: [ + ColumnAssignment(column: QuotedIdentifier(name: "a"), value: QuotedValue(raw: "'x'")), + ], + rowids: ["1", "2"] ) + var output = Substring() + try UndoSQLParser().print(batched, into: &output) #expect( - sql - == #"WITH _v(_r,"a") AS (VALUES (1,'x'),(2,'y')) UPDATE "t" SET "a"=_v."a" FROM _v WHERE "t".rowid=_v._r"# + String(output) == #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"# ) } + + @Test + func updateDifferentAssignmentsNotBatched() { + let entries: [UndoLogEntry] = [ + UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='x' WHERE rowid=1"#), + UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='y' WHERE rowid=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 "t" SET "a"='x' WHERE rowid=1"#), + UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='x' WHERE rowid=2"#), + ] + let result = batchedSQL(from: entries) + #expect(result == [ + #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#, + ]) + } } private func parseSQL(_ sql: String) throws -> UndoSQL { From 7e993b6cc54006c222e8507cb11695ad76f7aea9 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 15:43:34 -0700 Subject: [PATCH 06/11] simplified parsing format and sparse update recording --- Package.resolved | 11 +- Package.swift | 2 - Sources/SQLiteUndo/SQLParser.swift | 410 ++++---------------- Sources/SQLiteUndo/UndoOperations.swift | 48 ++- Sources/SQLiteUndo/UndoTracked.swift | 41 +- Tests/SQLiteUndoTests/SQLParserTests.swift | 172 ++++---- Tests/SQLiteUndoTests/UndoEngineTests.swift | 17 +- 7 files changed, 250 insertions(+), 451 deletions(-) diff --git a/Package.resolved b/Package.resolved index a3f8cad..cbecb8a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "39c0cd6a1e693bd45231e39e57aa4320e926003a308eb8bd7d1a27f5a3eba3b8", + "originHash" : "5df21c4820606a0f3908478533dea9f2e659abf5a877660ce76321eead546594", "pins" : [ { "identity" : "combine-schedulers", @@ -109,15 +109,6 @@ "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.swift b/Package.swift index 4a2cd9c..6c08ede 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,6 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.22.3"), .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.9.5"), - .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.14.1"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.18.7"), .package(url: "https://github.com/pointfreeco/sqlite-data.git", from: "1.3.0"), ], @@ -27,7 +26,6 @@ let package = Package( dependencies: [ .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), - .product(name: "Parsing", package: "swift-parsing"), .product(name: "SQLiteData", package: "sqlite-data"), ] ), diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift index 5df2001..763d9f1 100644 --- a/Sources/SQLiteUndo/SQLParser.swift +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -1,361 +1,116 @@ -import Parsing - -/// Top-level parser for trigger-generated undo SQL. -struct UndoSQLParser: ParserPrinter { - func parse(_ input: inout Substring) throws -> UndoSQL { - let saved = input - do { return try DeleteSQLParser().parse(&input) } - catch { input = saved } - do { return try InsertSQLParser().parse(&input) } - catch { input = saved } - return try UpdateSQLParser().parse(&input) - } - - func print(_ output: UndoSQL, into input: inout Substring) throws { - switch output { - case .delete: try DeleteSQLParser().print(output, into: &input) - case .insert: try InsertSQLParser().print(output, into: &input) - case .update: try UpdateSQLParser().print(output, into: &input) - } - } -} - -/// Parsed representation of trigger-generated undo SQL. +/// Parsed representation of trigger-generated undo entries. /// Parsers produce single-element arrays; batching merges consecutive same-key entries. enum UndoSQL: Equatable, Sendable { - case delete( - table: QuotedIdentifier, - rowids: [Substring] - ) - case insert( - table: QuotedIdentifier, columns: [QuotedIdentifier], - rows: [(rowid: Substring, values: [QuotedValue])] - ) - case update( - table: QuotedIdentifier, - assignments: [ColumnAssignment], - rowids: [Substring] - ) + case delete(table: String, rowids: [String]) + case insert(table: String, columns: [String], rows: [(rowid: String, values: [String])]) + case update(table: String, assignments: [(column: String, value: String)], rowids: [String]) static func == (lhs: UndoSQL, rhs: UndoSQL) -> Bool { switch (lhs, rhs) { case let (.delete(lt, lr), .delete(rt, rr)): - return lt == rt && lr.map(String.init) == rr.map(String.init) + return lt == rt && lr == rr case let (.insert(lt, lc, lrows), .insert(rt, rc, rrows)): guard lt == rt && lc == rc && lrows.count == rrows.count else { return false } for (l, r) in zip(lrows, rrows) { - guard String(l.rowid) == String(r.rowid) && l.values == r.values else { return false } + guard l.rowid == r.rowid && l.values == r.values else { return false } } return true case let (.update(lt, la, lr), .update(rt, ra, rr)): - return lt == rt && la == ra && lr.map(String.init) == rr.map(String.init) + guard lt == rt && la.count == ra.count && lr == rr else { return false } + for (l, r) in zip(la, ra) { + guard l.column == r.column && l.value == r.value else { return false } + } + return true default: return false } } } -/// A double-quoted SQL identifier like `"tableName"` or `"columnName"`. -struct QuotedIdentifier: Equatable, Sendable { - var name: Substring -} +// MARK: - Tab-delimited parsing -/// An opaque value from SQLite `quote()` — boundaries found, content untouched. -struct QuotedValue: Equatable, Sendable { - var raw: Substring -} +/// Parse a tab-delimited undo entry into an UndoSQL value. +/// +/// Format: `TYPE\tTABLE\tROWID[\tCOL\tVAL]*` +/// - `D\t\t` → delete +/// - `I\t
\t\t\t...` → insert +/// - `U\t
\t\t\t...` → update +func parseUndoEntry(_ sql: String) -> UndoSQL? { + let parts = sql.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count >= 3 else { return nil } -/// A column=value pair in an UPDATE SET clause. -struct ColumnAssignment: Equatable, Sendable { - var column: QuotedIdentifier - var value: QuotedValue -} + let table = String(parts[1]) + let rowid = String(parts[2]) -// MARK: - Component ParserPrinters + switch parts[0] { + case "D": + return .delete(table: table, rowids: [rowid]) -struct QuotedIdentifierParser: ParserPrinter { - func parse(_ input: inout Substring) throws -> QuotedIdentifier { - guard input.first == "\"" else { - struct ExpectedQuote: Error {} - throw ExpectedQuote() - } - input.removeFirst() - guard let end = input.firstIndex(of: "\"") else { - struct UnterminatedIdentifier: Error {} - throw UnterminatedIdentifier() + 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 } - let name = input[.. QuotedValue { - let start = input.startIndex - - switch input.first { - case "'": - // String: 'text with ''escapes''' - input.removeFirst() - while !input.isEmpty { - if input.first == "'" { - input.removeFirst() - if input.first == "'" { - input.removeFirst() - } else { - return QuotedValue(raw: input.base[start.. start else { - struct ExpectedValue: Error {} - throw ExpectedValue() - } - return QuotedValue(raw: input.base[start.. start else { - struct ExpectedValue: Error {} - throw ExpectedValue() - } - return QuotedValue(raw: input.base[start.. UndoSQL { - guard input.hasPrefix("DELETE FROM ") else { - struct NotDelete: Error {} - throw NotDelete() - } - input.removeFirst(12) - let table = try QuotedIdentifierParser().parse(&input) - guard input.hasPrefix(" WHERE rowid=") else { - struct ExpectedWhere: Error {} - throw ExpectedWhere() - } - input.removeFirst(13) - let rowid = input - input = input[input.endIndex...] - return .delete(table: table, rowids: [rowid]) - } +// MARK: - SQL generation - func print(_ output: UndoSQL, into input: inout Substring) throws { - guard case let .delete(table, rowids) = output else { - struct NotDelete: Error {} - throw NotDelete() - } - var s = "DELETE FROM \"\(table.name)\"" as String +/// Generate executable SQL from a parsed UndoSQL value. +func generateSQL(_ entry: UndoSQL) -> String { + switch entry { + case let .delete(table, rowids): if rowids.count == 1 { - s += " WHERE rowid=" - s.append(contentsOf: rowids[0]) - } else { - s += " WHERE rowid IN (" - s += rowids.map(String.init).joined(separator: ",") - s += ")" + return "DELETE FROM \"\(table)\" WHERE rowid=\(rowids[0])" } - s.append(contentsOf: input) - input = Substring(s) - } -} + return "DELETE FROM \"\(table)\" WHERE rowid IN (\(rowids.joined(separator: ",")))" -struct InsertSQLParser: ParserPrinter { - func parse(_ input: inout Substring) throws -> UndoSQL { - guard input.hasPrefix("INSERT INTO ") else { - struct NotInsert: Error {} - throw NotInsert() - } - input.removeFirst(12) - let table = try QuotedIdentifierParser().parse(&input) - - guard input.hasPrefix("(rowid") else { - struct ExpectedRowid: Error {} - throw ExpectedRowid() - } - input.removeFirst(6) - - // Parse optional column list after rowid - var columns: [QuotedIdentifier] = [] - if input.first == "," { - input.removeFirst() - while true { - let col = try QuotedIdentifierParser().parse(&input) - columns.append(col) - if input.first == "," { - input.removeFirst() - } else { - break - } - } - } - - guard input.hasPrefix(") VALUES(") else { - struct ExpectedValues: Error {} - throw ExpectedValues() - } - input.removeFirst(9) - - // Parse rowid value - let rowidStart = input.startIndex - while !input.isEmpty && input.first != "," && input.first != ")" { - input.removeFirst() - } - let rowid = input.base[rowidStart.. 0 { s += "," } - s += "(" - s.append(contentsOf: row.rowid) + if i > 0 { sql += "," } + sql += "(" + sql += row.rowid for val in row.values { - s += "," - s.append(contentsOf: val.raw) - } - s += ")" - } - s.append(contentsOf: input) - input = Substring(s) - } -} - -struct UpdateSQLParser: ParserPrinter { - func parse(_ input: inout Substring) throws -> UndoSQL { - guard input.hasPrefix("UPDATE ") else { - struct NotUpdate: Error {} - throw NotUpdate() - } - input.removeFirst(7) - let table = try QuotedIdentifierParser().parse(&input) - - guard input.hasPrefix(" SET ") else { - struct ExpectedSet: Error {} - throw ExpectedSet() - } - input.removeFirst(5) - - // Parse assignments: "col"=val,"col2"=val2 - var assignments: [ColumnAssignment] = [] - while true { - let col = try QuotedIdentifierParser().parse(&input) - guard input.first == "=" else { - struct ExpectedEquals: Error {} - throw ExpectedEquals() + sql += "," + sql += val } - input.removeFirst() - let val = try QuotedValueParser().parse(&input) - assignments.append(ColumnAssignment(column: col, value: val)) - if input.first == "," { - input.removeFirst() - } else { - break - } - } - - guard input.hasPrefix(" WHERE rowid=") else { - struct ExpectedWhere: Error {} - throw ExpectedWhere() + sql += ")" } - input.removeFirst(13) - let rowid = input - input = input[input.endIndex...] - - return .update(table: table, assignments: assignments, rowids: [rowid]) - } + return sql - func print(_ output: UndoSQL, into input: inout Substring) throws { - guard case let .update(table, assignments, rowids) = output else { - struct NotUpdate: Error {} - throw NotUpdate() - } - var s = "UPDATE \"\(table.name)\" SET " as String - s += assignments.map { "\"\($0.column.name)\"=\($0.value.raw)" as String }.joined(separator: ",") + case let .update(table, assignments, rowids): + let set = assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",") if rowids.count == 1 { - s += " WHERE rowid=" - s.append(contentsOf: rowids[0]) - } else { - s += " WHERE rowid IN (" - s += rowids.map(String.init).joined(separator: ",") - s += ")" + return "UPDATE \"\(table)\" SET \(set) WHERE rowid=\(rowids[0])" } - s.append(contentsOf: input) - input = Substring(s) + return "UPDATE \"\(table)\" SET \(set) WHERE rowid IN (\(rowids.joined(separator: ",")))" } } @@ -372,14 +127,11 @@ nonisolated(unsafe) var _undoBatchingDisabled = false /// Key: table for DELETE/INSERT, table+assignments for UPDATE. func batchedSQL(from entries: [UndoLogEntry]) -> [String] { if _undoBatchingDisabled { - return entries.map(\.sql) + return entries.map { generateSQL(parseUndoEntry($0.sql)!) } } - let parser = UndoSQLParser() let items: [(sql: String, parsed: UndoSQL?)] = entries.map { entry in - var input = Substring(entry.sql) - let parsed = (try? parser.parse(&input)).flatMap { input.isEmpty ? $0 : nil } - return (entry.sql, parsed) + (entry.sql, parseUndoEntry(entry.sql)) } var remaining = items[...] @@ -398,10 +150,7 @@ func batchedSQL(from entries: [UndoLogEntry]) -> [String] { remaining.removeFirst() } - // Print via the parser-printer - var output = Substring() - try! parser.print(current, into: &output) - result.append(String(output)) + result.append(generateSQL(current)) } return result @@ -416,12 +165,15 @@ private func merge(_ lhs: UndoSQL, _ rhs: UndoSQL) -> UndoSQL? { case let (.insert(lt, lc, lrows), .insert(rt, _, rrows)): guard lt == rt, lrows.count + rrows.count <= maxBatchSize else { return nil } - // INSERT batches by table (columns come from the first entry) return .insert(table: lt, columns: lc, rows: lrows + rrows) case let (.update(lt, la, lr), .update(rt, ra, rr)): - // UPDATE batches by table + assignments (values must be identical) - guard lt == rt, la == ra, lr.count + rr.count <= maxBatchSize else { return nil } + guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } + // UPDATE batches by table + identical assignments + guard la.count == ra.count else { return nil } + for (l, r) in zip(la, ra) { + guard l.column == r.column && l.value == r.value else { return nil } + } return .update(table: lt, assignments: la, rowids: lr + rr) default: diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index a92cdd0..8ad9d4c 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -159,6 +159,7 @@ extension Database { } var seqsToDelete: [Int] = [] + var seqsToUpdate: [(seq: Int, sql: String)] = [] for (_, group) in groups { guard group.count > 1 else { continue } @@ -166,8 +167,8 @@ 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") + let firstIsDeleteReverse = first.sql.hasPrefix("D\t") + let lastIsInsertReverse = last.sql.hasPrefix("I\t") if firstIsDeleteReverse && lastIsInsertReverse { // INSERT then DELETE in same barrier → no-op, remove all @@ -181,16 +182,55 @@ extension Database { } } 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 mergedFirst: UndoSQL? + if first.sql.hasPrefix("U\t"), let parsed = parseUndoEntry(first.sql) { + mergedFirst = parsed + } + for entry in group.dropFirst() { - if entry.sql.hasPrefix("UPDATE") { + if entry.sql.hasPrefix("U\t") { + // Merge sparse update columns into the first update + if var merged = mergedFirst, + case let .update(table, existingAssignments, rowids) = merged, + let subsequent = parseUndoEntry(entry.sql), + case let .update(_, newAssignments, _) = subsequent + { + let existingColumns = Set(existingAssignments.map(\.column)) + let additions = newAssignments.filter { !existingColumns.contains($0.column) } + if !additions.isEmpty { + merged = .update( + table: table, assignments: existingAssignments + additions, rowids: rowids) + mergedFirst = merged + } + } seqsToDelete.append(entry.seq) } } + + // If we merged additional columns, update the first entry's SQL + if let merged = mergedFirst, case .update = merged { + let originalParsed = parseUndoEntry(first.sql) + if originalParsed != merged { + // Reconstruct as tab-delimited format + if case let .update(table, assignments, rowids) = merged { + var sql = "U\t" + table + "\t" + rowids[0] + for a in assignments { + sql += "\t" + a.column + "\t" + a.value + } + seqsToUpdate.append((seq: first.seq, sql: sql)) + } + } + } } } + for entry in seqsToUpdate { + try self.execute(sql: "UPDATE undolog SET sql = ? WHERE seq = ?", arguments: [entry.sql, 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/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 index 039f75e..3b9d08b 100644 --- a/Tests/SQLiteUndoTests/SQLParserTests.swift +++ b/Tests/SQLiteUndoTests/SQLParserTests.swift @@ -6,130 +6,132 @@ import Testing struct SQLParserTests { @Test(arguments: [ - #"DELETE FROM "testRecords" WHERE rowid=1"#, - #"DELETE FROM "testRecords" WHERE rowid=999"#, - #"DELETE FROM "my table" WHERE rowid=42"#, + ("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 deleteRoundTrip(_ sql: String) throws { - try assertRoundTrip(sql) + func deleteParseAndGenerate(_ input: String, _ expectedSQL: String) { + let parsed = parseUndoEntry(input)! + #expect(generateSQL(parsed) == expectedSQL) } @Test(arguments: [ - #"INSERT INTO "testRecords"(rowid,"id","name","value") VALUES(1,1,'hello',NULL)"#, - #"INSERT INTO "testRecords"(rowid,"id","name") VALUES(1,1,'it''s')"#, - #"INSERT INTO "testRecords"(rowid,"id","data") VALUES(1,1,X'ABCD')"#, - #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,-42)"#, - #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,3.14)"#, - #"INSERT INTO "testRecords"(rowid,"id","value") VALUES(1,1,1.5e10)"#, - #"INSERT INTO "t"(rowid) VALUES(1)"#, + ("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 insertRoundTrip(_ sql: String) throws { - try assertRoundTrip(sql) + func insertParseAndGenerate(_ input: String, _ expectedSQL: String) { + let parsed = parseUndoEntry(input)! + #expect(generateSQL(parsed) == expectedSQL) } @Test(arguments: [ - #"UPDATE "testRecords" SET "name"='hello',"value"=42 WHERE rowid=1"#, - #"UPDATE "testRecords" SET "name"=NULL WHERE rowid=1"#, - #"UPDATE "testRecords" SET "data"=X'ABCD' WHERE rowid=1"#, - #"UPDATE "testRecords" SET "value"=-3.14 WHERE rowid=1"#, - #"UPDATE "testRecords" SET "name"='it''s a test' WHERE rowid=1"#, - #"UPDATE "testRecords" SET "name"='hello world' WHERE rowid=1"#, - #"UPDATE "testRecords" SET "name"='comma,inside' WHERE rowid=1"#, + ("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 updateRoundTrip(_ sql: String) throws { - try assertRoundTrip(sql) + func updateParseAndGenerate(_ input: String, _ expectedSQL: String) { + let parsed = parseUndoEntry(input)! + #expect(generateSQL(parsed) == expectedSQL) } @Test - func deleteParseValues() throws { - let parsed = try parseSQL(#"DELETE FROM "t" WHERE rowid=42"#) + func deleteParseValues() { + let parsed = parseUndoEntry("D\tt\t42")! guard case let .delete(table, rowids) = parsed else { Issue.record("Expected delete, got \(parsed)") return } - #expect(table.name == "t") - #expect(rowids.map(String.init) == ["42"]) + #expect(table == "t") + #expect(rowids == ["42"]) } @Test - func insertParseValues() throws { - let parsed = try parseSQL( - #"INSERT INTO "t"(rowid,"a","b") VALUES(1,'hello',NULL)"#) + func insertParseValues() { + let parsed = parseUndoEntry("I\tt\t1\ta\t'hello'\tb\tNULL")! guard case let .insert(table, columns, rows) = parsed else { Issue.record("Expected insert, got \(parsed)") return } - #expect(table.name == "t") - #expect(columns.map(\.name) == ["a", "b"]) + #expect(table == "t") + #expect(columns == ["a", "b"]) #expect(rows.count == 1) - #expect(String(rows[0].rowid) == "1") - #expect(rows[0].values.map(\.raw).map(String.init) == ["'hello'", "NULL"]) + #expect(rows[0].rowid == "1") + #expect(rows[0].values == ["'hello'", "NULL"]) } @Test - func updateParseValues() throws { - let parsed = try parseSQL( - #"UPDATE "t" SET "a"='x',"b"=42 WHERE rowid=1"#) + func updateParseValues() { + let parsed = parseUndoEntry("U\tt\t1\ta\t'x'\tb\t42")! guard case let .update(table, assignments, rowids) = parsed else { Issue.record("Expected update, got \(parsed)") return } - #expect(table.name == "t") + #expect(table == "t") #expect(assignments.count == 2) - #expect(assignments[0].column.name == "a") - #expect(assignments[0].value.raw == "'x'") - #expect(assignments[1].column.name == "b") - #expect(assignments[1].value.raw == "42") - #expect(rowids.map(String.init) == ["1"]) + #expect(assignments[0].column == "a") + #expect(assignments[0].value == "'x'") + #expect(assignments[1].column == "b") + #expect(assignments[1].value == "42") + #expect(rowids == ["1"]) } @Test - func batchedDeletePrint() throws { - let batched = UndoSQL.delete( - table: QuotedIdentifier(name: "t"), - rowids: ["1", "2", "3"] - ) - var output = Substring() - try UndoSQLParser().print(batched, into: &output) - #expect(String(output) == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) + func batchedDeleteGenerate() { + let batched = UndoSQL.delete(table: "t", rowids: ["1", "2", "3"]) + #expect(generateSQL(batched) == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) } @Test - func batchedInsertPrint() throws { + func batchedInsertGenerate() { let batched = UndoSQL.insert( - table: QuotedIdentifier(name: "t"), - columns: [QuotedIdentifier(name: "a")], + table: "t", + columns: ["a"], rows: [ - (rowid: "1"[...], values: [QuotedValue(raw: "'x'")]), - (rowid: "2"[...], values: [QuotedValue(raw: "'y'")]), + (rowid: "1", values: ["'x'"]), + (rowid: "2", values: ["'y'"]), ] ) - var output = Substring() - try UndoSQLParser().print(batched, into: &output) - #expect(String(output) == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) + #expect(generateSQL(batched) == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) } @Test - func batchedUpdatePrint() throws { + func batchedUpdateGenerate() { let batched = UndoSQL.update( - table: QuotedIdentifier(name: "t"), - assignments: [ - ColumnAssignment(column: QuotedIdentifier(name: "a"), value: QuotedValue(raw: "'x'")), - ], + table: "t", + assignments: [(column: "a", value: "'x'")], rowids: ["1", "2"] ) - var output = Substring() - try UndoSQLParser().print(batched, into: &output) - #expect( - String(output) == #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"# - ) + #expect(generateSQL(batched) == #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#) } @Test func updateDifferentAssignmentsNotBatched() { let entries: [UndoLogEntry] = [ - UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='x' WHERE rowid=1"#), - UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='y' WHERE rowid=2"#), + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t1\ta\t'x'"), + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\ta\t'y'"), ] let result = batchedSQL(from: entries) #expect(result == [ @@ -141,26 +143,24 @@ struct SQLParserTests { @Test func updateSameAssignmentsBatched() { let entries: [UndoLogEntry] = [ - UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='x' WHERE rowid=1"#), - UndoLogEntry(seq: 0, tableName: "t", sql: #"UPDATE "t" SET "a"='x' WHERE rowid=2"#), + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t1\ta\t'x'"), + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\ta\t'x'"), ] let result = batchedSQL(from: entries) #expect(result == [ #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#, ]) } -} -private func parseSQL(_ sql: String) throws -> UndoSQL { - var input = Substring(sql) - let parsed = try UndoSQLParser().parse(&input) - #expect(input.isEmpty) - return parsed -} - -private func assertRoundTrip(_ sql: String) throws { - let parsed = try parseSQL(sql) - var output = Substring() - try UndoSQLParser().print(parsed, into: &output) - #expect(String(output) == sql) + @Test + func sparseUpdateOnlyChangedColumns() { + let entries: [UndoLogEntry] = [ + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t1\tvalue\t42"), + UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\tvalue\t42"), + ] + let result = batchedSQL(from: entries) + #expect(result == [ + #"UPDATE "t" SET "value"=42 WHERE rowid IN (1,2)"#, + ]) + } } diff --git a/Tests/SQLiteUndoTests/UndoEngineTests.swift b/Tests/SQLiteUndoTests/UndoEngineTests.swift index ecbdc9c..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 """ } From 85ec1591c9f5974f693892889205068d78506c15 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 15:51:42 -0700 Subject: [PATCH 07/11] tidy --- Sources/SQLiteUndo/SQLParser.swift | 21 +++++++++++++ Sources/SQLiteUndo/UndoOperations.swift | 40 ++++++++++++------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift index 763d9f1..a4e2ef8 100644 --- a/Sources/SQLiteUndo/SQLParser.swift +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -71,6 +71,27 @@ func parseUndoEntry(_ sql: String) -> UndoSQL? { } } +/// Convert an UndoSQL value back to tab-delimited storage format. +func formatUndoEntry(_ entry: UndoSQL) -> String { + switch entry { + case let .delete(table, rowids): + return "D\t" + table + "\t" + rowids[0] + case let .insert(table, columns, rows): + let row = rows[0] + var sql = "I\t" + table + "\t" + row.rowid + for (col, val) in zip(columns, row.values) { + sql += "\t" + col + "\t" + val + } + return sql + case let .update(table, assignments, rowids): + var sql = "U\t" + table + "\t" + rowids[0] + for a in assignments { + sql += "\t" + a.column + "\t" + a.value + } + return sql + } +} + // MARK: - SQL generation /// Generate executable SQL from a parsed UndoSQL value. diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 8ad9d4c..89cadd2 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -185,43 +185,41 @@ extension Database { // 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 mergedFirst: UndoSQL? - if first.sql.hasPrefix("U\t"), let parsed = parseUndoEntry(first.sql) { - mergedFirst = parsed + let originalParsed = + first.sql.hasPrefix("U\t") ? parseUndoEntry(first.sql) : nil + var mergedAssignments: [(column: String, value: String)]? + var mergedTable: String? + var mergedRowids: [String]? + + if case let .update(table, assignments, rowids) = originalParsed { + mergedAssignments = assignments + mergedTable = table + mergedRowids = rowids } for entry in group.dropFirst() { if entry.sql.hasPrefix("U\t") { - // Merge sparse update columns into the first update - if var merged = mergedFirst, - case let .update(table, existingAssignments, rowids) = merged, + if var assignments = mergedAssignments, let subsequent = parseUndoEntry(entry.sql), case let .update(_, newAssignments, _) = subsequent { - let existingColumns = Set(existingAssignments.map(\.column)) + let existingColumns = Set(assignments.map(\.column)) let additions = newAssignments.filter { !existingColumns.contains($0.column) } if !additions.isEmpty { - merged = .update( - table: table, assignments: existingAssignments + additions, rowids: rowids) - mergedFirst = merged + assignments += additions + mergedAssignments = assignments } } seqsToDelete.append(entry.seq) } } - // If we merged additional columns, update the first entry's SQL - if let merged = mergedFirst, case .update = merged { - let originalParsed = parseUndoEntry(first.sql) + if let table = mergedTable, let assignments = mergedAssignments, + let rowids = mergedRowids + { + let merged = UndoSQL.update(table: table, assignments: assignments, rowids: rowids) if originalParsed != merged { - // Reconstruct as tab-delimited format - if case let .update(table, assignments, rowids) = merged { - var sql = "U\t" + table + "\t" + rowids[0] - for a in assignments { - sql += "\t" + a.column + "\t" + a.value - } - seqsToUpdate.append((seq: first.seq, sql: sql)) - } + seqsToUpdate.append((seq: first.seq, sql: formatUndoEntry(merged))) } } } From 37b4c372ddc318d0c0f4c577970d45cdd4351cb4 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 16:10:51 -0700 Subject: [PATCH 08/11] use UndoSQL as first class type on db record --- Sources/SQLiteUndo/SQLParser.swift | 278 +++++++++------------ Sources/SQLiteUndo/UndoOperations.swift | 38 +-- Sources/SQLiteUndo/UndoSchema.swift | 54 +++- Tests/SQLiteUndoTests/SQLParserTests.swift | 48 ++-- 4 files changed, 216 insertions(+), 202 deletions(-) diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift index a4e2ef8..66f3292 100644 --- a/Sources/SQLiteUndo/SQLParser.swift +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -1,137 +1,109 @@ -/// Parsed representation of trigger-generated undo entries. -/// Parsers produce single-element arrays; batching merges consecutive same-key entries. -enum UndoSQL: Equatable, Sendable { - case delete(table: String, rowids: [String]) - case insert(table: String, columns: [String], rows: [(rowid: String, values: [String])]) - case update(table: String, assignments: [(column: String, value: String)], rowids: [String]) - - static func == (lhs: UndoSQL, rhs: UndoSQL) -> Bool { - switch (lhs, rhs) { - case let (.delete(lt, lr), .delete(rt, rr)): - return lt == rt && lr == rr - case let (.insert(lt, lc, lrows), .insert(rt, rc, rrows)): - guard lt == rt && lc == rc && lrows.count == rrows.count else { return false } - for (l, r) in zip(lrows, rrows) { - guard l.rowid == r.rowid && l.values == r.values else { return false } - } - return true - case let (.update(lt, la, lr), .update(rt, ra, rr)): - guard lt == rt && la.count == ra.count && lr == rr else { return false } - for (l, r) in zip(la, ra) { - guard l.column == r.column && l.value == r.value else { return false } - } - return true - default: - return false - } - } -} // MARK: - Tab-delimited parsing -/// Parse a tab-delimited undo entry into an UndoSQL value. -/// -/// Format: `TYPE\tTABLE\tROWID[\tCOL\tVAL]*` -/// - `D\t
\t` → delete -/// - `I\t
\t\t\t...` → insert -/// - `U\t
\t\t\t...` → update -func parseUndoEntry(_ sql: String) -> UndoSQL? { - 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": - return .delete(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 - } - return .insert(table: table, columns: columns, rows: [(rowid: rowid, values: values)]) - - case "U": - var assignments: [(column: String, value: String)] = [] - var i = 3 - while i + 1 < parts.count { - assignments.append((column: String(parts[i]), value: String(parts[i + 1]))) - i += 2 - } - return .update(table: table, assignments: assignments, rowids: [rowid]) +extension UndoSQL { + /// Parse a tab-delimited undo entry into an UndoSQL value. + /// + /// 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(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(table: table, columns: columns, rows: [(rowid: rowid, values: values)]) + + case "U": + var assignments: [(column: String, value: String)] = [] + var i = 3 + while i + 1 < parts.count { + assignments.append((column: String(parts[i]), value: String(parts[i + 1]))) + i += 2 + } + self = .update(table: table, assignments: assignments, rowids: [rowid]) - default: - return nil + default: + return nil + } } -} -/// Convert an UndoSQL value back to tab-delimited storage format. -func formatUndoEntry(_ entry: UndoSQL) -> String { - switch entry { - case let .delete(table, rowids): - return "D\t" + table + "\t" + rowids[0] - case let .insert(table, columns, rows): - let row = rows[0] - var sql = "I\t" + table + "\t" + row.rowid - for (col, val) in zip(columns, row.values) { - sql += "\t" + col + "\t" + val - } - return sql - case let .update(table, assignments, rowids): - var sql = "U\t" + table + "\t" + rowids[0] - for a in assignments { - sql += "\t" + a.column + "\t" + a.value + /// Convert to tab-delimited storage format. + var tabDelimited: String { + switch self { + case let .delete(table, rowids): + return "D\t" + table + "\t" + rowids[0] + case let .insert(table, columns, rows): + let row = rows[0] + var sql = "I\t" + table + "\t" + row.rowid + for (col, val) in zip(columns, row.values) { + sql += "\t" + col + "\t" + val + } + return sql + case let .update(table, assignments, rowids): + var sql = "U\t" + table + "\t" + rowids[0] + for a in assignments { + sql += "\t" + a.column + "\t" + a.value + } + return sql } - return sql } -} -// MARK: - SQL generation - -/// Generate executable SQL from a parsed UndoSQL value. -func generateSQL(_ entry: UndoSQL) -> String { - switch entry { - case let .delete(table, rowids): - if rowids.count == 1 { - return "DELETE FROM \"\(table)\" WHERE rowid=\(rowids[0])" - } - return "DELETE FROM \"\(table)\" WHERE rowid IN (\(rowids.joined(separator: ",")))" - - case let .insert(table, columns, rows): - var sql = "INSERT INTO \"" - sql += table - sql += "\"(" - if columns.isEmpty { - sql += "rowid" - } else { - sql += "rowid," - sql += columns.map { "\"" + $0 + "\"" }.joined(separator: ",") - } - sql += ") VALUES" - for (i, row) in rows.enumerated() { - if i > 0 { sql += "," } - sql += "(" - sql += row.rowid - for val in row.values { - sql += "," - sql += val + /// Generate executable SQL. + var executableSQL: String { + switch self { + case let .delete(table, rowids): + if rowids.count == 1 { + return "DELETE FROM \"\(table)\" WHERE rowid=\(rowids[0])" } - sql += ")" - } - return sql + return "DELETE FROM \"\(table)\" WHERE rowid IN (\(rowids.joined(separator: ",")))" + + case let .insert(table, columns, rows): + var sql = "INSERT INTO \"" + sql += table + sql += "\"(" + if columns.isEmpty { + sql += "rowid" + } else { + sql += "rowid," + sql += columns.map { "\"" + $0 + "\"" }.joined(separator: ",") + } + sql += ") VALUES" + for (i, row) in rows.enumerated() { + if i > 0 { sql += "," } + sql += "(" + sql += row.rowid + for val in row.values { + sql += "," + sql += val + } + sql += ")" + } + return sql - case let .update(table, assignments, rowids): - let set = assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",") - if rowids.count == 1 { - return "UPDATE \"\(table)\" SET \(set) WHERE rowid=\(rowids[0])" + case let .update(table, assignments, rowids): + let set = assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",") + if rowids.count == 1 { + return "UPDATE \"\(table)\" SET \(set) WHERE rowid=\(rowids[0])" + } + return "UPDATE \"\(table)\" SET \(set) WHERE rowid IN (\(rowids.joined(separator: ",")))" } - return "UPDATE \"\(table)\" SET \(set) WHERE rowid IN (\(rowids.joined(separator: ",")))" } } @@ -148,56 +120,50 @@ nonisolated(unsafe) var _undoBatchingDisabled = false /// Key: table for DELETE/INSERT, table+assignments for UPDATE. func batchedSQL(from entries: [UndoLogEntry]) -> [String] { if _undoBatchingDisabled { - return entries.map { generateSQL(parseUndoEntry($0.sql)!) } + return entries.map(\.sql.executableSQL) } - let items: [(sql: String, parsed: UndoSQL?)] = entries.map { entry in - (entry.sql, parseUndoEntry(entry.sql)) - } - - var remaining = items[...] + var remaining = entries[...] var result: [String] = [] while let first = remaining.popFirst() { - guard var current = first.parsed else { - result.append(first.sql) - continue - } + var current = first.sql // Merge consecutive same-key entries - while remaining.first?.parsed != nil { - guard let merged = merge(current, remaining.first!.parsed!) else { break } + while !remaining.isEmpty { + guard let merged = current.merging(remaining.first!.sql) else { break } current = merged remaining.removeFirst() } - result.append(generateSQL(current)) + result.append(current.executableSQL) } return result } -/// Merge two UndoSQL values if they share the same grouping key. -private func merge(_ lhs: UndoSQL, _ rhs: UndoSQL) -> UndoSQL? { - switch (lhs, rhs) { - case let (.delete(lt, lr), .delete(rt, rr)): - guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } - return .delete(table: lt, rowids: lr + rr) - - case let (.insert(lt, lc, lrows), .insert(rt, _, rrows)): - guard lt == rt, lrows.count + rrows.count <= maxBatchSize else { return nil } - return .insert(table: lt, columns: lc, rows: lrows + rrows) - - case let (.update(lt, la, lr), .update(rt, ra, rr)): - guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } - // UPDATE batches by table + identical assignments - guard la.count == ra.count else { return nil } - for (l, r) in zip(la, ra) { - guard l.column == r.column && l.value == r.value else { return nil } - } - return .update(table: lt, assignments: la, rowids: lr + rr) +extension UndoSQL { + /// Merge with another UndoSQL if they share the same grouping key. + func merging(_ other: UndoSQL) -> UndoSQL? { + switch (self, other) { + case let (.delete(lt, lr), .delete(rt, rr)): + guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } + return .delete(table: lt, rowids: lr + rr) + + case let (.insert(lt, lc, lrows), .insert(rt, _, rrows)): + guard lt == rt, lrows.count + rrows.count <= maxBatchSize else { return nil } + return .insert(table: lt, columns: lc, rows: lrows + rrows) - default: - return nil + case let (.update(lt, la, lr), .update(rt, ra, rr)): + guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } + guard la.count == ra.count else { return nil } + for (l, r) in zip(la, ra) { + guard l.column == r.column && l.value == r.value else { return nil } + } + return .update(table: lt, assignments: la, rowids: lr + rr) + + default: + return nil + } } } diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 89cadd2..e49078c 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -159,7 +159,7 @@ extension Database { } var seqsToDelete: [Int] = [] - var seqsToUpdate: [(seq: Int, sql: String)] = [] + var seqsToUpdate: [(seq: Int, sql: UndoSQL)] = [] for (_, group) in groups { guard group.count > 1 else { continue } @@ -167,15 +167,12 @@ extension Database { let first = group[0] let last = group[group.count - 1] - let firstIsDeleteReverse = first.sql.hasPrefix("D\t") - let lastIsInsertReverse = last.sql.hasPrefix("I\t") - - 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) @@ -185,24 +182,14 @@ extension Database { // 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). - let originalParsed = - first.sql.hasPrefix("U\t") ? parseUndoEntry(first.sql) : nil var mergedAssignments: [(column: String, value: String)]? - var mergedTable: String? - var mergedRowids: [String]? - - if case let .update(table, assignments, rowids) = originalParsed { + if case let .update(_, assignments, _) = first.sql { mergedAssignments = assignments - mergedTable = table - mergedRowids = rowids } for entry in group.dropFirst() { - if entry.sql.hasPrefix("U\t") { - if var assignments = mergedAssignments, - let subsequent = parseUndoEntry(entry.sql), - case let .update(_, newAssignments, _) = subsequent - { + if case let .update(_, newAssignments, _) = entry.sql { + if var assignments = mergedAssignments { let existingColumns = Set(assignments.map(\.column)) let additions = newAssignments.filter { !existingColumns.contains($0.column) } if !additions.isEmpty { @@ -214,19 +201,18 @@ extension Database { } } - if let table = mergedTable, let assignments = mergedAssignments, - let rowids = mergedRowids + if case let .update(table, originalAssignments, rowids) = first.sql, + let assignments = mergedAssignments, assignments.count > originalAssignments.count { - let merged = UndoSQL.update(table: table, assignments: assignments, rowids: rowids) - if originalParsed != merged { - seqsToUpdate.append((seq: first.seq, sql: formatUndoEntry(merged))) - } + seqsToUpdate.append( + (seq: first.seq, sql: .update(table: table, assignments: assignments, rowids: rowids))) } } } for entry in seqsToUpdate { - try self.execute(sql: "UPDATE undolog SET sql = ? WHERE seq = ?", arguments: [entry.sql, entry.seq]) + let text = entry.sql.tabDelimited + try self.execute(sql: "UPDATE undolog SET sql = ? WHERE seq = ?", arguments: [text, entry.seq]) } if !seqsToDelete.isEmpty { diff --git a/Sources/SQLiteUndo/UndoSchema.swift b/Sources/SQLiteUndo/UndoSchema.swift index 4c8237e..cc80b6e 100644 --- a/Sources/SQLiteUndo/UndoSchema.swift +++ b/Sources/SQLiteUndo/UndoSchema.swift @@ -13,8 +13,58 @@ 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. +/// Parsers produce single-element arrays; batching merges consecutive same-key entries. +enum UndoSQL: Equatable, Sendable { + case delete(table: String, rowids: [String]) + case insert(table: String, columns: [String], rows: [(rowid: String, values: [String])]) + case update(table: String, assignments: [(column: String, value: String)], rowids: [String]) + + static func == (lhs: UndoSQL, rhs: UndoSQL) -> Bool { + switch (lhs, rhs) { + case let (.delete(lt, lr), .delete(rt, rr)): + return lt == rt && lr == rr + case let (.insert(lt, lc, lrows), .insert(rt, rc, rrows)): + guard lt == rt && lc == rc && lrows.count == rrows.count else { return false } + for (l, r) in zip(lrows, rrows) { + guard l.rowid == r.rowid && l.values == r.values else { return false } + } + return true + case let (.update(lt, la, lr), .update(rt, ra, rr)): + guard lt == rt && la.count == ra.count && lr == rr else { return false } + for (l, r) in zip(la, ra) { + guard l.column == r.column && l.value == r.value else { return false } + } + return true + default: + return false + } + } +} + +extension UndoSQL: _OptionalPromotable {} + +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: QueryRepresentable {} + +extension UndoSQL: QueryExpression { + typealias QueryValue = Self +} + +extension UndoSQL: QueryBindable { + var queryBinding: QueryBinding { .text(self.tabDelimited) } } extension DatabaseWriter { diff --git a/Tests/SQLiteUndoTests/SQLParserTests.swift b/Tests/SQLiteUndoTests/SQLParserTests.swift index 3b9d08b..971765a 100644 --- a/Tests/SQLiteUndoTests/SQLParserTests.swift +++ b/Tests/SQLiteUndoTests/SQLParserTests.swift @@ -11,8 +11,8 @@ struct SQLParserTests { ("D\tmy table\t42", #"DELETE FROM "my table" WHERE rowid=42"#), ]) func deleteParseAndGenerate(_ input: String, _ expectedSQL: String) { - let parsed = parseUndoEntry(input)! - #expect(generateSQL(parsed) == expectedSQL) + let parsed = UndoSQL(tabDelimited: input)! + #expect(parsed.executableSQL == expectedSQL) } @Test(arguments: [ @@ -32,8 +32,8 @@ struct SQLParserTests { #"INSERT INTO "t"(rowid) VALUES(1)"#), ]) func insertParseAndGenerate(_ input: String, _ expectedSQL: String) { - let parsed = parseUndoEntry(input)! - #expect(generateSQL(parsed) == expectedSQL) + let parsed = UndoSQL(tabDelimited: input)! + #expect(parsed.executableSQL == expectedSQL) } @Test(arguments: [ @@ -53,13 +53,13 @@ struct SQLParserTests { #"UPDATE "testRecords" SET "name"='comma,inside' WHERE rowid=1"#), ]) func updateParseAndGenerate(_ input: String, _ expectedSQL: String) { - let parsed = parseUndoEntry(input)! - #expect(generateSQL(parsed) == expectedSQL) + let parsed = UndoSQL(tabDelimited: input)! + #expect(parsed.executableSQL == expectedSQL) } @Test func deleteParseValues() { - let parsed = parseUndoEntry("D\tt\t42")! + let parsed = UndoSQL(tabDelimited: "D\tt\t42")! guard case let .delete(table, rowids) = parsed else { Issue.record("Expected delete, got \(parsed)") return @@ -70,7 +70,7 @@ struct SQLParserTests { @Test func insertParseValues() { - let parsed = parseUndoEntry("I\tt\t1\ta\t'hello'\tb\tNULL")! + let parsed = UndoSQL(tabDelimited: "I\tt\t1\ta\t'hello'\tb\tNULL")! guard case let .insert(table, columns, rows) = parsed else { Issue.record("Expected insert, got \(parsed)") return @@ -84,7 +84,7 @@ struct SQLParserTests { @Test func updateParseValues() { - let parsed = parseUndoEntry("U\tt\t1\ta\t'x'\tb\t42")! + let parsed = UndoSQL(tabDelimited: "U\tt\t1\ta\t'x'\tb\t42")! guard case let .update(table, assignments, rowids) = parsed else { Issue.record("Expected update, got \(parsed)") return @@ -101,7 +101,7 @@ struct SQLParserTests { @Test func batchedDeleteGenerate() { let batched = UndoSQL.delete(table: "t", rowids: ["1", "2", "3"]) - #expect(generateSQL(batched) == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) + #expect(batched.executableSQL == #"DELETE FROM "t" WHERE rowid IN (1,2,3)"#) } @Test @@ -114,7 +114,7 @@ struct SQLParserTests { (rowid: "2", values: ["'y'"]), ] ) - #expect(generateSQL(batched) == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) + #expect(batched.executableSQL == #"INSERT INTO "t"(rowid,"a") VALUES(1,'x'),(2,'y')"#) } @Test @@ -124,14 +124,18 @@ struct SQLParserTests { assignments: [(column: "a", value: "'x'")], rowids: ["1", "2"] ) - #expect(generateSQL(batched) == #"UPDATE "t" SET "a"='x' WHERE rowid IN (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: "U\tt\t1\ta\t'x'"), - UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\ta\t'y'"), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["1"])), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "a", value: "'y'")], rowids: ["2"])), ] let result = batchedSQL(from: entries) #expect(result == [ @@ -143,8 +147,12 @@ struct SQLParserTests { @Test func updateSameAssignmentsBatched() { let entries: [UndoLogEntry] = [ - UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t1\ta\t'x'"), - UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\ta\t'x'"), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["1"])), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["2"])), ] let result = batchedSQL(from: entries) #expect(result == [ @@ -155,8 +163,12 @@ struct SQLParserTests { @Test func sparseUpdateOnlyChangedColumns() { let entries: [UndoLogEntry] = [ - UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t1\tvalue\t42"), - UndoLogEntry(seq: 0, tableName: "t", sql: "U\tt\t2\tvalue\t42"), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "value", value: "42")], rowids: ["1"])), + UndoLogEntry( + seq: 0, tableName: "t", + sql: .update(table: "t", assignments: [(column: "value", value: "42")], rowids: ["2"])), ] let result = batchedSQL(from: entries) #expect(result == [ From 8b78ca51090946e52575f57b6e769718da623f8c Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 16:18:30 -0700 Subject: [PATCH 09/11] clean up UndoSQL with internal types --- Sources/SQLiteUndo/SQLParser.swift | 89 +++++++++++----------- Sources/SQLiteUndo/UndoOperations.swift | 21 ++--- Sources/SQLiteUndo/UndoSchema.swift | 50 ++++++------ Tests/SQLiteUndoTests/SQLParserTests.swift | 63 ++++++++------- 4 files changed, 115 insertions(+), 108 deletions(-) diff --git a/Sources/SQLiteUndo/SQLParser.swift b/Sources/SQLiteUndo/SQLParser.swift index 66f3292..7eb2883 100644 --- a/Sources/SQLiteUndo/SQLParser.swift +++ b/Sources/SQLiteUndo/SQLParser.swift @@ -2,7 +2,7 @@ // MARK: - Tab-delimited parsing extension UndoSQL { - /// Parse a tab-delimited undo entry into an UndoSQL value. + /// Parse a tab-delimited undo entry. /// /// Format: `TYPE\tTABLE\tROWID[\tCOL\tVAL]*` /// - `D\t
\t` → delete @@ -17,7 +17,7 @@ extension UndoSQL { switch parts[0] { case "D": - self = .delete(table: table, rowids: [rowid]) + self = .delete(DeleteSQL(table: table, rowids: [rowid])) case "I": var columns: [String] = [] @@ -28,16 +28,19 @@ extension UndoSQL { values.append(String(parts[i + 1])) i += 2 } - self = .insert(table: table, columns: columns, rows: [(rowid: rowid, values: values)]) + self = .insert(InsertSQL( + table: table, columns: columns, + rows: [InsertSQL.Row(rowid: rowid, values: values)])) case "U": - var assignments: [(column: String, value: String)] = [] + var assignments: [UpdateSQL.Assignment] = [] var i = 3 while i + 1 < parts.count { - assignments.append((column: String(parts[i]), value: String(parts[i + 1]))) + assignments.append(UpdateSQL.Assignment( + column: String(parts[i]), value: String(parts[i + 1]))) i += 2 } - self = .update(table: table, assignments: assignments, rowids: [rowid]) + self = .update(UpdateSQL(table: table, assignments: assignments, rowids: [rowid])) default: return nil @@ -47,18 +50,18 @@ extension UndoSQL { /// Convert to tab-delimited storage format. var tabDelimited: String { switch self { - case let .delete(table, rowids): - return "D\t" + table + "\t" + rowids[0] - case let .insert(table, columns, rows): - let row = rows[0] - var sql = "I\t" + table + "\t" + row.rowid - for (col, val) in zip(columns, row.values) { + 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(table, assignments, rowids): - var sql = "U\t" + table + "\t" + rowids[0] - for a in assignments { + 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 @@ -68,24 +71,24 @@ extension UndoSQL { /// Generate executable SQL. var executableSQL: String { switch self { - case let .delete(table, rowids): - if rowids.count == 1 { - return "DELETE FROM \"\(table)\" WHERE rowid=\(rowids[0])" + case let .delete(d): + if d.rowids.count == 1 { + return "DELETE FROM \"\(d.table)\" WHERE rowid=\(d.rowids[0])" } - return "DELETE FROM \"\(table)\" WHERE rowid IN (\(rowids.joined(separator: ",")))" + return "DELETE FROM \"\(d.table)\" WHERE rowid IN (\(d.rowids.joined(separator: ",")))" - case let .insert(table, columns, rows): + case let .insert(ins): var sql = "INSERT INTO \"" - sql += table + sql += ins.table sql += "\"(" - if columns.isEmpty { + if ins.columns.isEmpty { sql += "rowid" } else { sql += "rowid," - sql += columns.map { "\"" + $0 + "\"" }.joined(separator: ",") + sql += ins.columns.map { "\"" + $0 + "\"" }.joined(separator: ",") } sql += ") VALUES" - for (i, row) in rows.enumerated() { + for (i, row) in ins.rows.enumerated() { if i > 0 { sql += "," } sql += "(" sql += row.rowid @@ -97,12 +100,12 @@ extension UndoSQL { } return sql - case let .update(table, assignments, rowids): - let set = assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",") - if rowids.count == 1 { - return "UPDATE \"\(table)\" SET \(set) WHERE rowid=\(rowids[0])" + 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 \"\(table)\" SET \(set) WHERE rowid IN (\(rowids.joined(separator: ",")))" + return "UPDATE \"\(upd.table)\" SET \(set) WHERE rowid IN (\(upd.rowids.joined(separator: ",")))" } } } @@ -146,21 +149,19 @@ extension UndoSQL { /// Merge with another UndoSQL if they share the same grouping key. func merging(_ other: UndoSQL) -> UndoSQL? { switch (self, other) { - case let (.delete(lt, lr), .delete(rt, rr)): - guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } - return .delete(table: lt, rowids: lr + rr) - - case let (.insert(lt, lc, lrows), .insert(rt, _, rrows)): - guard lt == rt, lrows.count + rrows.count <= maxBatchSize else { return nil } - return .insert(table: lt, columns: lc, rows: lrows + rrows) - - case let (.update(lt, la, lr), .update(rt, ra, rr)): - guard lt == rt, lr.count + rr.count <= maxBatchSize else { return nil } - guard la.count == ra.count else { return nil } - for (l, r) in zip(la, ra) { - guard l.column == r.column && l.value == r.value else { return nil } - } - return .update(table: lt, assignments: la, rowids: lr + rr) + 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 e49078c..cfb9f0f 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -182,16 +182,16 @@ extension Database { // 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: [(column: String, value: String)]? - if case let .update(_, assignments, _) = first.sql { - mergedAssignments = assignments + var mergedAssignments: [UndoSQL.UpdateSQL.Assignment]? + if case let .update(upd) = first.sql { + mergedAssignments = upd.assignments } for entry in group.dropFirst() { - if case let .update(_, newAssignments, _) = entry.sql { + if case let .update(upd) = entry.sql { if var assignments = mergedAssignments { let existingColumns = Set(assignments.map(\.column)) - let additions = newAssignments.filter { !existingColumns.contains($0.column) } + let additions = upd.assignments.filter { !existingColumns.contains($0.column) } if !additions.isEmpty { assignments += additions mergedAssignments = assignments @@ -201,11 +201,14 @@ extension Database { } } - if case let .update(table, originalAssignments, rowids) = first.sql, - let assignments = mergedAssignments, assignments.count > originalAssignments.count + if case let .update(upd) = first.sql, + let assignments = mergedAssignments, assignments.count > upd.assignments.count { - seqsToUpdate.append( - (seq: first.seq, sql: .update(table: table, assignments: assignments, rowids: rowids))) + seqsToUpdate.append(( + seq: first.seq, + sql: .update(UndoSQL.UpdateSQL( + table: upd.table, assignments: assignments, rowids: upd.rowids)) + )) } } } diff --git a/Sources/SQLiteUndo/UndoSchema.swift b/Sources/SQLiteUndo/UndoSchema.swift index cc80b6e..08ff3a4 100644 --- a/Sources/SQLiteUndo/UndoSchema.swift +++ b/Sources/SQLiteUndo/UndoSchema.swift @@ -18,30 +18,36 @@ struct UndoLogEntry: Sendable { } /// Parsed representation of trigger-generated undo entries. -/// Parsers produce single-element arrays; batching merges consecutive same-key entries. +/// Single-element arrays when stored; batching merges consecutive same-key entries. enum UndoSQL: Equatable, Sendable { - case delete(table: String, rowids: [String]) - case insert(table: String, columns: [String], rows: [(rowid: String, values: [String])]) - case update(table: String, assignments: [(column: String, value: String)], rowids: [String]) + case delete(DeleteSQL) + case insert(InsertSQL) + case update(UpdateSQL) - static func == (lhs: UndoSQL, rhs: UndoSQL) -> Bool { - switch (lhs, rhs) { - case let (.delete(lt, lr), .delete(rt, rr)): - return lt == rt && lr == rr - case let (.insert(lt, lc, lrows), .insert(rt, rc, rrows)): - guard lt == rt && lc == rc && lrows.count == rrows.count else { return false } - for (l, r) in zip(lrows, rrows) { - guard l.rowid == r.rowid && l.values == r.values else { return false } - } - return true - case let (.update(lt, la, lr), .update(rt, ra, rr)): - guard lt == rt && la.count == ra.count && lr == rr else { return false } - for (l, r) in zip(la, ra) { - guard l.column == r.column && l.value == r.value else { return false } - } - return true - default: - return false + 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 } } } diff --git a/Tests/SQLiteUndoTests/SQLParserTests.swift b/Tests/SQLiteUndoTests/SQLParserTests.swift index 971765a..5cee4c2 100644 --- a/Tests/SQLiteUndoTests/SQLParserTests.swift +++ b/Tests/SQLiteUndoTests/SQLParserTests.swift @@ -60,70 +60,67 @@ struct SQLParserTests { @Test func deleteParseValues() { let parsed = UndoSQL(tabDelimited: "D\tt\t42")! - guard case let .delete(table, rowids) = parsed else { + guard case let .delete(d) = parsed else { Issue.record("Expected delete, got \(parsed)") return } - #expect(table == "t") - #expect(rowids == ["42"]) + #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(table, columns, rows) = parsed else { + guard case let .insert(ins) = parsed else { Issue.record("Expected insert, got \(parsed)") return } - #expect(table == "t") - #expect(columns == ["a", "b"]) - #expect(rows.count == 1) - #expect(rows[0].rowid == "1") - #expect(rows[0].values == ["'hello'", "NULL"]) + #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(table, assignments, rowids) = parsed else { + guard case let .update(upd) = parsed else { Issue.record("Expected update, got \(parsed)") return } - #expect(table == "t") - #expect(assignments.count == 2) - #expect(assignments[0].column == "a") - #expect(assignments[0].value == "'x'") - #expect(assignments[1].column == "b") - #expect(assignments[1].value == "42") - #expect(rowids == ["1"]) + #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(table: "t", rowids: ["1", "2", "3"]) + 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( + let batched = UndoSQL.insert(.init( table: "t", columns: ["a"], - rows: [ - (rowid: "1", values: ["'x'"]), - (rowid: "2", values: ["'y'"]), - ] - ) + 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( + let batched = UndoSQL.update(.init( table: "t", - assignments: [(column: "a", value: "'x'")], + assignments: [.init(column: "a", value: "'x'")], rowids: ["1", "2"] - ) + )) #expect(batched.executableSQL == #"UPDATE "t" SET "a"='x' WHERE rowid IN (1,2)"#) } @@ -132,10 +129,10 @@ struct SQLParserTests { let entries: [UndoLogEntry] = [ UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["1"])), + sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["1"]))), UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "a", value: "'y'")], rowids: ["2"])), + sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'y'")], rowids: ["2"]))), ] let result = batchedSQL(from: entries) #expect(result == [ @@ -149,10 +146,10 @@ struct SQLParserTests { let entries: [UndoLogEntry] = [ UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["1"])), + sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["1"]))), UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "a", value: "'x'")], rowids: ["2"])), + sql: .update(.init(table: "t", assignments: [.init(column: "a", value: "'x'")], rowids: ["2"]))), ] let result = batchedSQL(from: entries) #expect(result == [ @@ -165,10 +162,10 @@ struct SQLParserTests { let entries: [UndoLogEntry] = [ UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "value", value: "42")], rowids: ["1"])), + sql: .update(.init(table: "t", assignments: [.init(column: "value", value: "42")], rowids: ["1"]))), UndoLogEntry( seq: 0, tableName: "t", - sql: .update(table: "t", assignments: [(column: "value", value: "42")], rowids: ["2"])), + sql: .update(.init(table: "t", assignments: [.init(column: "value", value: "42")], rowids: ["2"]))), ] let result = batchedSQL(from: entries) #expect(result == [ From 74a3f5d8b27eced597067e14c9be01cccbea9135 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 16:23:28 -0700 Subject: [PATCH 10/11] tidy --- Sources/SQLiteUndo/UndoOperations.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index cfb9f0f..27245d3 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -183,18 +183,21 @@ extension Database { // 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 case let .update(upd) = entry.sql { - if var assignments = mergedAssignments { - let existingColumns = Set(assignments.map(\.column)) - let additions = upd.assignments.filter { !existingColumns.contains($0.column) } + 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) From 3df34bdb2acd6c5166eefdeca1fa8f600e4895dd Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Tue, 17 Mar 2026 16:32:43 -0700 Subject: [PATCH 11/11] tidy --- Sources/SQLiteUndo/UndoSchema.swift | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/Sources/SQLiteUndo/UndoSchema.swift b/Sources/SQLiteUndo/UndoSchema.swift index 08ff3a4..c58c72f 100644 --- a/Sources/SQLiteUndo/UndoSchema.swift +++ b/Sources/SQLiteUndo/UndoSchema.swift @@ -52,25 +52,20 @@ enum UndoSQL: Equatable, Sendable { } } -extension UndoSQL: _OptionalPromotable {} - extension UndoSQL: QueryDecodable { init(decoder: inout some QueryDecoder) throws { - guard let text = try decoder.decode(String.self), + guard + let text = try decoder.decode(String.self), let parsed = UndoSQL(tabDelimited: text) - else { throw QueryDecodingError.missingRequiredColumn } + else { + throw QueryDecodingError.missingRequiredColumn + } self = parsed } } -extension UndoSQL: QueryRepresentable {} - -extension UndoSQL: QueryExpression { - typealias QueryValue = Self -} - extension UndoSQL: QueryBindable { - var queryBinding: QueryBinding { .text(self.tabDelimited) } + var queryBinding: QueryBinding { .text(tabDelimited) } } extension DatabaseWriter {