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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The same statements committed twice when Cmd+S is pressed again during a slow save.
- Switching tabs during a save clearing the edits of the tab switched to, and re-running its query.
- Placeholder SQL with no values in the Safe Mode confirmation and the authorization prompt.
- Deleting one row of a pasted batch dropping the other rows, or saving them with another row's values.
- Undoing the deletion of a new row putting its values in the wrong columns.
- Undoing a cell edit reverting the wrong row while a column filter is active.
- A save that deletes a row and reuses its unique value failing on the constraint.
- "Ignore foreign key checks" doing nothing on SQLite, libSQL and Cloudflare D1.
- A save reported as failed on an engine without transactions after some statements had been written.

## [0.68.1] - 2026-08-26

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ final class DataChangeManager: ChangeManaging {
)
} else {
pending.reapplyCellChange(
rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName,
rowIndex: rowIndex,
columnIndex: columnIndex, columnName: columnName,
originalDBValue: newValue, newValue: previousValue, originalRow: originalRow
)
}
Expand Down
15 changes: 11 additions & 4 deletions TablePro/Core/ChangeTracking/DataChangeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@ enum ChangeType: Hashable {

struct CellChange: Identifiable, Equatable {
let id: UUID
let rowIndex: Int
let columnIndex: Int
let columnName: String
let oldValue: PluginCellValue
let newValue: PluginCellValue

init(
rowIndex: Int,
columnIndex: Int,
columnName: String,
oldValue: PluginCellValue,
newValue: PluginCellValue
) {
self.id = UUID()
self.rowIndex = rowIndex
self.columnIndex = columnIndex
self.columnName = columnName
self.oldValue = oldValue
Expand All @@ -43,17 +40,27 @@ struct RowChange: Identifiable, Equatable {
var cellChanges: [CellChange]
let originalRow: [PluginCellValue]?

/// The order the user made this change in.
///
/// Not the array position. `PendingChanges` removes a cancelled change by swapping the last
/// element into its slot, so array order stops matching edit order the first time anything is
/// undone. Statement generation has to know which change came first, because deleting a row
/// and reusing its unique value in a new one only works in that order.
var sequence: Int

init(
rowIndex: Int,
type: ChangeType,
cellChanges: [CellChange] = [],
originalRow: [PluginCellValue]? = nil
originalRow: [PluginCellValue]? = nil,
sequence: Int = 0
) {
self.id = UUID()
self.rowIndex = rowIndex
self.type = type
self.cellChanges = cellChanges
self.originalRow = originalRow
self.sequence = sequence
}
}

Expand Down
126 changes: 59 additions & 67 deletions TablePro/Core/ChangeTracking/PendingChanges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import TableProPluginKit
struct PendingChanges: Equatable {
private(set) var changes: [RowChange] = []
private(set) var deletedRowIndices: Set<Int> = []

/// Stamped onto every change so statement generation can recover the order the user worked in.
/// `changes` cannot carry that order itself: a cancelled change is removed by swapping the last
/// element into its slot.
private var nextSequence = 0
private(set) var insertedRowIndices: Set<Int> = []
private(set) var modifiedCells: [Int: Set<Int>] = [:]
private(set) var insertedRowData: [Int: [PluginCellValue]] = [:]
Expand Down Expand Up @@ -67,7 +72,6 @@ struct PendingChanges: Equatable {
}

let cellChange = CellChange(
rowIndex: rowIndex,
columnIndex: columnIndex,
columnName: columnName,
oldValue: oldValue,
Expand Down Expand Up @@ -137,16 +141,11 @@ struct PendingChanges: Equatable {
mutating func undoBatchRowInsertion(rowIndices: [Int], columnCount: Int) -> [[PluginCellValue]] {
let validRows = rowIndices.filter { insertedRowIndices.contains($0) }

var rowValues: [[PluginCellValue]] = []
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: .null, count: columnCount))
}
/// `insertedRowData` holds the whole row. `cellChanges` holds only the columns the user
/// typed, so rebuilding from it drops the untouched ones and slides the rest left: a name
/// typed into the third column comes back in the first.
let rowValues = validRows.map { rowIndex in
insertedRowData[rowIndex] ?? Array(repeating: PluginCellValue.null, count: columnCount)
}

for rowIndex in validRows {
Expand All @@ -155,20 +154,7 @@ struct PendingChanges: Equatable {
insertedRowData.removeValue(forKey: rowIndex)
}

let sortedRemoved = validRows.sorted()

var newInserted = Set<Int>()
for idx in insertedRowIndices {
newInserted.insert(idx - Self.countLessThan(idx, in: sortedRemoved))
}
insertedRowIndices = newInserted

for i in 0..<changes.count {
let rowIndex = changes[i].rowIndex
changes[i].rowIndex = rowIndex - Self.countLessThan(rowIndex, in: sortedRemoved)
}

rebuildChangeIndex()
shiftRowIndicesDown(atSortedRows: validRows.sorted())
return rowValues
}

Expand All @@ -195,7 +181,6 @@ struct PendingChanges: Equatable {
originalRow: [PluginCellValue]?
) {
let cellChange = CellChange(
rowIndex: rowIndex,
columnIndex: columnIndex,
columnName: columnName,
oldValue: originalDBValue,
Expand Down Expand Up @@ -256,7 +241,6 @@ struct PendingChanges: Equatable {
}
} else {
changes[updateIdx].cellChanges[cellIdx] = CellChange(
rowIndex: rowIndex,
columnIndex: columnIndex,
columnName: columnName,
oldValue: originalOldValue,
Expand All @@ -271,7 +255,7 @@ struct PendingChanges: Equatable {
insertedRowIndices.insert(rowIndex)
let cellChanges = columns.enumerated().map { index, columnName in
CellChange(
rowIndex: rowIndex, columnIndex: index, columnName: columnName,
columnIndex: index, columnName: columnName,
oldValue: nil, newValue: savedValues?[safe: index] ?? nil
)
}
Expand All @@ -293,12 +277,12 @@ struct PendingChanges: Equatable {
let values = rowValues[index]
let cellChanges = values.enumerated().map { colIndex, value in
CellChange(
rowIndex: rowIndex, columnIndex: colIndex,
columnIndex: colIndex,
columnName: columns[safe: colIndex] ?? "",
oldValue: nil, newValue: value
)
}
changes.append(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges))
appendChange(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges))
insertedRowIndices.insert(rowIndex)
insertedRowData[rowIndex] = values
}
Expand All @@ -318,6 +302,7 @@ struct PendingChanges: Equatable {
// MARK: - Reset / persistence

mutating func clear() {
nextSequence = 0
changes.removeAll()
changeIndex.removeAll()
deletedRowIndices.removeAll()
Expand All @@ -332,6 +317,7 @@ struct PendingChanges: Equatable {
insertedRowIndices = snapshot.insertedRowIndices
modifiedCells = snapshot.modifiedCells
insertedRowData = snapshot.insertedRowData
nextSequence = (changes.map(\.sequence).max() ?? -1) + 1
rebuildChangeIndex()
}

Expand All @@ -350,8 +336,11 @@ struct PendingChanges: Equatable {
// MARK: - Internals

private mutating func appendChange(_ change: RowChange) {
changes.append(change)
changeIndex[RowChangeKey(rowIndex: change.rowIndex, type: change.type)] = changes.count - 1
var stamped = change
stamped.sequence = nextSequence
nextSequence += 1
changes.append(stamped)
changeIndex[RowChangeKey(rowIndex: stamped.rowIndex, type: stamped.type)] = changes.count - 1
}

@discardableResult
Expand Down Expand Up @@ -392,7 +381,7 @@ struct PendingChanges: Equatable {
}

let replacement = CellChange(
rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName,
columnIndex: columnIndex, columnName: columnName,
oldValue: nil, newValue: newValue
)
if let cellIdx = changes[insertIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) {
Expand All @@ -409,7 +398,6 @@ struct PendingChanges: Equatable {
}) {
let originalOldValue = changes[updateIdx].cellChanges[cellIdx].oldValue
let merged = CellChange(
rowIndex: rowIndex,
columnIndex: cellChange.columnIndex,
columnName: cellChange.columnName,
oldValue: originalOldValue,
Expand Down Expand Up @@ -454,46 +442,50 @@ struct PendingChanges: Equatable {
return true
}

private mutating func shiftRowIndicesUp(from insertionPoint: Int) {
for i in 0..<changes.count where changes[i].rowIndex >= 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: [PluginCellValue]] = [:]
for (key, value) in insertedRowData {
newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value
/// Renumbers every collection this type keys by row index, in one place.
///
/// The state is six things that have to agree: `changes[].rowIndex`, `changeIndex`,
/// `insertedRowIndices`, `deletedRowIndices`, `insertedRowData` and `modifiedCells`. There used
/// to be three renumbering paths handling three different subsets of them, and the gaps were
/// invisible: undoing one row of a pasted batch left the surviving rows' values filed under
/// their old indices, so Save wrote one row with another row's values and dropped the rest
/// without reporting anything. A single primitive is what makes that class of omission
/// impossible rather than merely absent.
///
/// A new row always lands at the end of the grid, so nothing pending is ever below an inserted
/// one and the delete and modified-cell arms do not fire today. They are here because the
/// alternative is three renumbering paths covering three different subsets again, which is
/// what this replaced.
private mutating func reindex(_ transform: (Int) -> Int) {
for i in 0 ..< changes.count {
changes[i].rowIndex = transform(changes[i].rowIndex)
}
insertedRowData = newInsertedRowData

var newModifiedCells: [Int: Set<Int>] = [:]
for (key, value) in modifiedCells {
newModifiedCells[key >= insertionPoint ? key + 1 : key] = value
}
modifiedCells = newModifiedCells

insertedRowIndices = Set(insertedRowIndices.map(transform))
deletedRowIndices = Set(deletedRowIndices.map(transform))
insertedRowData = Dictionary(
uniqueKeysWithValues: insertedRowData.map { (transform($0.key), $0.value) }
)
modifiedCells = Dictionary(
uniqueKeysWithValues: modifiedCells.map { (transform($0.key), $0.value) }
)
rebuildChangeIndex()
}

private mutating func shiftRowIndicesDown(at removedRow: Int) {
for i in 0..<changes.count where changes[i].rowIndex > removedRow {
changes[i].rowIndex -= 1
}
insertedRowIndices = Set(insertedRowIndices.map { $0 > removedRow ? $0 - 1 : $0 })
private mutating func shiftRowIndicesUp(from insertionPoint: Int) {
reindex { $0 >= insertionPoint ? $0 + 1 : $0 }
}

var newInsertedRowData: [Int: [PluginCellValue]] = [:]
for (key, value) in insertedRowData {
newInsertedRowData[key > removedRow ? key - 1 : key] = value
}
insertedRowData = newInsertedRowData
private mutating func shiftRowIndicesDown(at removedRow: Int) {
modifiedCells.removeValue(forKey: removedRow)
reindex { $0 > removedRow ? $0 - 1 : $0 }
}

var newModifiedCells: [Int: Set<Int>] = [:]
for (key, value) in modifiedCells where key != removedRow {
newModifiedCells[key > removedRow ? key - 1 : key] = value
/// The same renumbering for a whole batch removed at once.
private mutating func shiftRowIndicesDown(atSortedRows removedRows: [Int]) {
for removedRow in removedRows {
modifiedCells.removeValue(forKey: removedRow)
}
modifiedCells = newModifiedCells
rebuildChangeIndex()
reindex { $0 - Self.countLessThan($0, in: removedRows) }
}

/// Binary search: count of elements strictly less than `target` in a sorted array.
Expand Down
52 changes: 24 additions & 28 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,54 +90,50 @@ struct SQLStatementGenerator {

/// The same statements, each carrying how many rows it is meant to touch.
///
/// A delete is batched, so the count is not always one and the batching rule lives here. A
/// caller that verified the server's affected-row count against a rule of its own would be a
/// second copy of that rule with nothing keeping the two in step.
/// Emitted in the order the user made the changes, because that order can be load-bearing: a
/// row deleted to free a unique value, and a new row taking that value, only work if the
/// DELETE runs first. Grouping every INSERT ahead of every DELETE, which is what this used to
/// do, turned that save into a constraint violation and rolled the whole thing back.
///
/// Deletes still batch, but only across a consecutive run of them. That keeps the case that
/// matters, selecting many rows and pressing Delete, on one statement, while a delete
/// separated from another delete by an insert stays on its own side of it.
func generateAttributedStatements(
from changes: [RowChange],
insertedRowData: [Int: [PluginCellValue]],
deletedRowIndices: Set<Int>,
insertedRowIndices: Set<Int>
) -> [AttributedStatement] {
var statements: [AttributedStatement] = []
var deleteRun: [RowChange] = []

// Collect UPDATE and DELETE changes to batch them
var updateChanges: [RowChange] = []
var deleteChanges: [RowChange] = []
func flushDeleteRun() {
guard !deleteRun.isEmpty else { return }
statements.append(contentsOf: generateDeleteStatements(for: deleteRun))
deleteRun.removeAll(keepingCapacity: true)
}

for change in changes {
for change in changes.sorted(by: { $0.sequence < $1.sequence }) {
switch change.type {
case .update:
updateChanges.append(change)
flushDeleteRun()
if let stmt = generateUpdateSQL(for: change) {
statements.append(AttributedStatement(statement: stmt, kind: .update, rowCount: 1))
}
case .insert:
// SAFETY: Verify the row is still marked as inserted
guard insertedRowIndices.contains(change.rowIndex) else {
continue
}
guard insertedRowIndices.contains(change.rowIndex) else { continue }
flushDeleteRun()
if let stmt = generateInsertSQL(for: change, insertedRowData: insertedRowData) {
statements.append(AttributedStatement(statement: stmt, kind: .insert, rowCount: 1))
}
case .delete:
// SAFETY: Verify the row is still marked as deleted
guard deletedRowIndices.contains(change.rowIndex) else {
continue
}
deleteChanges.append(change)
guard deletedRowIndices.contains(change.rowIndex) else { continue }
deleteRun.append(change)
}
}

// Generate individual UPDATE statements (safer than batched CASE/WHEN)
if !updateChanges.isEmpty {
for change in updateChanges {
if let stmt = generateUpdateSQL(for: change) {
statements.append(AttributedStatement(statement: stmt, kind: .update, rowCount: 1))
}
}
}

if !deleteChanges.isEmpty {
statements.append(contentsOf: generateDeleteStatements(for: deleteChanges))
}
flushDeleteRun()

return statements
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ extension RowEditingCoordinator {
route: DatabaseManager.shared.executionRoute(for: scope),
cancellation: .protectedWrite
) { driver in
try await DataWriteExecutor.run(statements: statements, mode: mode, on: driver)
_ = try await DataWriteExecutor.run(statements: statements, mode: mode, on: driver)
}
}

Expand Down
Loading
Loading