diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f51fbae..4782ff84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click - QueryTab decomposed into focused sub-types: TabExecutionState, TabTableContext, TabQueryContent, TabDisplayState diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 2d3b38a81..1ade78419 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -25,12 +25,14 @@ struct UndoResult { @MainActor @Observable final class DataChangeManager: ChangeManaging { private static let logger = Logger(subsystem: "com.TablePro", category: "DataChangeManager") - var changes: [RowChange] = [] - var rowChanges: [RowChange] { changes } + + private(set) var pending = PendingChanges() var hasChanges: Bool = false var reloadVersion: Int = 0 - private(set) var changedRowIndices: Set = [] + var changes: [RowChange] { pending.changes } + var rowChanges: [RowChange] { pending.changes } + var insertedRowIndices: Set { pending.insertedRowIndices } var tableName: String = "" var primaryKeyColumns: [String] = [] @@ -45,62 +47,6 @@ final class DataChangeManager: ChangeManaging { set { _columnsStorage = newValue.map { String($0) } } } - // MARK: - Cached Lookups for O(1) Performance - - private var deletedRowIndices: Set = [] - private(set) var insertedRowIndices: Set = [] - private var modifiedCells: [Int: Set] = [:] - private var insertedRowData: [Int: [String?]] = [:] - - /// (rowIndex, changeType) → index in `changes` array for O(1) lookup - /// Replaces O(n) `firstIndex(where:)` scans in hot paths like `recordCellChange` - private var changeIndex: [RowChangeKey: Int] = [:] - - /// Rebuild `changeIndex` from the `changes` array. - /// Called only for complex operations (bulk shifts, restoreState, clearChanges). - private func rebuildChangeIndex() { - changeIndex.removeAll(keepingCapacity: true) - for (index, change) in changes.enumerated() { - changeIndex[RowChangeKey(rowIndex: change.rowIndex, type: change.type)] = index - } - } - - /// Remove a single change at a known array index and update changeIndex incrementally. - /// O(n) for index adjustment but avoids full dictionary rebuild. - private func removeChangeAt(_ arrayIndex: Int) { - let removed = changes[arrayIndex] - let removedKey = RowChangeKey(rowIndex: removed.rowIndex, type: removed.type) - changeIndex.removeValue(forKey: removedKey) - changes.remove(at: arrayIndex) - - for (key, idx) in changeIndex where idx > arrayIndex { - changeIndex[key] = idx - 1 - } - } - - @discardableResult - private func removeChangeByKey(rowIndex: Int, type: ChangeType) -> Bool { - let key = RowChangeKey(rowIndex: rowIndex, type: type) - guard let arrayIndex = changeIndex[key] else { return false } - removeChangeAt(arrayIndex) - return true - } - - /// Binary search: count of elements in a sorted array that are strictly less than `target`. - /// Used for O(n log n) batch index shifting instead of O(n²) nested loops. - private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int { - var lo = 0, hi = sorted.count - while lo < hi { - let mid = (lo + hi) / 2 - if sorted[mid] < target { - lo = mid + 1 - } else { - hi = mid - } - } - return lo - } - var undoManagerProvider: (() -> UndoManager?)? var onUndoApplied: ((UndoResult) -> Void)? @@ -120,21 +66,13 @@ final class DataChangeManager: ChangeManaging { // MARK: - Helper Methods func consumeChangedRowIndices() -> Set { - let indices = changedRowIndices - changedRowIndices.removeAll() - return indices + pending.consumeChangedRowIndices() } // MARK: - Configuration func clearChanges() { - changes.removeAll() - changeIndex.removeAll() - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - insertedRowData.removeAll() - changedRowIndices.removeAll() + pending.clear() hasChanges = false reloadVersion += 1 } @@ -156,15 +94,9 @@ final class DataChangeManager: ChangeManaging { self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType - changeIndex.removeAll() - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - insertedRowData.removeAll() - changedRowIndices.removeAll() + pending.clear() undoManagerProvider?()?.removeAllActions(withTarget: self) - changes.removeAll() hasChanges = false if triggerReload { reloadVersion += 1 @@ -181,133 +113,31 @@ final class DataChangeManager: ChangeManaging { newValue: String?, originalRow: [String?]? = nil ) { - if oldValue == newValue { - let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) - if let existingIndex = changeIndex[updateKey], - let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { - let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue - if originalOldValue == newValue { - changes[existingIndex].cellChanges.remove(at: cellIndex) - modifiedCells[rowIndex]?.remove(columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { modifiedCells.removeValue(forKey: rowIndex) } - if changes[existingIndex].cellChanges.isEmpty { removeChangeAt(existingIndex) } - changedRowIndices.insert(rowIndex) - hasChanges = !changes.isEmpty - reloadVersion += 1 - } - } - return - } - - let cellChange = CellChange( + let recorded = pending.recordCellChange( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, - newValue: newValue + newValue: newValue, + originalRow: originalRow ) - - let insertKey = RowChangeKey(rowIndex: rowIndex, type: .insert) - if let insertIndex = changeIndex[insertKey] { - if var storedValues = insertedRowData[rowIndex] { - if columnIndex < storedValues.count { - storedValues[columnIndex] = newValue - insertedRowData[rowIndex] = storedValues - } - } - - if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - changes[insertIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, - newValue: newValue - ) - } else { - changes[insertIndex].cellChanges.append(CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, - newValue: newValue - )) - } - registerUndo(actionName: String(localized: "Edit Cell")) { target in - target.applyDataUndo(.cellEdit( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue, originalRow: nil - )) - } - changedRowIndices.insert(rowIndex) - hasChanges = !changes.isEmpty + guard recorded else { + hasChanges = !pending.isEmpty reloadVersion += 1 return } - - let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) - if let existingIndex = changeIndex[updateKey] { - if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue - changes[existingIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: originalOldValue, - newValue: newValue - ) - - if originalOldValue == newValue { - changes[existingIndex].cellChanges.remove(at: cellIndex) - modifiedCells[rowIndex]?.remove(columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { - modifiedCells.removeValue(forKey: rowIndex) - } - if changes[existingIndex].cellChanges.isEmpty { - removeChangeAt(existingIndex) - } - } - } else { - changes[existingIndex].cellChanges.append(cellChange) - modifiedCells[rowIndex, default: []].insert(columnIndex) - } - changedRowIndices.insert(rowIndex) - } else { - let rowChange = RowChange( - rowIndex: rowIndex, - type: .update, - cellChanges: [cellChange], - originalRow: originalRow - ) - changes.append(rowChange) - changeIndex[updateKey] = changes.count - 1 - modifiedCells[rowIndex, default: []].insert(columnIndex) - changedRowIndices.insert(rowIndex) - } - registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue, originalRow: originalRow )) } - hasChanges = !changes.isEmpty + hasChanges = !pending.isEmpty reloadVersion += 1 } func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { - removeChangeByKey(rowIndex: rowIndex, type: .update) - modifiedCells.removeValue(forKey: rowIndex) - - let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) - changes.append(rowChange) - changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 - deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) + pending.recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) registerUndo(actionName: String(localized: "Delete Row")) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } @@ -322,20 +152,10 @@ final class DataChangeManager: ChangeManaging { } return } - - var batchData: [(rowIndex: Int, originalRow: [String?])] = [] - for (rowIndex, originalRow) in rows { - removeChangeByKey(rowIndex: rowIndex, type: .update) - modifiedCells.removeValue(forKey: rowIndex) - - let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) - changes.append(rowChange) - changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 - deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) - batchData.append((rowIndex: rowIndex, originalRow: originalRow)) + pending.recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) } + let batchData = rows registerUndo(actionName: String(localized: "Delete Rows")) { target in target.applyDataUndo(.batchRowDeletion(rows: batchData)) } @@ -344,12 +164,7 @@ final class DataChangeManager: ChangeManaging { } func recordRowInsertion(rowIndex: Int, values: [String?]) { - insertedRowData[rowIndex] = values - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) - changes.append(rowChange) - changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 - insertedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) + pending.recordRowInsertion(rowIndex: rowIndex, values: values) registerUndo(actionName: String(localized: "Insert Row")) { target in target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } @@ -360,338 +175,53 @@ final class DataChangeManager: ChangeManaging { // MARK: - Undo Operations func undoRowDeletion(rowIndex: Int) { - guard deletedRowIndices.contains(rowIndex) else { return } - removeChangeByKey(rowIndex: rowIndex, type: .delete) - deletedRowIndices.remove(rowIndex) - hasChanges = !changes.isEmpty + guard pending.undoRowDeletion(rowIndex: rowIndex) else { return } + hasChanges = !pending.isEmpty reloadVersion += 1 } func undoRowInsertion(rowIndex: Int) { - guard insertedRowIndices.contains(rowIndex) else { return } - - removeChangeByKey(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) - - var shiftedInsertedIndices = Set() - for idx in insertedRowIndices { - shiftedInsertedIndices.insert(idx > rowIndex ? idx - 1 : idx) - } - insertedRowIndices = shiftedInsertedIndices - - for i in 0.. rowIndex { - changes[i].rowIndex -= 1 - } - } - - var newInsertedRowData: [Int: [String?]] = [:] - for (key, value) in insertedRowData { - if key > rowIndex { - newInsertedRowData[key - 1] = value - } else { - newInsertedRowData[key] = value - } - } - insertedRowData = newInsertedRowData - - var newModifiedCells: [Int: Set] = [:] - for (key, value) in modifiedCells { - if key > rowIndex { - newModifiedCells[key - 1] = value - } else if key < rowIndex { - newModifiedCells[key] = value - } - } - modifiedCells = newModifiedCells - - rebuildChangeIndex() - hasChanges = !changes.isEmpty - } - - private func shiftRowIndicesUp(from insertionPoint: Int) { - for i in 0..= insertionPoint { - changes[i].rowIndex += 1 - } - } - - var shiftedInserted = Set() - for idx in insertedRowIndices { - shiftedInserted.insert(idx >= insertionPoint ? idx + 1 : idx) - } - insertedRowIndices = shiftedInserted - - var shiftedDeleted = Set() - for idx in deletedRowIndices { - shiftedDeleted.insert(idx >= insertionPoint ? idx + 1 : idx) - } - deletedRowIndices = shiftedDeleted - - var newInsertedRowData: [Int: [String?]] = [:] - for (key, value) in insertedRowData { - newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value - } - insertedRowData = newInsertedRowData - - var newModifiedCells: [Int: Set] = [:] - for (key, value) in modifiedCells { - newModifiedCells[key >= insertionPoint ? key + 1 : key] = value - } - modifiedCells = newModifiedCells - - var newChangedRows = Set() - for idx in changedRowIndices { - newChangedRows.insert(idx >= insertionPoint ? idx + 1 : idx) - } - changedRowIndices = newChangedRows - - rebuildChangeIndex() + guard pending.undoRowInsertion(rowIndex: rowIndex) else { return } + hasChanges = !pending.isEmpty + reloadVersion += 1 } func undoBatchRowInsertion(rowIndices: [Int]) { - guard !rowIndices.isEmpty else { return } - - let validRows = rowIndices.filter { insertedRowIndices.contains($0) } + let validRows = rowIndices.filter { pending.isRowInserted($0) } guard !validRows.isEmpty else { return } - - var rowValues: [[String?]] = [] - for rowIndex in validRows { - let key = RowChangeKey(rowIndex: rowIndex, type: .insert) - if let idx = changeIndex[key] { - let values = changes[idx].cellChanges.sorted { $0.columnIndex < $1.columnIndex } - .map { $0.newValue } - rowValues.append(values) - } else { - rowValues.append(Array(repeating: nil, count: columns.count)) - } - } - - for rowIndex in validRows { - removeChangeByKey(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) - } - + let rowValues = pending.undoBatchRowInsertion(rowIndices: validRows, columnCount: columns.count) registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) } - - let sortedDeleted = validRows.sorted() - - var newInserted = Set() - for idx in insertedRowIndices { - let shiftCount = Self.countLessThan(idx, in: sortedDeleted) - newInserted.insert(idx - shiftCount) - } - insertedRowIndices = newInserted - - for i in 0.. [ParameterizedStatement] { try generateSQL( - for: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + for: pending.changes, + insertedRowData: pending.insertedRowData, + deletedRowIndices: pending.deletedRowIndices, + insertedRowIndices: pending.insertedRowIndices ) } @@ -886,77 +458,54 @@ final class DataChangeManager: ChangeManaging { func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: String?)] { var originals: [(rowIndex: Int, columnIndex: Int, value: String?)] = [] - - for change in changes { - if change.type == .update { - for cellChange in change.cellChanges { - originals.append(( - rowIndex: change.rowIndex, - columnIndex: cellChange.columnIndex, - value: cellChange.oldValue - )) - } + for change in pending.changes where change.type == .update { + for cellChange in change.cellChanges { + originals.append(( + rowIndex: change.rowIndex, + columnIndex: cellChange.columnIndex, + value: cellChange.oldValue + )) } } - return originals } func discardChanges() { - changes.removeAll() - changeIndex.removeAll() - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - insertedRowData.removeAll() - changedRowIndices.removeAll() + pending.clear() hasChanges = false reloadVersion += 1 } // MARK: - Per-Tab State Management - func saveState() -> TabPendingChanges { - var state = TabPendingChanges() - state.changes = changes - state.deletedRowIndices = deletedRowIndices - state.insertedRowIndices = insertedRowIndices - state.modifiedCells = modifiedCells - state.insertedRowData = insertedRowData - state.primaryKeyColumns = primaryKeyColumns - state.columns = columns - return state + func saveState() -> TabChangeSnapshot { + pending.snapshot(primaryKeyColumns: primaryKeyColumns, columns: columns) } - func restoreState(from state: TabPendingChanges, tableName: String, databaseType: DatabaseType) { + func restoreState(from state: TabChangeSnapshot, tableName: String, databaseType: DatabaseType) { self.tableName = tableName self.columns = state.columns self.primaryKeyColumns = state.primaryKeyColumns self.databaseType = databaseType - self.changes = state.changes - self.deletedRowIndices = state.deletedRowIndices - self.insertedRowIndices = state.insertedRowIndices - self.modifiedCells = state.modifiedCells - self.insertedRowData = state.insertedRowData - self.hasChanges = !state.changes.isEmpty - rebuildChangeIndex() + pending.restore(from: state) + self.hasChanges = !pending.isEmpty } // MARK: - O(1) Lookups func isRowDeleted(_ rowIndex: Int) -> Bool { - deletedRowIndices.contains(rowIndex) + pending.isRowDeleted(rowIndex) } func isRowInserted(_ rowIndex: Int) -> Bool { - insertedRowIndices.contains(rowIndex) + pending.isRowInserted(rowIndex) } func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { - modifiedCells[rowIndex]?.contains(columnIndex) == true + pending.isCellModified(rowIndex: rowIndex, columnIndex: columnIndex) } func getModifiedColumnsForRow(_ rowIndex: Int) -> Set { - modifiedCells[rowIndex] ?? [] + pending.modifiedColumns(forRow: rowIndex) } } diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index b0f15b120..2438b2781 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -86,7 +86,7 @@ enum UndoAction { case batchRowInsertion(rowIndices: [Int], rowValues: [[String?]]) } -// Note: TabPendingChanges is defined in QueryTab.swift +// Note: TabChangeSnapshot is defined in QueryTab.swift // MARK: - Array Extension diff --git a/TablePro/Core/ChangeTracking/PendingChanges.swift b/TablePro/Core/ChangeTracking/PendingChanges.swift new file mode 100644 index 000000000..286c25b3c --- /dev/null +++ b/TablePro/Core/ChangeTracking/PendingChanges.swift @@ -0,0 +1,534 @@ +// +// PendingChanges.swift +// TablePro +// +// Value type holding all uncommitted edits to a result set. +// Owns the consistency invariants between `changes`, `changeIndex`, +// `deletedRowIndices`, `insertedRowIndices`, `modifiedCells`, and +// `insertedRowData`. Callers mutate through methods that maintain +// the cross-collection state. +// + +import Foundation + +struct PendingChanges: Equatable { + private(set) var changes: [RowChange] = [] + private(set) var deletedRowIndices: Set = [] + private(set) var insertedRowIndices: Set = [] + private(set) var modifiedCells: [Int: Set] = [:] + private(set) var insertedRowData: [Int: [String?]] = [:] + private(set) var changedRowIndices: Set = [] + + private var changeIndex: [RowChangeKey: Int] = [:] + + var isEmpty: Bool { changes.isEmpty } + var hasChanges: Bool { !isEmpty } + + // MARK: - Read + + func isRowDeleted(_ rowIndex: Int) -> Bool { + deletedRowIndices.contains(rowIndex) + } + + func isRowInserted(_ rowIndex: Int) -> Bool { + insertedRowIndices.contains(rowIndex) + } + + func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { + modifiedCells[rowIndex]?.contains(columnIndex) == true + } + + func modifiedColumns(forRow rowIndex: Int) -> Set { + modifiedCells[rowIndex] ?? [] + } + + func change(forRow rowIndex: Int, type: ChangeType) -> RowChange? { + guard let idx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: type)] else { return nil } + return changes[idx] + } + + // MARK: - Mutate (recording user edits) + + /// Whether the recorded edit is a no-op (oldValue == newValue with no prior modification). + /// Returns the result so the caller can decide whether to register undo. + @discardableResult + mutating func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?]? = nil + ) -> Bool { + if oldValue == newValue { + return rollbackCellIfMatchesOriginal( + rowIndex: rowIndex, columnIndex: columnIndex, restoredValue: newValue + ) + } + + let cellChange = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue + ) + + if let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] { + updateInsertedCell(at: insertIdx, columnIndex: columnIndex, + columnName: columnName, newValue: newValue) + changedRowIndices.insert(rowIndex) + return true + } + + let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + if let updateIdx = changeIndex[updateKey] { + mergeUpdateCell(at: updateIdx, cellChange: cellChange) + } else { + let row = RowChange( + rowIndex: rowIndex, type: .update, + cellChanges: [cellChange], originalRow: originalRow + ) + changes.append(row) + changeIndex[updateKey] = changes.count - 1 + modifiedCells[rowIndex, default: []].insert(columnIndex) + } + changedRowIndices.insert(rowIndex) + return true + } + + mutating func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { + guard !deletedRowIndices.contains(rowIndex) else { return } + removeChange(rowIndex: rowIndex, type: .update) + modifiedCells.removeValue(forKey: rowIndex) + appendChange(RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow)) + deletedRowIndices.insert(rowIndex) + changedRowIndices.insert(rowIndex) + } + + mutating func recordRowInsertion(rowIndex: Int, values: [String?]) { + guard !insertedRowIndices.contains(rowIndex) else { + insertedRowData[rowIndex] = values + return + } + insertedRowData[rowIndex] = values + appendChange(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: [])) + insertedRowIndices.insert(rowIndex) + changedRowIndices.insert(rowIndex) + } + + // MARK: - Mutate (cancelling pending edits) + + mutating func undoRowDeletion(rowIndex: Int) -> Bool { + guard deletedRowIndices.contains(rowIndex) else { return false } + removeChange(rowIndex: rowIndex, type: .delete) + deletedRowIndices.remove(rowIndex) + changedRowIndices.insert(rowIndex) + return true + } + + mutating func undoRowInsertion(rowIndex: Int) -> Bool { + guard insertedRowIndices.contains(rowIndex) else { return false } + + removeChange(rowIndex: rowIndex, type: .insert) + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + + shiftRowIndicesDown(at: rowIndex) + changedRowIndices.insert(rowIndex) + return true + } + + /// Undo a batch of inserted rows. Returns the saved values for each row in the same order. + mutating func undoBatchRowInsertion(rowIndices: [Int], columnCount: Int) -> [[String?]] { + let validRows = rowIndices.filter { insertedRowIndices.contains($0) } + + var rowValues: [[String?]] = [] + for rowIndex in validRows { + if let idx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] { + let values = changes[idx].cellChanges + .sorted { $0.columnIndex < $1.columnIndex } + .map { $0.newValue } + rowValues.append(values) + } else { + rowValues.append(Array(repeating: nil, count: columnCount)) + } + } + + for rowIndex in validRows { + removeChange(rowIndex: rowIndex, type: .insert) + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + changedRowIndices.insert(rowIndex) + } + + let sortedRemoved = validRows.sorted() + + var newInserted = Set() + for idx in insertedRowIndices { + newInserted.insert(idx - Self.countLessThan(idx, in: sortedRemoved)) + } + insertedRowIndices = newInserted + + for i in 0.. [String?]? { + insertedRowData[rowIndex] + } + + /// Restore inserted-row values when undo restores a row. + mutating func restoreInsertedValues(forRow rowIndex: Int, values: [String?]) { + insertedRowData[rowIndex] = values + } + + // MARK: - Reset / persistence + + mutating func clear() { + changes.removeAll() + changeIndex.removeAll() + deletedRowIndices.removeAll() + insertedRowIndices.removeAll() + modifiedCells.removeAll() + insertedRowData.removeAll() + changedRowIndices.removeAll() + } + + mutating func consumeChangedRowIndices() -> Set { + let indices = changedRowIndices + changedRowIndices.removeAll() + return indices + } + + /// Replace internal state from a serialized snapshot. + mutating func restore(from snapshot: TabChangeSnapshot) { + changes = snapshot.changes + deletedRowIndices = snapshot.deletedRowIndices + insertedRowIndices = snapshot.insertedRowIndices + modifiedCells = snapshot.modifiedCells + insertedRowData = snapshot.insertedRowData + changedRowIndices = [] + rebuildChangeIndex() + } + + func snapshot(primaryKeyColumns: [String], columns: [String]) -> TabChangeSnapshot { + var snap = TabChangeSnapshot() + snap.changes = changes + snap.deletedRowIndices = deletedRowIndices + snap.insertedRowIndices = insertedRowIndices + snap.modifiedCells = modifiedCells + snap.insertedRowData = insertedRowData + snap.primaryKeyColumns = primaryKeyColumns + snap.columns = columns + return snap + } + + // MARK: - Internals + + private mutating func appendChange(_ change: RowChange) { + changes.append(change) + changeIndex[RowChangeKey(rowIndex: change.rowIndex, type: change.type)] = changes.count - 1 + } + + @discardableResult + private mutating func removeChange(rowIndex: Int, type: ChangeType) -> Bool { + let key = RowChangeKey(rowIndex: rowIndex, type: type) + guard let arrayIndex = changeIndex[key] else { return false } + removeChangeAt(arrayIndex) + return true + } + + private mutating func removeChangeAt(_ arrayIndex: Int) { + let removed = changes[arrayIndex] + changeIndex.removeValue(forKey: RowChangeKey(rowIndex: removed.rowIndex, type: removed.type)) + changes.remove(at: arrayIndex) + + for (key, idx) in changeIndex where idx > arrayIndex { + changeIndex[key] = idx - 1 + } + } + + private mutating func rebuildChangeIndex() { + changeIndex.removeAll(keepingCapacity: true) + for (index, change) in changes.enumerated() { + changeIndex[RowChangeKey(rowIndex: change.rowIndex, type: change.type)] = index + } + } + + private mutating func updateInsertedCell( + at insertIdx: Int, columnIndex: Int, columnName: String, newValue: String? + ) { + let rowIndex = changes[insertIdx].rowIndex + if var stored = insertedRowData[rowIndex], columnIndex < stored.count { + stored[columnIndex] = newValue + insertedRowData[rowIndex] = stored + } + + let replacement = CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: nil, newValue: newValue + ) + if let cellIdx = changes[insertIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { + changes[insertIdx].cellChanges[cellIdx] = replacement + } else { + changes[insertIdx].cellChanges.append(replacement) + } + } + + private mutating func mergeUpdateCell(at updateIdx: Int, cellChange: CellChange) { + let rowIndex = changes[updateIdx].rowIndex + if let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { + $0.columnIndex == cellChange.columnIndex + }) { + let originalOldValue = changes[updateIdx].cellChanges[cellIdx].oldValue + let merged = CellChange( + rowIndex: rowIndex, + columnIndex: cellChange.columnIndex, + columnName: cellChange.columnName, + oldValue: originalOldValue, + newValue: cellChange.newValue + ) + changes[updateIdx].cellChanges[cellIdx] = merged + + if originalOldValue == cellChange.newValue { + changes[updateIdx].cellChanges.remove(at: cellIdx) + modifiedCells[rowIndex]?.remove(cellChange.columnIndex) + if modifiedCells[rowIndex]?.isEmpty == true { + modifiedCells.removeValue(forKey: rowIndex) + } + if changes[updateIdx].cellChanges.isEmpty { + removeChangeAt(updateIdx) + } + } + } else { + changes[updateIdx].cellChanges.append(cellChange) + modifiedCells[rowIndex, default: []].insert(cellChange.columnIndex) + } + } + + @discardableResult + private mutating func rollbackCellIfMatchesOriginal( + rowIndex: Int, columnIndex: Int, restoredValue: String? + ) -> Bool { + let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + guard let updateIdx = changeIndex[updateKey], + let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }), + changes[updateIdx].cellChanges[cellIdx].oldValue == restoredValue else { + return false + } + changes[updateIdx].cellChanges.remove(at: cellIdx) + modifiedCells[rowIndex]?.remove(columnIndex) + if modifiedCells[rowIndex]?.isEmpty == true { + modifiedCells.removeValue(forKey: rowIndex) + } + if changes[updateIdx].cellChanges.isEmpty { + removeChangeAt(updateIdx) + } + changedRowIndices.insert(rowIndex) + return true + } + + private mutating func shiftRowIndicesUp(from insertionPoint: Int) { + for i in 0..= insertionPoint { + changes[i].rowIndex += 1 + } + insertedRowIndices = Set(insertedRowIndices.map { $0 >= insertionPoint ? $0 + 1 : $0 }) + deletedRowIndices = Set(deletedRowIndices.map { $0 >= insertionPoint ? $0 + 1 : $0 }) + + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells { + newModifiedCells[key >= insertionPoint ? key + 1 : key] = value + } + modifiedCells = newModifiedCells + + changedRowIndices = Set(changedRowIndices.map { $0 >= insertionPoint ? $0 + 1 : $0 }) + rebuildChangeIndex() + } + + private mutating func shiftRowIndicesDown(at removedRow: Int) { + for i in 0.. removedRow { + changes[i].rowIndex -= 1 + } + insertedRowIndices = Set(insertedRowIndices.map { $0 > removedRow ? $0 - 1 : $0 }) + + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + newInsertedRowData[key > removedRow ? key - 1 : key] = value + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells where key != removedRow { + newModifiedCells[key > removedRow ? key - 1 : key] = value + } + modifiedCells = newModifiedCells + rebuildChangeIndex() + } + + /// Binary search: count of elements strictly less than `target` in a sorted array. + private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int { + var lo = 0, hi = sorted.count + while lo < hi { + let mid = (lo + hi) / 2 + if sorted[mid] < target { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index fc43cd4b7..01e533363 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -20,7 +20,7 @@ struct QueryTab: Identifiable, Equatable { var tableContext: TabTableContext var display: TabDisplayState - var pendingChanges: TabPendingChanges + var pendingChanges: TabChangeSnapshot var selectedRowIndices: Set var sortState: SortState var filterState: TabFilterState @@ -46,7 +46,7 @@ struct QueryTab: Identifiable, Equatable { self.execution = TabExecutionState() self.tableContext = TabTableContext(tableName: tableName, isEditable: tabType == .table) self.display = TabDisplayState() - self.pendingChanges = TabPendingChanges() + self.pendingChanges = TabChangeSnapshot() self.selectedRowIndices = [] self.sortState = SortState() self.filterState = TabFilterState() @@ -77,7 +77,7 @@ struct QueryTab: Identifiable, Equatable { isView: persisted.isView ) self.display = TabDisplayState(erDiagramSchemaKey: persisted.erDiagramSchemaKey) - self.pendingChanges = TabPendingChanges() + self.pendingChanges = TabChangeSnapshot() self.selectedRowIndices = [] self.sortState = SortState() self.filterState = TabFilterState() diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 72e40a731..7fd593021 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -230,7 +230,7 @@ final class QueryTabManager { tab.display.resultsViewMode = .data tab.sortState = SortState() tab.selectedRowIndices = [] - tab.pendingChanges = TabPendingChanges() + tab.pendingChanges = TabChangeSnapshot() tab.hasUserInteraction = false tab.tableContext.isView = isView tab.tableContext.isEditable = !isView diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index a61971f63..8f18d4059 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -35,8 +35,7 @@ struct PersistedTab: Codable { var queryParameters: [QueryParameter]? } -/// Stores pending changes for a tab (used to preserve state when switching tabs) -struct TabPendingChanges: Equatable { +struct TabChangeSnapshot: Equatable { var changes: [RowChange] var deletedRowIndices: Set var insertedRowIndices: Set diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 93ebc63fe..5136b79aa 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -98,7 +98,7 @@ extension MainContentCoordinator { changeManager.clearChangesAndUndoHistory() if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].pendingChanges = TabPendingChanges() + tabManager.tabs[index].pendingChanges = TabChangeSnapshot() } Task { await refreshTables() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 2446cee9d..d9909f5c6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -232,7 +232,7 @@ extension MainContentCoordinator { changeManager.clearChangesAndUndoHistory() if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].pendingChanges = TabPendingChanges() + tabManager.tabs[index].pendingChanges = TabChangeSnapshot() tabManager.tabs[index].execution.errorMessage = nil } diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index 594573387..585d1b3a9 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -17,6 +17,10 @@ struct DataChangeManagerExtendedTests { pk: String? = "id" ) -> DataChangeManager { let manager = DataChangeManager() + let undoManager = UndoManager() + undoManager.levelsOfUndo = 100 + undoManager.groupsByEvent = false + manager.undoManagerProvider = { undoManager } manager.configureForTable( tableName: "test_table", columns: columns, @@ -659,12 +663,12 @@ struct DataChangeManagerExtendedTests { // MARK: - Edge Cases - @Test("Recording deletion for already-deleted row adds duplicate entry") + @Test("Recording deletion for already-deleted row is idempotent") func recordDeletionForAlreadyDeletedRow() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice", "a@test.com"]) manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice", "a@test.com"]) - #expect(manager.changes.count == 2) + #expect(manager.changes.count == 1) } @Test("changedRowIndices includes all operation types") @@ -676,9 +680,10 @@ struct DataChangeManagerExtendedTests { ) manager.recordRowDeletion(rowIndex: 1, originalRow: ["2", "Charlie", "c@test.com"]) manager.recordRowInsertion(rowIndex: 2, values: ["x", "y", "z"]) - #expect(manager.changedRowIndices.contains(0)) - #expect(manager.changedRowIndices.contains(1)) - #expect(manager.changedRowIndices.contains(2)) + let changed = manager.consumeChangedRowIndices() + #expect(changed.contains(0)) + #expect(changed.contains(1)) + #expect(changed.contains(2)) } @Test("configureForTable with triggerReload false does not increment reloadVersion") @@ -783,6 +788,23 @@ struct DataChangeManagerExtendedTests { #expect(!manager.changes.isEmpty) } + @Test("Edit -> undo -> redo -> undo collapses cleanly (no orphan modifiedCells)") + func editUndoRedoUndoCollapses() { + let manager = makeManager() + manager.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "Alice", newValue: "Bob" + ) + _ = manager.undoLastChange() + _ = manager.redoLastChange() + #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + + _ = manager.undoLastChange() + #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.changes.isEmpty) + #expect(!manager.hasChanges) + } + @Test("Inserted row edit consistency between changes and insertedRowData") func invariantInsertedRowEditConsistency() { let manager = makeManager() diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index afdc8c7ed..86b8d3328 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -12,6 +12,14 @@ import Testing @MainActor @Suite("Data Change Manager") struct DataChangeManagerTests { + private func makeManagerWithUndo() -> DataChangeManager { + let manager = DataChangeManager() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + manager.undoManagerProvider = { undoManager } + return manager + } + // MARK: - Configuration Tests @Test("configureForTable sets properties correctly") @@ -246,7 +254,7 @@ struct DataChangeManagerTests { newValue: "Bob" ) - #expect(manager.changedRowIndices.contains(5)) + #expect(manager.consumeChangedRowIndices().contains(5)) } // MARK: - Row Deletion Tests @@ -382,7 +390,7 @@ struct DataChangeManagerTests { _ = manager.consumeChangedRowIndices() - #expect(manager.changedRowIndices.isEmpty) + #expect(manager.consumeChangedRowIndices().isEmpty) } // MARK: - clearChanges Tests @@ -439,7 +447,7 @@ struct DataChangeManagerTests { @Test("After recording a change, canUndo is true") func canUndoAfterChange() async { - let manager = DataChangeManager() + let manager = makeManagerWithUndo() manager.configureForTable( tableName: "users", columns: ["id", "name"], @@ -459,7 +467,7 @@ struct DataChangeManagerTests { @Test("After undo, the change is reversed") func undoReversesChange() async { - let manager = DataChangeManager() + let manager = makeManagerWithUndo() manager.configureForTable( tableName: "users", columns: ["id", "name"], @@ -483,7 +491,7 @@ struct DataChangeManagerTests { @Test("canRedo after undo") func canRedoAfterUndo() async { - let manager = DataChangeManager() + let manager = makeManagerWithUndo() manager.configureForTable( tableName: "users", columns: ["id", "name"], @@ -505,7 +513,7 @@ struct DataChangeManagerTests { @Test("New change clears redo stack") func newChangeClearsRedo() async { - let manager = DataChangeManager() + let manager = makeManagerWithUndo() manager.configureForTable( tableName: "users", columns: ["id", "name"], diff --git a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift index ffda0bca1..dd73727be 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift @@ -116,9 +116,9 @@ struct DataChangeModelsTests { #expect(rowChange.type == .insert) } - @Test("TabPendingChanges initializes as empty") + @Test("TabChangeSnapshot initializes as empty") func tabPendingChangesInit() { - let pending = TabPendingChanges() + let pending = TabChangeSnapshot() #expect(pending.changes.isEmpty) #expect(pending.deletedRowIndices.isEmpty) @@ -129,37 +129,37 @@ struct DataChangeModelsTests { #expect(pending.columns.isEmpty) } - @Test("TabPendingChanges hasChanges is false when empty") + @Test("TabChangeSnapshot hasChanges is false when empty") func tabPendingChangesHasChangesEmpty() { - let pending = TabPendingChanges() + let pending = TabChangeSnapshot() #expect(!pending.hasChanges) } - @Test("TabPendingChanges hasChanges is true with changes") + @Test("TabChangeSnapshot hasChanges is true with changes") func tabPendingChangesHasChangesWithChanges() { let rowChange = RowChange( rowIndex: 0, type: .update ) - var pending = TabPendingChanges() + var pending = TabChangeSnapshot() pending.changes = [rowChange] #expect(pending.hasChanges) } - @Test("TabPendingChanges hasChanges is true with deletedRowIndices") + @Test("TabChangeSnapshot hasChanges is true with deletedRowIndices") func tabPendingChangesHasChangesWithDeleted() { - var pending = TabPendingChanges() + var pending = TabChangeSnapshot() pending.deletedRowIndices = [1, 2, 3] #expect(pending.hasChanges) } - @Test("TabPendingChanges hasChanges is true with insertedRowIndices") + @Test("TabChangeSnapshot hasChanges is true with insertedRowIndices") func tabPendingChangesHasChangesWithInserted() { - var pending = TabPendingChanges() + var pending = TabChangeSnapshot() pending.insertedRowIndices = [0, 1] #expect(pending.hasChanges) diff --git a/TableProTests/Core/ChangeTracking/PendingChangesTests.swift b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift new file mode 100644 index 000000000..b1f2cc2a8 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift @@ -0,0 +1,318 @@ +// +// PendingChangesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("PendingChanges - record") +struct PendingChangesRecordTests { + @Test("Empty by default") + func emptyByDefault() { + let pending = PendingChanges() + #expect(pending.isEmpty) + #expect(pending.changes.isEmpty) + } + + @Test("Recording cell edit creates an update change") + func recordCellCreatesUpdate() { + var pending = PendingChanges() + let recorded = pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + #expect(recorded == true) + #expect(pending.changes.count == 1) + #expect(pending.changes[0].type == .update) + #expect(pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(pending.modifiedColumns(forRow: 0) == [1]) + } + + @Test("No-op edit when oldValue equals newValue and no prior change") + func noOpEdit() { + var pending = PendingChanges() + let recorded = pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "a" + ) + #expect(recorded == false) + #expect(pending.isEmpty) + } + + @Test("Editing back to original value collapses the change") + func revertToOriginalCollapses() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + let collapsed = pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "b", newValue: "a" + ) + #expect(collapsed == true) + #expect(pending.isEmpty) + #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + } + + @Test("Recording row deletion adds delete change and marks row deleted") + func recordRowDeletion() { + var pending = PendingChanges() + pending.recordRowDeletion(rowIndex: 5, originalRow: ["a", "b"]) + #expect(pending.isRowDeleted(5)) + #expect(pending.changes.count == 1) + #expect(pending.changes[0].type == .delete) + } + + @Test("Deleting a row clears its prior cell edits") + func deletionRemovesUpdate() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + pending.recordRowDeletion(rowIndex: 0, originalRow: ["a", nil]) + #expect(pending.changes.count == 1) + #expect(pending.changes[0].type == .delete) + #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + } + + @Test("Recording row insertion marks row inserted") + func recordRowInsertion() { + var pending = PendingChanges() + pending.recordRowInsertion(rowIndex: 3, values: ["x", "y"]) + #expect(pending.isRowInserted(3)) + #expect(pending.savedInsertedValues(forRow: 3) == ["x", "y"]) + } + + @Test("Double deletion of the same row is idempotent") + func doubleDeletionIsIdempotent() { + var pending = PendingChanges() + pending.recordRowDeletion(rowIndex: 5, originalRow: ["a"]) + pending.recordRowDeletion(rowIndex: 5, originalRow: ["a"]) + #expect(pending.changes.count == 1) + #expect(pending.isRowDeleted(5)) + } + + @Test("Double insertion of the same row updates stored values without duplicating") + func doubleInsertionIsIdempotent() { + var pending = PendingChanges() + pending.recordRowInsertion(rowIndex: 3, values: ["x"]) + pending.recordRowInsertion(rowIndex: 3, values: ["y"]) + #expect(pending.changes.count == 1) + #expect(pending.isRowInserted(3)) + #expect(pending.savedInsertedValues(forRow: 3) == ["y"]) + } +} + +@Suite("PendingChanges - undo") +struct PendingChangesUndoTests { + @Test("Undo row deletion clears delete state") + func undoRowDeletion() { + var pending = PendingChanges() + pending.recordRowDeletion(rowIndex: 0, originalRow: ["a"]) + let undone = pending.undoRowDeletion(rowIndex: 0) + #expect(undone == true) + #expect(!pending.isRowDeleted(0)) + #expect(pending.isEmpty) + } + + @Test("Undo row insertion shifts later inserted rows down") + func undoRowInsertionShiftsOthers() { + var pending = PendingChanges() + pending.recordRowInsertion(rowIndex: 1, values: ["a"]) + pending.recordRowInsertion(rowIndex: 2, values: ["b"]) + pending.recordRowInsertion(rowIndex: 3, values: ["c"]) + + let undone = pending.undoRowInsertion(rowIndex: 2) + #expect(undone == true) + #expect(pending.isRowInserted(1)) + #expect(pending.isRowInserted(2)) + #expect(!pending.isRowInserted(3)) + } + + @Test("Undo on row that was not inserted is a no-op") + func undoNonexistentInsertion() { + var pending = PendingChanges() + let undone = pending.undoRowInsertion(rowIndex: 99) + #expect(undone == false) + } + + @Test("Undo batch row insertion returns saved values in order") + func undoBatchRowInsertion() { + var pending = PendingChanges() + pending.recordRowInsertion(rowIndex: 1, values: ["a"]) + pending.recordRowInsertion(rowIndex: 2, values: ["b"]) + pending.recordRowInsertion(rowIndex: 3, values: ["c"]) + + let restored = pending.undoBatchRowInsertion(rowIndices: [1, 2, 3], columnCount: 1) + #expect(restored.count == 3) + #expect(!pending.isRowInserted(1)) + #expect(!pending.isRowInserted(2)) + #expect(!pending.isRowInserted(3)) + } +} + +@Suite("PendingChanges - replay") +struct PendingChangesReplayTests { + @Test("Reapply cell change with no existing change") + func reapplyCellWithoutExisting() { + var pending = PendingChanges() + pending.reapplyCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + originalDBValue: "orig", newValue: "x", originalRow: nil + ) + #expect(pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(pending.changes[0].cellChanges[0].oldValue == "orig") + } + + @Test("Reapply cell change preserves the original DB value as oldValue") + func reapplyCellPreservesOriginalDBValue() { + var pending = PendingChanges() + pending.reapplyCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + originalDBValue: "Alice", newValue: "Bob", originalRow: nil + ) + let cellChange = pending.changes[0].cellChanges[0] + #expect(cellChange.oldValue == "Alice") + #expect(cellChange.newValue == "Bob") + } + + @Test("Reinsert row creates insert change with saved values") + func reinsertRowFromUndo() { + var pending = PendingChanges() + pending.reinsertRow(rowIndex: 2, columns: ["a", "b"], savedValues: ["x", "y"]) + #expect(pending.isRowInserted(2)) + #expect(pending.savedInsertedValues(forRow: 2) == ["x", "y"]) + } + + @Test("Reapply row deletion adds delete change") + func reapplyDeletion() { + var pending = PendingChanges() + pending.reapplyRowDeletion(rowIndex: 0, originalRow: ["a", "b"]) + #expect(pending.isRowDeleted(0)) + } +} + +@Suite("PendingChanges - snapshot") +struct PendingChangesSnapshotTests { + @Test("Snapshot round-trip preserves state") + func snapshotRoundTrip() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + pending.recordRowDeletion(rowIndex: 5, originalRow: ["x"]) + pending.recordRowInsertion(rowIndex: 7, values: ["new"]) + + let snapshot = pending.snapshot(primaryKeyColumns: ["id"], columns: ["id", "name"]) + + var restored = PendingChanges() + restored.restore(from: snapshot) + + #expect(restored.changes.count == pending.changes.count) + #expect(restored.isRowDeleted(5)) + #expect(restored.isRowInserted(7)) + #expect(restored.isCellModified(rowIndex: 0, columnIndex: 1)) + } +} + +@Suite("PendingChanges - changedRowIndices tracking") +struct PendingChangesChangedRowIndicesTests { + @Test("revertUpdateCell records the row as changed") + func revertUpdateCellMarksChanged() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 4, columnIndex: 1, columnName: "name", + oldValue: "A", newValue: "B" + ) + _ = pending.consumeChangedRowIndices() + + pending.revertUpdateCell( + rowIndex: 4, columnIndex: 1, columnName: "name", previousValue: "A" + ) + #expect(pending.consumeChangedRowIndices().contains(4)) + } + + @Test("undoRowDeletion records the row as changed") + func undoRowDeletionMarksChanged() { + var pending = PendingChanges() + pending.recordRowDeletion(rowIndex: 7, originalRow: ["a"]) + _ = pending.consumeChangedRowIndices() + + _ = pending.undoRowDeletion(rowIndex: 7) + #expect(pending.consumeChangedRowIndices().contains(7)) + } + + @Test("undoRowInsertion records the row as changed") + func undoRowInsertionMarksChanged() { + var pending = PendingChanges() + pending.recordRowInsertion(rowIndex: 2, values: ["x"]) + _ = pending.consumeChangedRowIndices() + + _ = pending.undoRowInsertion(rowIndex: 2) + #expect(pending.consumeChangedRowIndices().contains(2)) + } + + @Test("reapplyRowDeletion records the row as changed") + func reapplyRowDeletionMarksChanged() { + var pending = PendingChanges() + _ = pending.consumeChangedRowIndices() + pending.reapplyRowDeletion(rowIndex: 3, originalRow: ["a"]) + #expect(pending.consumeChangedRowIndices().contains(3)) + } + + @Test("reapplyCellChange records the row as changed") + func reapplyCellChangeMarksChanged() { + var pending = PendingChanges() + _ = pending.consumeChangedRowIndices() + pending.reapplyCellChange( + rowIndex: 5, columnIndex: 1, columnName: "name", + originalDBValue: nil, newValue: "X", originalRow: nil + ) + #expect(pending.consumeChangedRowIndices().contains(5)) + } + + @Test("reinsertRow records the row as changed") + func reinsertRowMarksChanged() { + var pending = PendingChanges() + _ = pending.consumeChangedRowIndices() + pending.reinsertRow(rowIndex: 1, columns: ["a"], savedValues: ["v"]) + #expect(pending.consumeChangedRowIndices().contains(1)) + } +} + +@Suite("PendingChanges - clear and consume") +struct PendingChangesLifecycleTests { + @Test("Clear empties all internal state") + func clearResets() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 0, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + pending.recordRowDeletion(rowIndex: 5, originalRow: ["x"]) + pending.clear() + + #expect(pending.isEmpty) + #expect(pending.changes.isEmpty) + #expect(!pending.isRowDeleted(5)) + #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + } + + @Test("Consuming changedRowIndices empties the set") + func consumeChangedRows() { + var pending = PendingChanges() + pending.recordCellChange( + rowIndex: 3, columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "b" + ) + let first = pending.consumeChangedRowIndices() + #expect(first == [3]) + let second = pending.consumeChangedRowIndices() + #expect(second.isEmpty) + } +}