From 28c520399aa3ca7cb562eb216a5706555bf3d959 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:05:48 +0700 Subject: [PATCH 1/4] refactor(datagrid): introduce Row and Delta value types for Phase C --- TablePro/Models/Query/Delta.swift | 17 +++++ TablePro/Models/Query/Row.swift | 29 +++++++ TablePro/Views/Results/DataGridView.swift | 2 +- TableProTests/Models/Query/DeltaTests.swift | 77 +++++++++++++++++++ TableProTests/Models/Query/RowTests.swift | 84 +++++++++++++++++++++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 TablePro/Models/Query/Delta.swift create mode 100644 TablePro/Models/Query/Row.swift create mode 100644 TableProTests/Models/Query/DeltaTests.swift create mode 100644 TableProTests/Models/Query/RowTests.swift diff --git a/TablePro/Models/Query/Delta.swift b/TablePro/Models/Query/Delta.swift new file mode 100644 index 000000000..b17239aa2 --- /dev/null +++ b/TablePro/Models/Query/Delta.swift @@ -0,0 +1,17 @@ +// +// Delta.swift +// TablePro +// + +import Foundation + +enum Delta: Equatable { + case cellChanged(row: Int, column: Int) + case cellsChanged(Set) + case rowsInserted(IndexSet) + case rowsRemoved(IndexSet) + case columnsReplaced + case fullReplace + + static let none = Delta.cellsChanged([]) +} diff --git a/TablePro/Models/Query/Row.swift b/TablePro/Models/Query/Row.swift new file mode 100644 index 000000000..b40fc16d8 --- /dev/null +++ b/TablePro/Models/Query/Row.swift @@ -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 + } + } +} diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f04b30c55..aa8f3effa 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -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 } diff --git a/TableProTests/Models/Query/DeltaTests.swift b/TableProTests/Models/Query/DeltaTests.swift new file mode 100644 index 000000000..e0dac47e2 --- /dev/null +++ b/TableProTests/Models/Query/DeltaTests.swift @@ -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) + } +} diff --git a/TableProTests/Models/Query/RowTests.swift b/TableProTests/Models/Query/RowTests.swift new file mode 100644 index 000000000..d63d8caf8 --- /dev/null +++ b/TableProTests/Models/Query/RowTests.swift @@ -0,0 +1,84 @@ +// +// RowTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("RowID") +struct RowIDTests { + @Test("Two inserted RowIDs have different UUIDs") + func insertedFactoriesProduceDistinctUUIDs() { + let first = RowID.inserted(UUID()) + let second = RowID.inserted(UUID()) + #expect(first != second) + } + + @Test("Existing RowIDs with the same ordinal compare equal") + func existingFactoriesEqualForSameOrdinal() { + let lhs = RowID.existing(5) + let rhs = RowID.existing(5) + let other = RowID.existing(6) + #expect(lhs == rhs) + #expect(lhs != other) + } + + @Test("isInserted is true for inserted, false for existing") + func isInsertedReportsCase() { + #expect(RowID.inserted(UUID()).isInserted == true) + #expect(RowID.existing(0).isInserted == false) + } +} + +@Suite("Row") +struct RowTests { + @Test("Subscript returns the cell at a valid column") + func subscriptReadsValidColumn() { + let row = Row(id: .existing(0), values: ["a", "b", nil]) + #expect(row[0] == "a") + #expect(row[1] == "b") + #expect(row[2] == nil) + } + + @Test("Subscript get returns nil for out-of-bounds column") + func subscriptOutOfBoundsReturnsNil() { + let row = Row(id: .existing(0), values: ["a"]) + #expect(row[5] == nil) + #expect(row[-1] == nil) + } + + @Test("Subscript set on a valid column updates the value") + func subscriptWriteValidColumn() { + var row = Row(id: .existing(0), values: ["a", "b"]) + row[1] = "z" + #expect(row.values == ["a", "z"]) + } + + @Test("Subscript set on an out-of-bounds column is a no-op") + func subscriptWriteOutOfBoundsIsNoOp() { + var row = Row(id: .existing(0), values: ["a"]) + row[5] = "x" + row[-1] = "y" + #expect(row.values == ["a"]) + } + + @Test("Equality requires both id and values to match") + func equalityRequiresIDAndValues() { + let lhs = Row(id: .existing(1), values: ["a", "b"]) + let rhs = Row(id: .existing(1), values: ["a", "b"]) + let differentValues = Row(id: .existing(1), values: ["a", "c"]) + let differentID = Row(id: .existing(2), values: ["a", "b"]) + #expect(lhs == rhs) + #expect(lhs != differentValues) + #expect(lhs != differentID) + } + + @Test("Rows with same values but different inserted UUIDs are not equal") + func equalitySensitiveToInsertedUUID() { + let lhs = Row(id: .inserted(UUID()), values: ["a"]) + let rhs = Row(id: .inserted(UUID()), values: ["a"]) + #expect(lhs != rhs) + } +} From 7d0d0dc889cde7a00bcaa92b39e3b51f4894bcb3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:10:17 +0700 Subject: [PATCH 2/4] refactor(datagrid): introduce TableRows value type alongside RowBuffer --- TablePro/Models/Query/TableRows.swift | 186 ++++++++ .../Models/Query/TableRowsTests.swift | 404 ++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 TablePro/Models/Query/TableRows.swift create mode 100644 TableProTests/Models/Query/TableRowsTests.swift diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift new file mode 100644 index 000000000..e16647f02 --- /dev/null +++ b/TablePro/Models/Query/TableRows.swift @@ -0,0 +1,186 @@ +// +// TableRows.swift +// TablePro +// + +import Foundation + +struct TableRows: Sendable { + var rows: ContiguousArray + 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 = [], + 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 = [] + 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) -> 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() + 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 { + 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() + 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) + } +} diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift new file mode 100644 index 000000000..a4864b28b --- /dev/null +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -0,0 +1,404 @@ +// +// TableRowsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("TableRows - construction") +struct TableRowsConstructionTests { + @Test("Default initializer produces an empty table") + func emptyByDefault() { + let table = TableRows() + #expect(table.rows.isEmpty) + #expect(table.columns.isEmpty) + #expect(table.columnTypes.isEmpty) + } + + @Test("Factory with empty rows preserves columns and metadata") + func factoryWithEmptyRows() { + let table = TableRows.from( + queryRows: [], + columns: ["a"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.rows.isEmpty) + #expect(table.columns == ["a"]) + #expect(table.columnTypes.count == 1) + } + + @Test("Factory assigns ascending existing IDs to fresh rows") + func factoryAssignsExistingIDs() { + let table = TableRows.from( + queryRows: [["1"], ["2"]], + columns: ["id"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.count == 2) + #expect(table.rows[0].id == .existing(0)) + #expect(table.rows[1].id == .existing(1)) + #expect(table.rows[0].values == ["1"]) + #expect(table.rows[1].values == ["2"]) + } +} + +@Suite("TableRows - reads") +struct TableRowsReadTests { + @Test("value(at:column:) returns the cell at a valid coordinate") + func valueAtValidCoordinate() { + let table = TableRows.from( + queryRows: [["a", "b"]], + columns: ["c1", "c2"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + #expect(table.value(at: 0, column: 0) == "a") + #expect(table.value(at: 0, column: 1) == "b") + } + + @Test("value(at:column:) returns nil for out-of-bounds row") + func valueAtOutOfBoundsRow() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.value(at: 99, column: 0) == nil) + } +} + +@Suite("TableRows - edit") +struct TableRowsEditTests { + private static func makeTable() -> TableRows { + TableRows.from( + queryRows: [["a", "b"], ["c", "d"]], + columns: ["c1", "c2"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + } + + @Test("edit returns cellChanged with the position and updates the cell") + func editReturnsCellChanged() { + var table = Self.makeTable() + let delta = table.edit(row: 0, column: 0, value: "x") + #expect(delta == .cellChanged(row: 0, column: 0)) + #expect(table.value(at: 0, column: 0) == "x") + } + + @Test("edit with the same value returns Delta.none") + func editSameValueIsNoOp() { + var table = Self.makeTable() + let delta = table.edit(row: 0, column: 0, value: "a") + #expect(delta == .none) + #expect(table.value(at: 0, column: 0) == "a") + } + + @Test("edit with out-of-bounds row returns Delta.none and leaves rows untouched") + func editOutOfBoundsRow() { + var table = Self.makeTable() + let delta = table.edit(row: 99, column: 0, value: "x") + #expect(delta == .none) + #expect(table.value(at: 0, column: 0) == "a") + } + + @Test("editMany returns cellsChanged with one position per actual change") + func editManyMultipleChanges() { + var table = Self.makeTable() + let delta = table.editMany([ + (row: 0, column: 0, value: "x"), + (row: 0, column: 1, value: "y"), + (row: 1, column: 0, value: "z") + ]) + let expected: Set = [ + CellPosition(row: 0, column: 0), + CellPosition(row: 0, column: 1), + CellPosition(row: 1, column: 0) + ] + #expect(delta == .cellsChanged(expected)) + #expect(table.value(at: 0, column: 0) == "x") + #expect(table.value(at: 0, column: 1) == "y") + #expect(table.value(at: 1, column: 0) == "z") + } + + @Test("editMany returns Delta.none when all edits are no-ops") + func editManyNoOpReturnsNone() { + var table = Self.makeTable() + let delta = table.editMany([ + (row: 0, column: 0, value: "a"), + (row: 1, column: 1, value: "d") + ]) + #expect(delta == .none) + } + + @Test("editMany skips no-op edits and reports only actual changes") + func editManyMixesValidAndNoOp() { + var table = Self.makeTable() + let delta = table.editMany([ + (row: 0, column: 0, value: "a"), + (row: 0, column: 1, value: "y"), + (row: 99, column: 0, value: "ignored") + ]) + let expected: Set = [CellPosition(row: 0, column: 1)] + #expect(delta == .cellsChanged(expected)) + #expect(table.value(at: 0, column: 1) == "y") + } +} + +@Suite("TableRows - insert") +struct TableRowsInsertTests { + @Test("appendInsertedRow on an empty table returns rowsInserted at index 0") + func appendInsertedRowOnEmpty() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.appendInsertedRow(values: ["x"]) + #expect(delta == .rowsInserted(IndexSet(integer: 0))) + #expect(table.count == 1) + } + + @Test("appendInsertedRow assigns RowID.inserted to the new row") + func appendInsertedRowGetsInsertedID() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["x"]) + #expect(table.rows[0].id.isInserted) + } + + @Test("Two appendInsertedRow calls produce different RowID UUIDs") + func appendInsertedRowProducesDistinctUUIDs() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["x"]) + _ = table.appendInsertedRow(values: ["y"]) + #expect(table.rows[0].id != table.rows[1].id) + } + + @Test("appendInsertedRow pads short values and truncates long values to columns.count") + func appendInsertedRowPadsAndTruncates() { + var table = TableRows.from( + queryRows: [], + columns: ["c1", "c2", "c3"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["only-one"]) + _ = table.appendInsertedRow(values: ["a", "b", "c", "d"]) + #expect(table.rows[0].values == ["only-one", nil, nil]) + #expect(table.rows[1].values == ["a", "b", "c"]) + } +} + +@Suite("TableRows - appendPage") +struct TableRowsAppendPageTests { + @Test("appendPage on empty table returns rowsInserted with the appended range") + func appendPageOnEmpty() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.appendPage([["a"], ["b"]], startingAt: 0) + #expect(delta == .rowsInserted(IndexSet(integersIn: 0...1))) + #expect(table.rows[0].id == .existing(0)) + #expect(table.rows[1].id == .existing(1)) + } + + @Test("appendPage on a populated table appends at the end with the supplied offset") + func appendPageOntoExisting() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.appendPage([["c"]], startingAt: 5) + #expect(delta == .rowsInserted(IndexSet(integer: 2))) + #expect(table.rows[2].id == .existing(5)) + } + + @Test("appendPage with empty input returns Delta.none and does not mutate") + func appendPageEmptyInputIsNoOp() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.appendPage([], startingAt: 1) + #expect(delta == .none) + #expect(table.count == 1) + } +} + +@Suite("TableRows - remove") +struct TableRowsRemoveTests { + private static func makeTable() -> TableRows { + TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + } + + @Test("remove(rowIDs:) removes matching rows and returns rowsRemoved IndexSet") + func removeByIDs() { + var table = Self.makeTable() + let delta = table.remove(rowIDs: [.existing(0), .existing(2)]) + #expect(delta == .rowsRemoved(IndexSet([0, 2]))) + #expect(table.count == 1) + #expect(table.rows[0].values == ["b"]) + } + + @Test("remove(at:) removes in descending order without index drift") + func removeAtIndicesNoDrift() { + var table = Self.makeTable() + let delta = table.remove(at: IndexSet([0, 2])) + #expect(delta == .rowsRemoved(IndexSet([0, 2]))) + #expect(table.count == 1) + #expect(table.rows[0].values == ["b"]) + } + + @Test("remove(rowIDs:) can target inserted rows by their UUID") + func removeInsertedRowByID() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["new"]) + let insertedID = table.rows[1].id + let delta = table.remove(rowIDs: [insertedID]) + #expect(delta == .rowsRemoved(IndexSet(integer: 1))) + #expect(table.count == 1) + #expect(table.rows[0].values == ["a"]) + } + + @Test("remove(at:) silently drops out-of-bounds indices") + func removeAtSilentlyDropsOutOfBounds() { + var table = Self.makeTable() + let delta = table.remove(at: IndexSet([1, 99])) + #expect(delta == .rowsRemoved(IndexSet(integer: 1))) + #expect(table.count == 2) + } + + @Test("remove with no matching IDs returns Delta.none") + func removeWithNoMatchesIsNoOp() { + var table = Self.makeTable() + let delta = table.remove(rowIDs: [.existing(99)]) + #expect(delta == .none) + #expect(table.count == 3) + } +} + +@Suite("TableRows - replace") +struct TableRowsReplaceTests { + @Test("replace returns fullReplace and rebuilds rows with existing IDs") + func replaceReturnsFullReplace() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.replace(rows: [["x"]], offset: 0) + #expect(delta == .fullReplace) + #expect(table.count == 1) + #expect(table.rows[0].id == .existing(0)) + #expect(table.rows[0].values == ["x"]) + } +} + +@Suite("TableRows - metadata") +struct TableRowsMetadataTests { + private static func makeTable() -> TableRows { + TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)], + columnDefaults: ["c1": "d"], + columnNullable: ["c1": true] + ) + } + + @Test("updateDisplayMetadata reports columnsReplaced when a field changes") + func updateDisplayMetadataDetectsChange() { + var table = Self.makeTable() + let delta = table.updateDisplayMetadata(columnTypes: [.integer(rawType: "INT")]) + #expect(delta == .columnsReplaced) + #expect(table.columnTypes == [.integer(rawType: "INT")]) + } + + @Test("updateDisplayMetadata returns Delta.none when all arguments are nil") + func updateDisplayMetadataAllNilIsNoOp() { + var table = Self.makeTable() + let delta = table.updateDisplayMetadata() + #expect(delta == .none) + } + + @Test("updateDisplayMetadata returns Delta.none when supplied values match current state") + func updateDisplayMetadataEqualValuesIsNoOp() { + var table = Self.makeTable() + let delta = table.updateDisplayMetadata( + columnTypes: [.text(rawType: nil)], + columnDefaults: ["c1": "d"], + columnNullable: ["c1": true] + ) + #expect(delta == .none) + } +} + +@Suite("TableRows - metadata preservation regression") +struct TableRowsMetadataPreservationTests { + private static func makeTable() -> TableRows { + TableRows.from( + queryRows: [["a", "b"], ["c", "d"]], + columns: ["c1", "c2"], + columnTypes: [.text(rawType: "TEXT"), .integer(rawType: "INT")], + columnDefaults: ["c1": "default-1"], + columnForeignKeys: ["c2": ForeignKeyInfo(name: "fk", column: "c2", referencedTable: "t", referencedColumn: "id")], + columnEnumValues: ["c1": ["a", "b"]], + columnNullable: ["c2": false] + ) + } + + private static func assertMetadataPreserved(_ table: TableRows) { + #expect(table.columnTypes == [.text(rawType: "TEXT"), .integer(rawType: "INT")]) + #expect(table.columnDefaults["c1"] == "default-1") + #expect(table.columnForeignKeys["c2"]?.referencedTable == "t") + #expect(table.columnEnumValues["c1"] == ["a", "b"]) + #expect(table.columnNullable["c2"] == false) + } + + @Test("edit preserves all column metadata") + func editPreservesMetadata() { + var table = Self.makeTable() + _ = table.edit(row: 0, column: 0, value: "x") + Self.assertMetadataPreserved(table) + } + + @Test("appendInsertedRow preserves all column metadata") + func appendInsertedRowPreservesMetadata() { + var table = Self.makeTable() + _ = table.appendInsertedRow(values: ["x", "y"]) + Self.assertMetadataPreserved(table) + } + + @Test("remove preserves all column metadata") + func removePreservesMetadata() { + var table = Self.makeTable() + _ = table.remove(at: IndexSet(integer: 0)) + Self.assertMetadataPreserved(table) + } + + @Test("replace preserves all column metadata") + func replacePreservesMetadata() { + var table = Self.makeTable() + _ = table.replace(rows: [["x", "y"]], offset: 0) + Self.assertMetadataPreserved(table) + } +} From 8788bc5b8ec2e13cd268ee34604d427ca817bcdf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:10:28 +0700 Subject: [PATCH 3/4] docs: note DataGrid Phase C.1 in CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb834865c..0d519cfac 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 +- 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 From d2cf3e68bd614a6dac6e20f3344a35722aec3716 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:31:44 +0700 Subject: [PATCH 4/4] test(datagrid): cover replace offset, editMany OOB column, factory pad/truncate --- .../Models/Query/TableRowsTests.swift | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index a4864b28b..453752c85 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -42,6 +42,18 @@ struct TableRowsConstructionTests { #expect(table.rows[0].values == ["1"]) #expect(table.rows[1].values == ["2"]) } + + @Test("Factory pads short rows and truncates long rows to columns.count") + func factoryNormalizesRowWidth() { + let table = TableRows.from( + queryRows: [["a"], ["b", "c", "extra"]], + columns: ["c1", "c2"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + #expect(table.count == 2) + #expect(table.rows[0].values == ["a", nil]) + #expect(table.rows[1].values == ["b", "c"]) + } } @Suite("TableRows - reads") @@ -137,7 +149,8 @@ struct TableRowsEditTests { let delta = table.editMany([ (row: 0, column: 0, value: "a"), (row: 0, column: 1, value: "y"), - (row: 99, column: 0, value: "ignored") + (row: 99, column: 0, value: "ignored"), + (row: 0, column: 99, value: "ignored") ]) let expected: Set = [CellPosition(row: 0, column: 1)] #expect(delta == .cellsChanged(expected)) @@ -311,6 +324,22 @@ struct TableRowsReplaceTests { #expect(table.rows[0].id == .existing(0)) #expect(table.rows[0].values == ["x"]) } + + @Test("replace with non-zero offset assigns existing IDs starting from offset") + func replaceWithNonZeroOffsetAssignsExistingIDs() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.replace(rows: [["x"], ["y"]], offset: 5) + #expect(delta == .fullReplace) + #expect(table.count == 2) + #expect(table.rows[0].id == .existing(5)) + #expect(table.rows[1].id == .existing(6)) + #expect(table.rows[0].values == ["x"]) + #expect(table.rows[1].values == ["y"]) + } } @Suite("TableRows - metadata")