Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

170 changes: 170 additions & 0 deletions Sources/SQLiteUndo/SQLParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@

// MARK: - Tab-delimited parsing

extension UndoSQL {
/// Parse a tab-delimited undo entry.
///
/// Format: `TYPE\tTABLE\tROWID[\tCOL\tVAL]*`
/// - `D\t<table>\t<rowid>` → delete
/// - `I\t<table>\t<rowid>\t<col>\t<val>...` → insert
/// - `U\t<table>\t<rowid>\t<col>\t<val>...` → update
init?(tabDelimited sql: String) {
let parts = sql.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 3 else { return nil }

let table = String(parts[1])
let rowid = String(parts[2])

switch parts[0] {
case "D":
self = .delete(DeleteSQL(table: table, rowids: [rowid]))

case "I":
var columns: [String] = []
var values: [String] = []
var i = 3
while i + 1 < parts.count {
columns.append(String(parts[i]))
values.append(String(parts[i + 1]))
i += 2
}
self = .insert(InsertSQL(
table: table, columns: columns,
rows: [InsertSQL.Row(rowid: rowid, values: values)]))

case "U":
var assignments: [UpdateSQL.Assignment] = []
var i = 3
while i + 1 < parts.count {
assignments.append(UpdateSQL.Assignment(
column: String(parts[i]), value: String(parts[i + 1])))
i += 2
}
self = .update(UpdateSQL(table: table, assignments: assignments, rowids: [rowid]))

default:
return nil
}
}

/// Convert to tab-delimited storage format.
var tabDelimited: String {
switch self {
case let .delete(d):
return "D\t" + d.table + "\t" + d.rowids[0]
case let .insert(ins):
let row = ins.rows[0]
var sql = "I\t" + ins.table + "\t" + row.rowid
for (col, val) in zip(ins.columns, row.values) {
sql += "\t" + col + "\t" + val
}
return sql
case let .update(upd):
var sql = "U\t" + upd.table + "\t" + upd.rowids[0]
for a in upd.assignments {
sql += "\t" + a.column + "\t" + a.value
}
return sql
}
}

/// Generate executable SQL.
var executableSQL: String {
switch self {
case let .delete(d):
if d.rowids.count == 1 {
return "DELETE FROM \"\(d.table)\" WHERE rowid=\(d.rowids[0])"
}
return "DELETE FROM \"\(d.table)\" WHERE rowid IN (\(d.rowids.joined(separator: ",")))"

case let .insert(ins):
var sql = "INSERT INTO \""
sql += ins.table
sql += "\"("
if ins.columns.isEmpty {
sql += "rowid"
} else {
sql += "rowid,"
sql += ins.columns.map { "\"" + $0 + "\"" }.joined(separator: ",")
}
sql += ") VALUES"
for (i, row) in ins.rows.enumerated() {
if i > 0 { sql += "," }
sql += "("
sql += row.rowid
for val in row.values {
sql += ","
sql += val
}
sql += ")"
}
return sql

case let .update(upd):
let set = upd.assignments.map { "\"\($0.column)\"=\($0.value)" }.joined(separator: ",")
if upd.rowids.count == 1 {
return "UPDATE \"\(upd.table)\" SET \(set) WHERE rowid=\(upd.rowids[0])"
}
return "UPDATE \"\(upd.table)\" SET \(set) WHERE rowid IN (\(upd.rowids.joined(separator: ",")))"
}
}
}

// MARK: - SQL Batching

/// Maximum entries per batch to stay within SQLite limits.
private let maxBatchSize = 500

/// When true, disables batching so each entry executes individually.
/// Used for benchmarking to compare batched vs unbatched performance.
nonisolated(unsafe) var _undoBatchingDisabled = false

/// Groups consecutive same-key entries into batched SQL.
/// Key: table for DELETE/INSERT, table+assignments for UPDATE.
func batchedSQL(from entries: [UndoLogEntry]) -> [String] {
if _undoBatchingDisabled {
return entries.map(\.sql.executableSQL)
}

var remaining = entries[...]
var result: [String] = []

while let first = remaining.popFirst() {
var current = first.sql

// Merge consecutive same-key entries
while !remaining.isEmpty {
guard let merged = current.merging(remaining.first!.sql) else { break }
current = merged
remaining.removeFirst()
}

result.append(current.executableSQL)
}

return result
}

extension UndoSQL {
/// Merge with another UndoSQL if they share the same grouping key.
func merging(_ other: UndoSQL) -> UndoSQL? {
switch (self, other) {
case let (.delete(l), .delete(r)):
guard l.table == r.table, l.rowids.count + r.rowids.count <= maxBatchSize else { return nil }
return .delete(DeleteSQL(table: l.table, rowids: l.rowids + r.rowids))

case let (.insert(l), .insert(r)):
guard l.table == r.table, l.rows.count + r.rows.count <= maxBatchSize else { return nil }
return .insert(InsertSQL(table: l.table, columns: l.columns, rows: l.rows + r.rows))

case let (.update(l), .update(r)):
guard l.table == r.table, l.rowids.count + r.rowids.count <= maxBatchSize else { return nil }
guard l.assignments == r.assignments else { return nil }
return .update(UpdateSQL(
table: l.table, assignments: l.assignments, rowids: l.rowids + r.rowids))

default:
return nil
}
}
}
55 changes: 43 additions & 12 deletions Sources/SQLiteUndo/UndoOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,20 @@ 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)
}
}

// Get new seq range for captured entries
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)
}
Expand Down Expand Up @@ -158,38 +159,68 @@ extension Database {
}

var seqsToDelete: [Int] = []
var seqsToUpdate: [(seq: Int, sql: UndoSQL)] = []

for (_, group) in groups {
guard group.count > 1 else { continue }

let first = group[0]
let last = group[group.count - 1]

let firstIsDeleteReverse = first.sql.hasPrefix("DELETE FROM")
let lastIsInsertReverse = last.sql.hasPrefix("INSERT INTO")

if firstIsDeleteReverse && lastIsInsertReverse {
if case .delete = first.sql, case .insert = last.sql {
// INSERT then DELETE in same barrier → no-op, remove all
for entry in group {
seqsToDelete.append(entry.seq)
}
} else if firstIsDeleteReverse {
} else if case .delete = first.sql {
// INSERT then UPDATEs → keep DELETE-reverse (undo = delete), remove rest
for entry in group.dropFirst() {
seqsToDelete.append(entry.seq)
}
} else {
// First is UPDATE-reverse or INSERT-reverse (pre-existing row).
// Remove only subsequent UPDATE-reverses (cascade duplicates).
// Keep INSERT-reverses (from DELETE) since replay needs them for row re-creation.
// Merge subsequent UPDATE-reverses into the first UPDATE-reverse,
// adding any columns not already present (first entry's values win).
var mergedAssignments: [UndoSQL.UpdateSQL.Assignment]?
var existingColumns: Set<String>?
if case let .update(upd) = first.sql {
mergedAssignments = upd.assignments
existingColumns = Set(upd.assignments.map(\.column))
}

for entry in group.dropFirst() {
if entry.sql.hasPrefix("UPDATE") {
if case let .update(upd) = entry.sql {
if var assignments = mergedAssignments, var columns = existingColumns {
let additions = upd.assignments.filter { !columns.contains($0.column) }
if !additions.isEmpty {
for a in additions { columns.insert(a.column) }
assignments += additions
mergedAssignments = assignments
existingColumns = columns
}
}
seqsToDelete.append(entry.seq)
}
}

if case let .update(upd) = first.sql,
let assignments = mergedAssignments, assignments.count > upd.assignments.count
{
seqsToUpdate.append((
seq: first.seq,
sql: .update(UndoSQL.UpdateSQL(
table: upd.table, assignments: assignments, rowids: upd.rowids))
))
}
}
}

for entry in seqsToUpdate {
let text = entry.sql.tabDelimited
try self.execute(sql: "UPDATE undolog SET sql = ? WHERE seq = ?", arguments: [text, entry.seq])
}

if !seqsToDelete.isEmpty {
let placeholders = seqsToDelete.map { "\($0)" }.joined(separator: ",")
try #sql("DELETE FROM undolog WHERE seq IN (\(raw: placeholders))").execute(self)
Expand Down
55 changes: 53 additions & 2 deletions Sources/SQLiteUndo/UndoSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,59 @@ struct UndoLogEntry: Sendable {
var tableName: String
/// The rowid of the tracked row, for deduplication during reconciliation.
var trackedRowid: Int = 0
/// The SQL statement to reverse the change.
var sql: String
/// The parsed undo entry to reverse the change.
var sql: UndoSQL
}

/// Parsed representation of trigger-generated undo entries.
/// Single-element arrays when stored; batching merges consecutive same-key entries.
enum UndoSQL: Equatable, Sendable {
case delete(DeleteSQL)
case insert(InsertSQL)
case update(UpdateSQL)

struct DeleteSQL: Equatable, Sendable {
var table: String
var rowids: [String]
}

struct InsertSQL: Equatable, Sendable {
var table: String
var columns: [String]
var rows: [Row]

struct Row: Equatable, Sendable {
var rowid: String
var values: [String]
}
}

struct UpdateSQL: Equatable, Sendable {
var table: String
var assignments: [Assignment]
var rowids: [String]

struct Assignment: Equatable, Sendable {
var column: String
var value: String
}
}
}

extension UndoSQL: QueryDecodable {
init(decoder: inout some QueryDecoder) throws {
guard
let text = try decoder.decode(String.self),
let parsed = UndoSQL(tabDelimited: text)
else {
throw QueryDecodingError.missingRequiredColumn
}
self = parsed
}
}

extension UndoSQL: QueryBindable {
var queryBinding: QueryBinding { .text(tabDelimited) }
}

extension DatabaseWriter {
Expand Down
Loading
Loading