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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor).
- 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
Expand Down
17 changes: 17 additions & 0 deletions TablePro/Models/Query/Delta.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// Delta.swift
// TablePro
//

import Foundation

enum Delta: Equatable {
case cellChanged(row: Int, column: Int)
case cellsChanged(Set<CellPosition>)
case rowsInserted(IndexSet)
case rowsRemoved(IndexSet)
case columnsReplaced
case fullReplace

static let none = Delta.cellsChanged([])
}
29 changes: 29 additions & 0 deletions TablePro/Models/Query/Row.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Row.swift
// TablePro
//

import Foundation

enum RowID: Hashable, Sendable {
case existing(Int)
case inserted(UUID)

var isInserted: Bool {
if case .inserted = self { return true }
return false
}
}

struct Row: Equatable, Sendable {
var id: RowID
var values: [String?]

subscript(column: Int) -> String? {
get { column >= 0 && column < values.count ? values[column] : nil }
set {
guard column >= 0, column < values.count else { return }
values[column] = newValue
}
}
}
186 changes: 186 additions & 0 deletions TablePro/Models/Query/TableRows.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//
// TableRows.swift
// TablePro
//

import Foundation

struct TableRows: Sendable {
var rows: ContiguousArray<Row>
var columns: [String]
var columnTypes: [ColumnType]
var columnDefaults: [String: String?]
var columnForeignKeys: [String: ForeignKeyInfo]
var columnEnumValues: [String: [String]]
var columnNullable: [String: Bool]

init(
rows: ContiguousArray<Row> = [],
columns: [String] = [],
columnTypes: [ColumnType] = [],
columnDefaults: [String: String?] = [:],
columnForeignKeys: [String: ForeignKeyInfo] = [:],
columnEnumValues: [String: [String]] = [:],
columnNullable: [String: Bool] = [:]
) {
self.rows = rows
self.columns = columns
self.columnTypes = columnTypes
self.columnDefaults = columnDefaults
self.columnForeignKeys = columnForeignKeys
self.columnEnumValues = columnEnumValues
self.columnNullable = columnNullable
}

var count: Int { rows.count }

func value(at row: Int, column: Int) -> String? {
guard row >= 0, row < rows.count else { return nil }
return rows[row][column]
}

@discardableResult
mutating func edit(row: Int, column: Int, value: String?) -> Delta {
guard row >= 0, row < rows.count else { return .none }
guard column >= 0, column < columns.count else { return .none }
guard column < rows[row].values.count else { return .none }
if rows[row].values[column] == value { return .none }
rows[row].values[column] = value
return .cellChanged(row: row, column: column)
}

@discardableResult
mutating func editMany(_ edits: [(row: Int, column: Int, value: String?)]) -> Delta {
var changed: Set<CellPosition> = []
for edit in edits {
guard edit.row >= 0, edit.row < rows.count else { continue }
guard edit.column >= 0, edit.column < columns.count else { continue }
guard edit.column < rows[edit.row].values.count else { continue }
if rows[edit.row].values[edit.column] == edit.value { continue }
rows[edit.row].values[edit.column] = edit.value
changed.insert(CellPosition(row: edit.row, column: edit.column))
}
if changed.isEmpty { return .none }
return .cellsChanged(changed)
}

@discardableResult
mutating func appendInsertedRow(values: [String?]) -> Delta {
let normalized = Self.normalize(values: values, toCount: columns.count)
let row = Row(id: .inserted(UUID()), values: normalized)
rows.append(row)
return .rowsInserted(IndexSet(integer: rows.count - 1))
}

@discardableResult
mutating func appendPage(_ pageRows: [[String?]], startingAt offset: Int) -> Delta {
guard !pageRows.isEmpty else { return .none }
let firstIndex = rows.count
for (idx, values) in pageRows.enumerated() {
let normalized = Self.normalize(values: values, toCount: columns.count)
rows.append(Row(id: .existing(offset + idx), values: normalized))
}
let lastIndex = rows.count - 1
return .rowsInserted(IndexSet(integersIn: firstIndex...lastIndex))
}

@discardableResult
mutating func remove(rowIDs: Set<RowID>) -> Delta {
guard !rowIDs.isEmpty else { return .none }
var indices = IndexSet()
for (index, row) in rows.enumerated() where rowIDs.contains(row.id) {
indices.insert(index)
}
return removeIndices(indices)
}

@discardableResult
mutating func remove(at indices: IndexSet) -> Delta {
let valid = indices.filteredIndexSet { $0 >= 0 && $0 < rows.count }
return removeIndices(valid)
}

@discardableResult
mutating func replace(rows replacementRows: [[String?]], offset: Int = 0) -> Delta {
var rebuilt = ContiguousArray<Row>()
rebuilt.reserveCapacity(replacementRows.count)
for (idx, values) in replacementRows.enumerated() {
let normalized = Self.normalize(values: values, toCount: columns.count)
rebuilt.append(Row(id: .existing(offset + idx), values: normalized))
}
rows = rebuilt
return .fullReplace
}

@discardableResult
mutating func updateDisplayMetadata(
columnTypes: [ColumnType]? = nil,
columnDefaults: [String: String?]? = nil,
columnForeignKeys: [String: ForeignKeyInfo]? = nil,
columnEnumValues: [String: [String]]? = nil,
columnNullable: [String: Bool]? = nil
) -> Delta {
var didChange = false
if let columnTypes, columnTypes != self.columnTypes {
self.columnTypes = columnTypes
didChange = true
}
if let columnDefaults, columnDefaults != self.columnDefaults {
self.columnDefaults = columnDefaults
didChange = true
}
if let columnForeignKeys, columnForeignKeys != self.columnForeignKeys {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare foreign keys using stable fields

updateDisplayMetadata currently decides whether foreign keys changed by comparing [String: ForeignKeyInfo] directly, but ForeignKeyInfo has a synthesized equality that includes its id UUID (QueryResult.swift), which is regenerated on each construction. In practice, reloading identical FK metadata from the backend will often look different and return .columnsReplaced, causing unnecessary full metadata invalidations/reloads even when nothing semantically changed.

Useful? React with 👍 / 👎.

self.columnForeignKeys = columnForeignKeys
didChange = true
}
if let columnEnumValues, columnEnumValues != self.columnEnumValues {
self.columnEnumValues = columnEnumValues
didChange = true
}
if let columnNullable, columnNullable != self.columnNullable {
self.columnNullable = columnNullable
didChange = true
}
return didChange ? .columnsReplaced : .none
}

static func from(
queryRows: [[String?]],
columns: [String],
columnTypes: [ColumnType],
columnDefaults: [String: String?] = [:],
columnForeignKeys: [String: ForeignKeyInfo] = [:],
columnEnumValues: [String: [String]] = [:],
columnNullable: [String: Bool] = [:]
) -> TableRows {
var rows = ContiguousArray<Row>()
rows.reserveCapacity(queryRows.count)
for (index, values) in queryRows.enumerated() {
let normalized = normalize(values: values, toCount: columns.count)
rows.append(Row(id: .existing(index), values: normalized))
}
return TableRows(
rows: rows,
columns: columns,
columnTypes: columnTypes,
columnDefaults: columnDefaults,
columnForeignKeys: columnForeignKeys,
columnEnumValues: columnEnumValues,
columnNullable: columnNullable
)
}

private mutating func removeIndices(_ indices: IndexSet) -> Delta {
guard !indices.isEmpty else { return .none }
for index in indices.reversed() {
rows.remove(at: index)
}
return .rowsRemoved(indices)
}

private static func normalize(values: [String?], toCount targetCount: Int) -> [String?] {
if values.count == targetCount { return values }
if values.count > targetCount { return Array(values.prefix(targetCount)) }
return values + Array(repeating: nil, count: targetCount - values.count)
}
}
2 changes: 1 addition & 1 deletion TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import AppKit
import SwiftUI

/// Position of a cell in the grid (row, column)
struct CellPosition: Equatable {
struct CellPosition: Hashable {
let row: Int
let column: Int
}
Expand Down
77 changes: 77 additions & 0 deletions TableProTests/Models/Query/DeltaTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// DeltaTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("Delta")
struct DeltaTests {
@Test("cellChanged equality matches on row and column")
func cellChangedEquality() {
let lhs = Delta.cellChanged(row: 1, column: 2)
let rhs = Delta.cellChanged(row: 1, column: 2)
let other = Delta.cellChanged(row: 2, column: 2)
#expect(lhs == rhs)
#expect(lhs != other)
}

@Test("cellsChanged equality matches on the underlying set")
func cellsChangedEquality() {
let lhs = Delta.cellsChanged([CellPosition(row: 0, column: 1), CellPosition(row: 2, column: 3)])
let rhs = Delta.cellsChanged([CellPosition(row: 2, column: 3), CellPosition(row: 0, column: 1)])
#expect(lhs == rhs)
}

@Test("rowsInserted equality matches on the underlying IndexSet")
func rowsInsertedEquality() {
let lhs = Delta.rowsInserted(IndexSet(0...2))
let rhs = Delta.rowsInserted(IndexSet(0...2))
let other = Delta.rowsInserted(IndexSet(0...3))
#expect(lhs == rhs)
#expect(lhs != other)
}

@Test("rowsRemoved equality matches on the underlying IndexSet")
func rowsRemovedEquality() {
let lhs = Delta.rowsRemoved(IndexSet([1, 3]))
let rhs = Delta.rowsRemoved(IndexSet([1, 3]))
let other = Delta.rowsRemoved(IndexSet([1, 4]))
#expect(lhs == rhs)
#expect(lhs != other)
}

@Test("columnsReplaced equals itself")
func columnsReplacedEquality() {
let lhs = Delta.columnsReplaced
let rhs = Delta.columnsReplaced
#expect(lhs == rhs)
}

@Test("fullReplace equals itself")
func fullReplaceEquality() {
let lhs = Delta.fullReplace
let rhs = Delta.fullReplace
#expect(lhs == rhs)
}

@Test("Delta.none is an empty cellsChanged set")
func noneIsEmptyCellsChanged() {
#expect(Delta.none == Delta.cellsChanged([]))
}

@Test("Distinct cases never compare equal")
func distinctCasesAreUnequal() {
let single = Delta.cellChanged(row: 0, column: 0)
let many = Delta.cellsChanged([CellPosition(row: 0, column: 0)])
let inserted = Delta.rowsInserted(IndexSet(integer: 0))
let removed = Delta.rowsRemoved(IndexSet(integer: 0))
#expect(single != many)
#expect(single != inserted)
#expect(many != removed)
#expect(inserted != removed)
#expect(Delta.columnsReplaced != Delta.fullReplace)
}
}
Loading
Loading