Skip to content

Commit 0129afb

Browse files
authored
refactor(datagrid): introduce TableRows / Row / Delta value types (#930)
* refactor(datagrid): introduce Row and Delta value types for Phase C * refactor(datagrid): introduce TableRows value type alongside RowBuffer * docs: note DataGrid Phase C.1 in CHANGELOG * test(datagrid): cover replace offset, editMany OOB column, factory pad/truncate
1 parent 618fc4c commit 0129afb

8 files changed

Lines changed: 828 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
### Changed
2424

25+
- 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).
2526
- 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.
2627
- AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts
2728
- Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click

TablePro/Models/Query/Delta.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// Delta.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum Delta: Equatable {
9+
case cellChanged(row: Int, column: Int)
10+
case cellsChanged(Set<CellPosition>)
11+
case rowsInserted(IndexSet)
12+
case rowsRemoved(IndexSet)
13+
case columnsReplaced
14+
case fullReplace
15+
16+
static let none = Delta.cellsChanged([])
17+
}

TablePro/Models/Query/Row.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// Row.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum RowID: Hashable, Sendable {
9+
case existing(Int)
10+
case inserted(UUID)
11+
12+
var isInserted: Bool {
13+
if case .inserted = self { return true }
14+
return false
15+
}
16+
}
17+
18+
struct Row: Equatable, Sendable {
19+
var id: RowID
20+
var values: [String?]
21+
22+
subscript(column: Int) -> String? {
23+
get { column >= 0 && column < values.count ? values[column] : nil }
24+
set {
25+
guard column >= 0, column < values.count else { return }
26+
values[column] = newValue
27+
}
28+
}
29+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//
2+
// TableRows.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct TableRows: Sendable {
9+
var rows: ContiguousArray<Row>
10+
var columns: [String]
11+
var columnTypes: [ColumnType]
12+
var columnDefaults: [String: String?]
13+
var columnForeignKeys: [String: ForeignKeyInfo]
14+
var columnEnumValues: [String: [String]]
15+
var columnNullable: [String: Bool]
16+
17+
init(
18+
rows: ContiguousArray<Row> = [],
19+
columns: [String] = [],
20+
columnTypes: [ColumnType] = [],
21+
columnDefaults: [String: String?] = [:],
22+
columnForeignKeys: [String: ForeignKeyInfo] = [:],
23+
columnEnumValues: [String: [String]] = [:],
24+
columnNullable: [String: Bool] = [:]
25+
) {
26+
self.rows = rows
27+
self.columns = columns
28+
self.columnTypes = columnTypes
29+
self.columnDefaults = columnDefaults
30+
self.columnForeignKeys = columnForeignKeys
31+
self.columnEnumValues = columnEnumValues
32+
self.columnNullable = columnNullable
33+
}
34+
35+
var count: Int { rows.count }
36+
37+
func value(at row: Int, column: Int) -> String? {
38+
guard row >= 0, row < rows.count else { return nil }
39+
return rows[row][column]
40+
}
41+
42+
@discardableResult
43+
mutating func edit(row: Int, column: Int, value: String?) -> Delta {
44+
guard row >= 0, row < rows.count else { return .none }
45+
guard column >= 0, column < columns.count else { return .none }
46+
guard column < rows[row].values.count else { return .none }
47+
if rows[row].values[column] == value { return .none }
48+
rows[row].values[column] = value
49+
return .cellChanged(row: row, column: column)
50+
}
51+
52+
@discardableResult
53+
mutating func editMany(_ edits: [(row: Int, column: Int, value: String?)]) -> Delta {
54+
var changed: Set<CellPosition> = []
55+
for edit in edits {
56+
guard edit.row >= 0, edit.row < rows.count else { continue }
57+
guard edit.column >= 0, edit.column < columns.count else { continue }
58+
guard edit.column < rows[edit.row].values.count else { continue }
59+
if rows[edit.row].values[edit.column] == edit.value { continue }
60+
rows[edit.row].values[edit.column] = edit.value
61+
changed.insert(CellPosition(row: edit.row, column: edit.column))
62+
}
63+
if changed.isEmpty { return .none }
64+
return .cellsChanged(changed)
65+
}
66+
67+
@discardableResult
68+
mutating func appendInsertedRow(values: [String?]) -> Delta {
69+
let normalized = Self.normalize(values: values, toCount: columns.count)
70+
let row = Row(id: .inserted(UUID()), values: normalized)
71+
rows.append(row)
72+
return .rowsInserted(IndexSet(integer: rows.count - 1))
73+
}
74+
75+
@discardableResult
76+
mutating func appendPage(_ pageRows: [[String?]], startingAt offset: Int) -> Delta {
77+
guard !pageRows.isEmpty else { return .none }
78+
let firstIndex = rows.count
79+
for (idx, values) in pageRows.enumerated() {
80+
let normalized = Self.normalize(values: values, toCount: columns.count)
81+
rows.append(Row(id: .existing(offset + idx), values: normalized))
82+
}
83+
let lastIndex = rows.count - 1
84+
return .rowsInserted(IndexSet(integersIn: firstIndex...lastIndex))
85+
}
86+
87+
@discardableResult
88+
mutating func remove(rowIDs: Set<RowID>) -> Delta {
89+
guard !rowIDs.isEmpty else { return .none }
90+
var indices = IndexSet()
91+
for (index, row) in rows.enumerated() where rowIDs.contains(row.id) {
92+
indices.insert(index)
93+
}
94+
return removeIndices(indices)
95+
}
96+
97+
@discardableResult
98+
mutating func remove(at indices: IndexSet) -> Delta {
99+
let valid = indices.filteredIndexSet { $0 >= 0 && $0 < rows.count }
100+
return removeIndices(valid)
101+
}
102+
103+
@discardableResult
104+
mutating func replace(rows replacementRows: [[String?]], offset: Int = 0) -> Delta {
105+
var rebuilt = ContiguousArray<Row>()
106+
rebuilt.reserveCapacity(replacementRows.count)
107+
for (idx, values) in replacementRows.enumerated() {
108+
let normalized = Self.normalize(values: values, toCount: columns.count)
109+
rebuilt.append(Row(id: .existing(offset + idx), values: normalized))
110+
}
111+
rows = rebuilt
112+
return .fullReplace
113+
}
114+
115+
@discardableResult
116+
mutating func updateDisplayMetadata(
117+
columnTypes: [ColumnType]? = nil,
118+
columnDefaults: [String: String?]? = nil,
119+
columnForeignKeys: [String: ForeignKeyInfo]? = nil,
120+
columnEnumValues: [String: [String]]? = nil,
121+
columnNullable: [String: Bool]? = nil
122+
) -> Delta {
123+
var didChange = false
124+
if let columnTypes, columnTypes != self.columnTypes {
125+
self.columnTypes = columnTypes
126+
didChange = true
127+
}
128+
if let columnDefaults, columnDefaults != self.columnDefaults {
129+
self.columnDefaults = columnDefaults
130+
didChange = true
131+
}
132+
if let columnForeignKeys, columnForeignKeys != self.columnForeignKeys {
133+
self.columnForeignKeys = columnForeignKeys
134+
didChange = true
135+
}
136+
if let columnEnumValues, columnEnumValues != self.columnEnumValues {
137+
self.columnEnumValues = columnEnumValues
138+
didChange = true
139+
}
140+
if let columnNullable, columnNullable != self.columnNullable {
141+
self.columnNullable = columnNullable
142+
didChange = true
143+
}
144+
return didChange ? .columnsReplaced : .none
145+
}
146+
147+
static func from(
148+
queryRows: [[String?]],
149+
columns: [String],
150+
columnTypes: [ColumnType],
151+
columnDefaults: [String: String?] = [:],
152+
columnForeignKeys: [String: ForeignKeyInfo] = [:],
153+
columnEnumValues: [String: [String]] = [:],
154+
columnNullable: [String: Bool] = [:]
155+
) -> TableRows {
156+
var rows = ContiguousArray<Row>()
157+
rows.reserveCapacity(queryRows.count)
158+
for (index, values) in queryRows.enumerated() {
159+
let normalized = normalize(values: values, toCount: columns.count)
160+
rows.append(Row(id: .existing(index), values: normalized))
161+
}
162+
return TableRows(
163+
rows: rows,
164+
columns: columns,
165+
columnTypes: columnTypes,
166+
columnDefaults: columnDefaults,
167+
columnForeignKeys: columnForeignKeys,
168+
columnEnumValues: columnEnumValues,
169+
columnNullable: columnNullable
170+
)
171+
}
172+
173+
private mutating func removeIndices(_ indices: IndexSet) -> Delta {
174+
guard !indices.isEmpty else { return .none }
175+
for index in indices.reversed() {
176+
rows.remove(at: index)
177+
}
178+
return .rowsRemoved(indices)
179+
}
180+
181+
private static func normalize(values: [String?], toCount targetCount: Int) -> [String?] {
182+
if values.count == targetCount { return values }
183+
if values.count > targetCount { return Array(values.prefix(targetCount)) }
184+
return values + Array(repeating: nil, count: targetCount - values.count)
185+
}
186+
}

TablePro/Views/Results/DataGridView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import AppKit
1010
import SwiftUI
1111

1212
/// Position of a cell in the grid (row, column)
13-
struct CellPosition: Equatable {
13+
struct CellPosition: Hashable {
1414
let row: Int
1515
let column: Int
1616
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// DeltaTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
@testable import TablePro
8+
import Testing
9+
10+
@Suite("Delta")
11+
struct DeltaTests {
12+
@Test("cellChanged equality matches on row and column")
13+
func cellChangedEquality() {
14+
let lhs = Delta.cellChanged(row: 1, column: 2)
15+
let rhs = Delta.cellChanged(row: 1, column: 2)
16+
let other = Delta.cellChanged(row: 2, column: 2)
17+
#expect(lhs == rhs)
18+
#expect(lhs != other)
19+
}
20+
21+
@Test("cellsChanged equality matches on the underlying set")
22+
func cellsChangedEquality() {
23+
let lhs = Delta.cellsChanged([CellPosition(row: 0, column: 1), CellPosition(row: 2, column: 3)])
24+
let rhs = Delta.cellsChanged([CellPosition(row: 2, column: 3), CellPosition(row: 0, column: 1)])
25+
#expect(lhs == rhs)
26+
}
27+
28+
@Test("rowsInserted equality matches on the underlying IndexSet")
29+
func rowsInsertedEquality() {
30+
let lhs = Delta.rowsInserted(IndexSet(0...2))
31+
let rhs = Delta.rowsInserted(IndexSet(0...2))
32+
let other = Delta.rowsInserted(IndexSet(0...3))
33+
#expect(lhs == rhs)
34+
#expect(lhs != other)
35+
}
36+
37+
@Test("rowsRemoved equality matches on the underlying IndexSet")
38+
func rowsRemovedEquality() {
39+
let lhs = Delta.rowsRemoved(IndexSet([1, 3]))
40+
let rhs = Delta.rowsRemoved(IndexSet([1, 3]))
41+
let other = Delta.rowsRemoved(IndexSet([1, 4]))
42+
#expect(lhs == rhs)
43+
#expect(lhs != other)
44+
}
45+
46+
@Test("columnsReplaced equals itself")
47+
func columnsReplacedEquality() {
48+
let lhs = Delta.columnsReplaced
49+
let rhs = Delta.columnsReplaced
50+
#expect(lhs == rhs)
51+
}
52+
53+
@Test("fullReplace equals itself")
54+
func fullReplaceEquality() {
55+
let lhs = Delta.fullReplace
56+
let rhs = Delta.fullReplace
57+
#expect(lhs == rhs)
58+
}
59+
60+
@Test("Delta.none is an empty cellsChanged set")
61+
func noneIsEmptyCellsChanged() {
62+
#expect(Delta.none == Delta.cellsChanged([]))
63+
}
64+
65+
@Test("Distinct cases never compare equal")
66+
func distinctCasesAreUnequal() {
67+
let single = Delta.cellChanged(row: 0, column: 0)
68+
let many = Delta.cellsChanged([CellPosition(row: 0, column: 0)])
69+
let inserted = Delta.rowsInserted(IndexSet(integer: 0))
70+
let removed = Delta.rowsRemoved(IndexSet(integer: 0))
71+
#expect(single != many)
72+
#expect(single != inserted)
73+
#expect(many != removed)
74+
#expect(inserted != removed)
75+
#expect(Delta.columnsReplaced != Delta.fullReplace)
76+
}
77+
}

0 commit comments

Comments
 (0)