From 243003610bb00b345806f80f32f5d8216de73221 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 09:59:23 +0700 Subject: [PATCH 1/3] feat(results): compare two query results row by row --- .../Core/Compare/ResultSetComparison.swift | 333 ++++++++++++++++++ .../TablePro/Core/Menu/ViewMenuBuilder.swift | 4 + ...inSplitViewController+MenuValidation.swift | 4 + ...nSplitViewController+ViewMenuActions.swift | 4 + .../Models/Query/ResultSetMenuModel.swift | 21 +- .../TablePro/Resources/Localizable.xcstrings | 105 ++++++ .../ViewModels/ResultComparisonModel.swift | 219 ++++++++++++ .../Views/Compare/CompareRowGrid.swift | 50 ++- .../Main/Child/MainEditorContentView.swift | 4 +- ...nContentCoordinator+ResultComparison.swift | 121 +++++++ .../Main/MainContentCommandActions.swift | 8 + .../Results/Cells/DataGridCellTextMark.swift | 10 +- .../Comparison/ResultComparisonGrid.swift | 203 +++++++++++ .../Comparison/ResultComparisonView.swift | 304 ++++++++++++++++ .../ResultComparisonWindowController.swift | 27 ++ .../TablePro/Views/Results/DataGridView.swift | 1 + .../Views/Results/ResultSetMenu.swift | 4 + .../Views/Results/ResultStatusBar.swift | 3 +- .../Results/ValueViewerWindowController.swift | 13 +- .../Compare/ResultSetComparisonTests.swift | 327 +++++++++++++++++ .../Query/ResultSetMenuModelTests.swift | 15 +- .../Main/ResultStatusBarLayoutTests.swift | 2 + .../Results/ComparisonGridCopyTests.swift | 64 ++++ .../ResultComparisonGridModelTests.swift | 97 +++++ .../ResultComparisonUITests.swift | 59 ++++ 25 files changed, 1964 insertions(+), 38 deletions(-) create mode 100644 apple/TablePro/Core/Compare/ResultSetComparison.swift create mode 100644 apple/TablePro/ViewModels/ResultComparisonModel.swift create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift create mode 100644 apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift create mode 100644 apple/TablePro/Views/Results/Comparison/ResultComparisonView.swift create mode 100644 apple/TablePro/Views/Results/Comparison/ResultComparisonWindowController.swift create mode 100644 apple/TableProTests/Core/Compare/ResultSetComparisonTests.swift create mode 100644 apple/TableProTests/Views/Results/ComparisonGridCopyTests.swift create mode 100644 apple/TableProTests/Views/Results/ResultComparisonGridModelTests.swift create mode 100644 apple/TableProUITests/ResultComparisonUITests.swift diff --git a/apple/TablePro/Core/Compare/ResultSetComparison.swift b/apple/TablePro/Core/Compare/ResultSetComparison.swift new file mode 100644 index 0000000000..3ff95d75eb --- /dev/null +++ b/apple/TablePro/Core/Compare/ResultSetComparison.swift @@ -0,0 +1,333 @@ +// +// ResultSetComparison.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct ResultComparisonSide: Sendable { + internal let title: String + internal let detail: String + internal let columns: [String] + internal let columnTypes: [ColumnType] + internal let rows: ContiguousArray + internal let isTruncated: Bool + internal let primaryKeyColumns: [String] + + internal init( + title: String, + detail: String, + columns: [String], + columnTypes: [ColumnType] = [], + rows: ContiguousArray, + isTruncated: Bool = false, + primaryKeyColumns: [String] = [] + ) { + self.title = title + self.detail = detail + self.columns = columns + self.columnTypes = columnTypes + self.rows = rows + self.isTruncated = isTruncated + self.primaryKeyColumns = primaryKeyColumns + } + + internal func columnType(at index: Int) -> ColumnType? { + columnTypes.indices.contains(index) ? columnTypes[index] : nil + } +} + +internal struct ResultColumnPairing: Equatable, Sendable { + internal struct Pair: Equatable, Sendable { + internal let name: String + internal let left: Int + internal let right: Int + } + + internal let pairs: [Pair] + internal let leftOnly: [String] + internal let rightOnly: [String] + + // Exact names pair first, so `ID` only falls back to `id` when nothing spelled the same is left. + // A repeated name pairs by occurrence: the second `id` on the left with the second on the right. + internal init(left: [String], right: [String]) { + var rightTaken = Array(repeating: false, count: right.count) + var partner = [Int?](repeating: nil, count: left.count) + + for (leftIndex, name) in left.enumerated() { + guard let rightIndex = right.indices.first(where: { !rightTaken[$0] && right[$0] == name }) else { continue } + rightTaken[rightIndex] = true + partner[leftIndex] = rightIndex + } + for (leftIndex, name) in left.enumerated() where partner[leftIndex] == nil { + guard let rightIndex = right.indices.first(where: { + !rightTaken[$0] && right[$0].caseInsensitiveCompare(name) == .orderedSame + }) else { continue } + rightTaken[rightIndex] = true + partner[leftIndex] = rightIndex + } + + pairs = left.indices.compactMap { index in + partner[index].map { Pair(name: left[index], left: index, right: $0) } + } + leftOnly = left.indices.filter { partner[$0] == nil }.map { left[$0] } + rightOnly = right.indices.filter { !rightTaken[$0] }.map { right[$0] } + } +} + +internal enum ResultRowDifference: CaseIterable, Hashable, Sendable { + case onlyLeft + case onlyRight + case changed + case same + + internal var isDifference: Bool { + self != .same + } +} + +internal struct ResultComparisonEntry: Hashable, Sendable { + internal let kind: ResultRowDifference + internal let leftRow: Int? + internal let rightRow: Int? + internal let changedPairs: [Int] + + internal init(kind: ResultRowDifference, leftRow: Int?, rightRow: Int?, changedPairs: [Int] = []) { + self.kind = kind + self.leftRow = leftRow + self.rightRow = rightRow + self.changedPairs = changedPairs + } +} + +internal struct ResultComparison: Sendable { + internal let id = UUID() + internal let entries: [ResultComparisonEntry] + internal let keyPairs: [Int] + + private let counts: [ResultRowDifference: Int] + + internal init(entries: [ResultComparisonEntry], keyPairs: [Int]) { + self.entries = entries + self.keyPairs = keyPairs + var counts: [ResultRowDifference: Int] = [:] + for entry in entries { + counts[entry.kind, default: 0] += 1 + } + self.counts = counts + } + + internal func count(of kind: ResultRowDifference) -> Int { + counts[kind] ?? 0 + } + + internal var differenceCount: Int { + count(of: .onlyLeft) + count(of: .onlyRight) + count(of: .changed) + } +} + +internal enum ResultComparisonError: Error, Equatable, LocalizedError { + case duplicateKey(String) + + internal var errorDescription: String? { + switch self { + case .duplicateKey(let message): + return message + } + } +} + +internal enum ResultComparisonEngine { + private static let cancellationStride = 4_096 + + // Empty `keyPairs` matches whole rows: a row that differs in one value reads as one row only on each side. + internal static func compare( + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing, + keyPairs: [Int] + ) throws -> ResultComparison { + let keyPairs = keyPairs.filter { pairing.pairs.indices.contains($0) } + guard !keyPairs.isEmpty else { + return try compareWholeRows(left: left, right: right, pairing: pairing) + } + return try compareByKey(left: left, right: right, pairing: pairing, keyPairs: keyPairs) + } + + // The left result's table key first; a key with a column missing from either result is skipped. + internal static func defaultKeyPairs( + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing + ) -> [Int] { + let candidates: [(key: [String], names: [String])] = [ + (left.primaryKeyColumns, pairing.pairs.map(\.name)), + (right.primaryKeyColumns, pairing.pairs.map { right.columns[$0.right] }) + ] + for candidate in candidates where !candidate.key.isEmpty { + let indices = candidate.key.compactMap { column in + candidate.names.firstIndex(of: column) + ?? candidate.names.firstIndex { $0.caseInsensitiveCompare(column) == .orderedSame } + } + guard indices.count == candidate.key.count, Set(indices).count == indices.count else { continue } + return indices.sorted() + } + return [] + } + + private static func compareByKey( + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing, + keyPairs: [Int] + ) throws -> ResultComparison { + let kinds = valueKinds(pairing: pairing, left: left, right: right) + let keySet = Set(keyPairs) + let compared = pairing.pairs.indices.filter { !keySet.contains($0) } + + var rightByKey: [[CellToken]: Int] = [:] + rightByKey.reserveCapacity(right.rows.count) + for index in right.rows.indices { + try checkCancellation(at: index) + let key = keyPairs.map { token(right.rows[index], pairing.pairs[$0].right, kinds[$0]) } + guard rightByKey.updateValue(index, forKey: key) == nil else { + throw duplicateKeyError(right.rows[index], keyPairs.map { pairing.pairs[$0].right }, onLeft: false) + } + } + + var entries: [ResultComparisonEntry] = [] + entries.reserveCapacity(max(left.rows.count, right.rows.count)) + var seenOnLeft = Set<[CellToken]>() + var rightMatched = [Bool](repeating: false, count: right.rows.count) + + for index in left.rows.indices { + try checkCancellation(at: index) + let leftRow = left.rows[index] + let key = keyPairs.map { token(leftRow, pairing.pairs[$0].left, kinds[$0]) } + guard seenOnLeft.insert(key).inserted else { + throw duplicateKeyError(leftRow, keyPairs.map { pairing.pairs[$0].left }, onLeft: true) + } + guard let partner = rightByKey[key] else { + entries.append(ResultComparisonEntry(kind: .onlyLeft, leftRow: index, rightRow: nil)) + continue + } + rightMatched[partner] = true + let rightRow = right.rows[partner] + let changed = compared.filter { pairIndex in + let pair = pairing.pairs[pairIndex] + return token(leftRow, pair.left, kinds[pairIndex]) != token(rightRow, pair.right, kinds[pairIndex]) + } + entries.append(ResultComparisonEntry( + kind: changed.isEmpty ? .same : .changed, + leftRow: index, + rightRow: partner, + changedPairs: changed + )) + } + + for index in right.rows.indices where !rightMatched[index] { + entries.append(ResultComparisonEntry(kind: .onlyRight, leftRow: nil, rightRow: index)) + } + return ResultComparison(entries: entries, keyPairs: keyPairs) + } + + // A multiset: two equal rows on the left and one on the right leave one of the two only on the left. + private static func compareWholeRows( + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing + ) throws -> ResultComparison { + let kinds = valueKinds(pairing: pairing, left: left, right: right) + + var rightByValues: [[CellToken]: [Int]] = [:] + for index in right.rows.indices { + try checkCancellation(at: index) + let values = pairing.pairs.indices.map { token(right.rows[index], pairing.pairs[$0].right, kinds[$0]) } + rightByValues[values, default: []].append(index) + } + + var entries: [ResultComparisonEntry] = [] + entries.reserveCapacity(max(left.rows.count, right.rows.count)) + var taken: [[CellToken]: Int] = [:] + var rightMatched = [Bool](repeating: false, count: right.rows.count) + + for index in left.rows.indices { + try checkCancellation(at: index) + let values = pairing.pairs.indices.map { token(left.rows[index], pairing.pairs[$0].left, kinds[$0]) } + let next = taken[values, default: 0] + guard let bucket = rightByValues[values], next < bucket.count else { + entries.append(ResultComparisonEntry(kind: .onlyLeft, leftRow: index, rightRow: nil)) + continue + } + taken[values] = next + 1 + rightMatched[bucket[next]] = true + entries.append(ResultComparisonEntry(kind: .same, leftRow: index, rightRow: bucket[next])) + } + + for index in right.rows.indices where !rightMatched[index] { + entries.append(ResultComparisonEntry(kind: .onlyRight, leftRow: nil, rightRow: index)) + } + return ResultComparison(entries: entries, keyPairs: []) + } + + // One equality for keys, whole rows and values, matching `CellValueComparator` at its default + // options: an absent field is not NULL, and two spellings of one instant agree to the microsecond. + private enum CellToken: Hashable { + case absent + case null + case text(String) + case bytes(Data) + case instant(Int64) + } + + private static func token(_ row: Row, _ column: Int, _ kind: ValueComparisonKind) -> CellToken { + guard !row.isAbsent(column) else { return .absent } + switch row[column] { + case .null: + return .null + case .bytes(let data): + return .bytes(data) + case .text(let text): + guard kind == .temporal || kind == .unknown, let microseconds = instantMicroseconds(text) else { + return .text(text) + } + return .instant(microseconds) + } + } + + // Every pattern `TimestampValue` reads starts with `yyyy-`, so anything else skips the formatters. + private static func instantMicroseconds(_ text: String) -> Int64? { + let head = Array(text.utf8.drop { $0 == UInt8(ascii: " ") }.prefix(5)) + guard head.count == 5, head[4] == UInt8(ascii: "-"), + head[0 ..< 4].allSatisfy({ $0 >= UInt8(ascii: "0") && $0 <= UInt8(ascii: "9") }), + let instant = TimestampValue.parse(text) else { return nil } + let nanoseconds = instant.nanosecondsSinceEpoch + let quotient = nanoseconds / 1_000 + return nanoseconds % 1_000 < 0 ? quotient - 1 : quotient + } + + private static func valueKinds( + pairing: ResultColumnPairing, + left: ResultComparisonSide, + right: ResultComparisonSide + ) -> [ValueComparisonKind] { + pairing.pairs.map { pair in + let leftKind = ValueComparisonKind(columnType: left.columnType(at: pair.left)) + let rightKind = ValueComparisonKind(columnType: right.columnType(at: pair.right)) + return leftKind == rightKind ? leftKind : .unknown + } + } + + private static func checkCancellation(at index: Int) throws { + guard index % cancellationStride == 0 else { return } + try Task.checkCancellation() + } + + private static func duplicateKeyError(_ row: Row, _ columns: [Int], onLeft: Bool) -> ResultComparisonError { + let format = onLeft + ? String(localized: "Key %@ matches more than one row in the left result. Choose key columns that identify a single row, or match whole rows.") + : String(localized: "Key %@ matches more than one row in the right result. Choose key columns that identify a single row, or match whole rows.") + return .duplicateKey(String(format: format, KeyOrdering.description(of: columns.map { row[$0] }))) + } +} diff --git a/apple/TablePro/Core/Menu/ViewMenuBuilder.swift b/apple/TablePro/Core/Menu/ViewMenuBuilder.swift index ce95f872f9..4c44aaee83 100644 --- a/apple/TablePro/Core/Menu/ViewMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/ViewMenuBuilder.swift @@ -130,6 +130,10 @@ enum ViewMenuBuilder { shortcut: .pinResultTab, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Compare Result With…"), + action: #selector(MainSplitViewController.compareResult(_:)) + ), MenuItemFactory.item( String(localized: "Close Result Tab"), action: #selector(MainSplitViewController.closeResultTab(_:)), diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 57e9f6d788..0d107036af 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -103,6 +103,7 @@ struct MenuValidationContext: Equatable { /// window's tabs when there is no editor tab to switch to. var hasOtherWindowTabs = false var canPinResultTab = false + var canCompareResult = false /// The selected tab's browse history. Separate flags rather than one, because Back and Forward /// run out independently and an item that is disabled has to say which one it is. var canNavigateBack = false @@ -322,6 +323,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && context.canPresentHighlightRules case #selector(pinResult(_:)): return context.canPinResultTab + case #selector(compareResult(_:)): + return context.canCompareResult case #selector(navigateBack(_:)): return context.isConnected && context.canNavigateBack case #selector(navigateForward(_:)): @@ -656,6 +659,7 @@ extension MainSplitViewController: NSMenuItemValidation { hasRecentTabToSwitchTo: hasRecentTabToSwitchTo, hasOtherWindowTabs: hasOtherWindowTabs, canPinResultTab: actions.canPinResultTab, + canCompareResult: actions.canCompareResult, canNavigateBack: actions.canNavigateBack, canNavigateForward: actions.canNavigateForward, canNavigatePages: actions.canNavigatePages, diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index 55bcfba40f..b42bee1920 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -135,6 +135,10 @@ extension MainSplitViewController { commandActions?.pinResultTab() } + @objc func compareResult(_ sender: Any?) { + commandActions?.compareResult() + } + @objc func closeResultTab(_ sender: Any?) { commandActions?.closeResultTab() } diff --git a/apple/TablePro/Models/Query/ResultSetMenuModel.swift b/apple/TablePro/Models/Query/ResultSetMenuModel.swift index 9dba28c431..a8d74cc890 100644 --- a/apple/TablePro/Models/Query/ResultSetMenuModel.swift +++ b/apple/TablePro/Models/Query/ResultSetMenuModel.swift @@ -5,27 +5,18 @@ import Foundation -/// One entry in the result-set chooser. struct ResultSetMenuEntry: Equatable, Identifiable { let id: UUID let label: String let isPinned: Bool let isActive: Bool - /// What the entry is called when the reader is counting rather than reading: "Result 2 of 4". + // What the entry is called when the reader is counting rather than reading: "Result 2 of 4". let ordinal: Int + // Whether it holds columns to compare: a plan, an error or a statement that returned no rows does not. + let isComparable: Bool } -/// The result-set chooser, resolved before any view exists. -/// -/// Replaces the 32pt strip of hand-drawn tabs. macOS has no native tab control that closes, pins or -/// reorders anything but windows: `NSTabViewItem` carries ten properties and not one of them is a -/// close affordance, `NSWindowTabGroup` takes `NSWindow` only, and SwiftUI's `Tab` and `TabSection` -/// are macOS 15 with no close either. So the collection is re-expressed rather than redrawn, and -/// the HIG names the control to re-express it with: a pop-up button is the "reasonable alternative -/// in cases where there are too many panes" for a tab view, and result sets are unbounded. -/// -/// It is absent at one result, which is the common case and the strip's worst habit: a lone tab -/// spending a band of height to say "this is the result". +// A pull-down rather than tabs: macOS has no native tab control that closes or pins anything but windows. struct ResultSetMenuModel: Equatable { let entries: [ResultSetMenuEntry] let activeOrdinal: Int @@ -78,4 +69,8 @@ struct ResultSetMenuModel: Equatable { func canCloseOthers(_ entry: ResultSetMenuEntry) -> Bool { entries.contains { $0.id != entry.id && !$0.isPinned } } + + func canCompare(_ entry: ResultSetMenuEntry) -> Bool { + entry.isComparable + } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..5a755cdbfc 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -74,6 +74,99 @@ } } } + }, + "%1$d only left, %2$d only right, %3$d changed, %4$d same" : { + + }, + "Both results stopped at the row cap and are compared on the rows they hold. A row past the cap can show as only on one side." : { + + }, + "Cannot Compare" : { + + }, + "Choose a Result" : { + + }, + "Compare Result With…" : { + + }, + "Compare Results" : { + + }, + "Compare With" : { + + }, + "Comparing" : { + + }, + "Comparing…" : { + + }, + "Key %@ matches more than one row in the left result. Choose key columns that identify a single row, or match whole rows." : { + + }, + "Key %@ matches more than one row in the right result. Choose key columns that identify a single row, or match whole rows." : { + + }, + "Left" : { + + }, + "Match Whole Rows" : { + + }, + "No Columns in Common" : { + + }, + "No Result to Compare With" : { + + }, + "Only Left" : { + + }, + "Only Right" : { + + }, + "Only in the left result, not compared: %@" : { + + }, + "Only in the right result, not compared: %@" : { + + }, + "Pick the result to compare this one with." : { + + }, + "Result %1$d: %2$@" : { + + }, + "Result Closed" : { + + }, + "Right" : { + + }, + "Rows with the same values in these columns are compared. With none chosen, whole rows are matched." : { + + }, + "Run another query in this tab, or pin a result in another tab." : { + + }, + "That result is no longer open. Choose another one." : { + + }, + "The left result stopped at the row cap and is compared on the %@ rows it holds. A row past the cap can show as only on the right." : { + + }, + "The right result stopped at the row cap and is compared on the %@ rows it holds. A row past the cap can show as only on the left." : { + + }, + "The two results share no column name, so there is nothing to compare." : { + + }, + "This Tab" : { + + }, + "Whole row" : { + }, "__name__ is the document's ID in this collection, such as alice." : { "extractionState" : "manual", @@ -227,6 +320,18 @@ } } } + }, + "left value, differs" : { + + }, + "only in the left result" : { + + }, + "only in the right result" : { + + }, + "right value, differs" : { + }, "~/.ssh/config sets IdentityAgent none for this host, so no SSH agent can sign in. Remove that line, or choose Private Key." : { diff --git a/apple/TablePro/ViewModels/ResultComparisonModel.swift b/apple/TablePro/ViewModels/ResultComparisonModel.swift new file mode 100644 index 0000000000..9ffb1492d8 --- /dev/null +++ b/apple/TablePro/ViewModels/ResultComparisonModel.swift @@ -0,0 +1,219 @@ +// +// ResultComparisonModel.swift +// TablePro +// + +import Combine +import Foundation + +internal struct ResultComparisonCandidate: Identifiable { + internal let id: UUID + internal let title: String + internal let group: String + internal let isPinned: Bool + internal let read: @MainActor () -> ResultComparisonSide? +} + +internal enum ResultComparisonFilter: CaseIterable, Hashable { + case all + case difference + case onlyLeft + case onlyRight + case changed + case same + + internal var title: String { + switch self { + case .all: return String(localized: "All Rows") + case .difference: return String(localized: "Difference") + case .onlyLeft: return String(localized: "Only Left") + case .onlyRight: return String(localized: "Only Right") + case .changed: return String(localized: "Changed") + case .same: return String(localized: "Same") + } + } + + internal func matches(_ kind: ResultRowDifference) -> Bool { + switch self { + case .all: return true + case .difference: return kind.isDifference + case .onlyLeft: return kind == .onlyLeft + case .onlyRight: return kind == .onlyRight + case .changed: return kind == .changed + case .same: return kind == .same + } + } +} + +internal enum ResultComparisonIdleReason: Equatable { + case noCandidates + case notChosen + case rightClosed + case noCommonColumns +} + +// The answer travels with the side and pairing it indexes into, so a grid never reads one answer through another pair. +internal struct ResultComparisonAnswer { + internal let right: ResultComparisonSide + internal let pairing: ResultColumnPairing + internal let comparison: ResultComparison +} + +@MainActor +internal final class ResultComparisonModel: ObservableObject { + internal enum State { + case idle(ResultComparisonIdleReason) + case comparing + case compared(ResultComparisonAnswer) + case failed(String) + } + + internal let left: ResultComparisonSide + + @Published private(set) var candidates: [ResultComparisonCandidate] = [] + @Published private(set) var rightId: UUID? + @Published private(set) var right: ResultComparisonSide? + @Published private(set) var pairing: ResultColumnPairing? + @Published private(set) var keyPairs: [Int] = [] + @Published private(set) var state: State = .idle(.notChosen) + @Published private(set) var isRefreshing = false + @Published var filter: ResultComparisonFilter = .difference + + private let candidateSource: @MainActor () -> [ResultComparisonCandidate] + private var comparisonTask: Task? + + internal init( + left: ResultComparisonSide, + preferredRightId: UUID?, + candidates: @escaping @MainActor () -> [ResultComparisonCandidate] + ) { + self.left = left + self.candidateSource = candidates + refreshCandidates() + chooseRight(preferredRightId ?? self.candidates.first?.id) + } + + internal var groupedCandidates: [(group: String, candidates: [ResultComparisonCandidate])] { + var groups: [(group: String, candidates: [ResultComparisonCandidate])] = [] + for candidate in candidates { + if let index = groups.firstIndex(where: { $0.group == candidate.group }) { + groups[index].candidates.append(candidate) + } else { + groups.append((candidate.group, [candidate])) + } + } + return groups + } + + internal var answer: ResultComparisonAnswer? { + guard case .compared(let answer) = state else { return nil } + return answer + } + + internal var comparison: ResultComparison? { + answer?.comparison + } + + // A closed result keeps its entry while it is the one on screen, so the picker never goes blank under it. + internal func refreshCandidates() { + var fresh = candidateSource() + if let rightId, !fresh.contains(where: { $0.id == rightId }), + let current = candidates.first(where: { $0.id == rightId }) { + fresh.append(current) + } + candidates = fresh + if rightId == nil { + state = .idle(candidates.isEmpty ? .noCandidates : .notChosen) + } + } + + internal func chooseRight(_ id: UUID?) { + guard let id else { + rightId = nil + right = nil + pairing = nil + state = .idle(candidates.isEmpty ? .noCandidates : .notChosen) + return + } + guard id != rightId || right == nil else { return } + rightId = id + guard let side = candidates.first(where: { $0.id == id })?.read() else { + cancel() + right = nil + pairing = nil + state = .idle(.rightClosed) + return + } + right = side + let pairing = ResultColumnPairing(left: left.columns, right: side.columns) + self.pairing = pairing + keyPairs = ResultComparisonEngine.defaultKeyPairs(left: left, right: side, pairing: pairing) + compare(keepingAnswer: false) + } + + internal func isKey(_ pairIndex: Int) -> Bool { + keyPairs.contains(pairIndex) + } + + internal func toggleKey(_ pairIndex: Int) { + guard let pairing, pairing.pairs.indices.contains(pairIndex) else { return } + if let position = keyPairs.firstIndex(of: pairIndex) { + keyPairs.remove(at: position) + } else { + keyPairs = (keyPairs + [pairIndex]).sorted() + } + compare(keepingAnswer: true) + } + + internal func matchWholeRows() { + guard !keyPairs.isEmpty else { return } + keyPairs = [] + compare(keepingAnswer: true) + } + + internal func cancel() { + comparisonTask?.cancel() + comparisonTask = nil + isRefreshing = false + } + + // A new key re-answers the same pair, so the grid keeps the last answer until the next one lands. + private func compare(keepingAnswer: Bool) { + cancel() + guard let right, let pairing else { return } + guard !pairing.pairs.isEmpty else { + state = .idle(.noCommonColumns) + return + } + if keepingAnswer, answer != nil { + isRefreshing = true + } else { + state = .comparing + } + let left = left + let keyPairs = keyPairs + comparisonTask = Task { [weak self] in + let outcome = await Self.run(left: left, right: right, pairing: pairing, keyPairs: keyPairs) + guard !Task.isCancelled, let self else { return } + isRefreshing = false + switch outcome { + case .success(let comparison): + state = .compared(ResultComparisonAnswer(right: right, pairing: pairing, comparison: comparison)) + case .failure(let error as ResultComparisonError): + state = .failed(error.localizedDescription) + case .failure: + return + } + } + } + + @concurrent + nonisolated private static func run( + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing, + keyPairs: [Int] + ) async -> Result { + Result { try ResultComparisonEngine.compare(left: left, right: right, pairing: pairing, keyPairs: keyPairs) } + } +} diff --git a/apple/TablePro/Views/Compare/CompareRowGrid.swift b/apple/TablePro/Views/Compare/CompareRowGrid.swift index 0e1cd886ed..83f68553a6 100644 --- a/apple/TablePro/Views/Compare/CompareRowGrid.swift +++ b/apple/TablePro/Views/Compare/CompareRowGrid.swift @@ -69,20 +69,20 @@ internal final class CompareRowGridModel: DataGridViewDelegate { String(localized: "Side") ] + columns, columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: Self.leadingColumnCount) - + plan.columns.map(Self.displayType) + + plan.columns.map { Self.displayType(for: $0) } ) return tableRows } - /// One column, two lines, and a type each side may spell differently. A column the two engines - /// type alike formats by that type; one they disagree on formats as text, so neither side's - /// values are drawn through the other's formatting. private static func displayType(for column: CompareColumn) -> ColumnType { - guard let source = column.sourceColumnType else { return column.targetColumnType ?? .text(rawType: nil) } - guard let target = column.targetColumnType else { return source } - return ValueComparisonKind(columnType: source) == ValueComparisonKind(columnType: target) - ? source - : .text(rawType: nil) + displayType(source: column.sourceColumnType, target: column.targetColumnType) + } + + // Both lines format through one type, so a DATE beside a TIMESTAMP falls back to text rather than hide the time. + internal static func displayType(source: ColumnType?, target: ColumnType?) -> ColumnType { + guard let source else { return target ?? .text(rawType: nil) } + guard let target else { return source } + return source.displayShape == target.displayShape ? source : .text(rawType: nil) } internal static func lines(for entry: RowDiffEntry) -> [CompareRowGridLine] { @@ -162,6 +162,11 @@ internal final class CompareRowGridModel: DataGridViewDelegate { return session.isRowIncluded(line.entry, in: plan) } + // The delegate's default copies nothing, which left Copy and Select All then Copy silent on this grid. + internal func dataGridCopyRows(_ indices: Set) { + coordinator?.copyRowsAsTSV(at: indices, includeHeaders: false) + } + internal func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) { guard column == Self.includeColumn, let session else { return } let entries = rows.compactMap { row -> RowDiffEntry? in @@ -204,7 +209,8 @@ internal struct CompareRowGrid: View { showRowNumbers: false, checkboxColumns: [CompareRowGridModel.includeColumn], supportsColumnCommands: false, - supportsValueFilter: false + supportsValueFilter: false, + supportsColumnSort: false ), delegate: model, selectedRowIndices: $selectedRows, @@ -219,3 +225,27 @@ internal struct CompareRowGrid: View { } } } + +private extension ColumnType { + enum DisplayShape { + case text, integer, decimal, date, timestamp, datetime, boolean, blob, json, enumType, set, spatial, array + } + + var displayShape: DisplayShape { + switch self { + case .text: return .text + case .integer: return .integer + case .decimal: return .decimal + case .date: return .date + case .timestamp: return .timestamp + case .datetime: return .datetime + case .boolean: return .boolean + case .blob: return .blob + case .json: return .json + case .enumType: return .enumType + case .set: return .set + case .spatial: return .spatial + case .array: return .array + } + } +} diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index ae3a7973b5..0e76e307f4 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -1178,6 +1178,7 @@ struct MainEditorContentView: View { resultSetMenu: resultSetMenuModel(for: tab), onActivateResultSet: { coordinator.switchActiveResultSet(to: $0, in: tab.id) }, onToggleResultSetPin: { coordinator.togglePinResultSet(id: $0) }, + onCompareResultSet: { coordinator.compareResultSet(id: $0) }, onCloseResultSet: { coordinator.closeResultSet(id: $0) }, onCloseOtherResultSets: { keptId in for other in tab.display.resultSets where other.id != keptId && !other.isPinned { @@ -1212,7 +1213,8 @@ struct MainEditorContentView: View { label: resultSet.label, isPinned: resultSet.isPinned, isActive: resultSet.id == activeId, - ordinal: index + 1 + ordinal: index + 1, + isComparable: coordinator.isComparable(resultSet, in: tab) ) } return ResultSetMenuModel( diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift new file mode 100644 index 0000000000..e147e7bbb7 --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift @@ -0,0 +1,121 @@ +// +// MainContentCoordinator+ResultComparison.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + var canCompareActiveResult: Bool { + guard let tab = tabManager.selectedTab, tab.tabType == .query, + tab.display.resultsViewMode != .structure, + let active = tab.display.activeResultSet else { return false } + return isComparable(active, in: tab) + } + + func compareActiveResult() { + guard canCompareActiveResult, let activeId = tabManager.selectedTab?.display.activeResultSet?.id else { return } + compareResultSet(id: activeId) + } + + func compareResultSet(id: UUID) { + guard let tab = tabManager.selectedTab, tab.tabType == .query, + let resultSet = tab.display.resultSets.first(where: { $0.id == id }), + let left = comparisonSide(of: resultSet, in: tab) else { return } + let leftId = resultSet.id + let tabId = tab.id + let partner = Self.defaultComparisonPartner( + of: leftId, + among: tab.display.resultSets.filter { isComparable($0, in: tab) }.map(\.id) + ) + let model = ResultComparisonModel(left: left, preferredRightId: partner) { + Self.comparisonCandidates(excluding: leftId, homeTabId: tabId) + } + ResultComparisonWindowController.open(model: model) + } + + // The newest result is compared with the one before it, so "run, pin, run again" lands on the pinned run. + static func defaultComparisonPartner(of activeId: UUID, among ids: [UUID]) -> UUID? { + guard let position = ids.firstIndex(of: activeId) else { return ids.last } + guard position == ids.count - 1 else { return ids.last } + return position > 0 ? ids[position - 1] : nil + } + + static func comparisonTitle(label: String, ordinal: Int) -> String { + String(format: String(localized: "Result %1$d: %2$@"), ordinal, label) + } + + // Every result of the tab the comparison starts from, and every pinned result of any other query tab. + static func comparisonCandidates(excluding leftId: UUID, homeTabId: UUID) -> [ResultComparisonCandidate] { + var home: [ResultComparisonCandidate] = [] + var others: [ResultComparisonCandidate] = [] + for coordinator in WindowManager.shared.hostedCoordinators() { + for tab in coordinator.tabManager.tabs where tab.tabType == .query { + let isHome = tab.id == homeTabId + let tabId = tab.id + for (index, resultSet) in tab.display.resultSets.enumerated() + where resultSet.id != leftId && (isHome || resultSet.isPinned) + && coordinator.isComparable(resultSet, in: tab) { + let resultId = resultSet.id + let candidate = ResultComparisonCandidate( + id: resultId, + title: comparisonTitle(label: resultSet.label, ordinal: index + 1), + group: isHome ? String(localized: "This Tab") : coordinator.comparisonDetail(for: tab), + isPinned: resultSet.isPinned, + read: { [weak coordinator] in + coordinator?.comparisonSide(ofResult: resultId, inTab: tabId) + } + ) + if isHome { + home.append(candidate) + } else { + others.append(candidate) + } + } + } + } + return home + others + } + + func comparisonSide(ofResult resultId: UUID, inTab tabId: UUID) -> ResultComparisonSide? { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), + let resultSet = tab.display.resultSets.first(where: { $0.id == resultId }) else { return nil } + return comparisonSide(of: resultSet, in: tab) + } + + func comparisonSide(of resultSet: ResultSet, in tab: QueryTab) -> ResultComparisonSide? { + guard !resultSet.isExplainResult else { return nil } + let rows = comparisonRows(of: resultSet, in: tab) + guard !rows.columns.isEmpty else { return nil } + let ordinal = (tab.display.resultSets.firstIndex { $0.id == resultSet.id } ?? 0) + 1 + let primaryKey: [String] + if let origin = resultSet.origin, origin.keysResolved, origin.tableName?.isEmpty == false { + primaryKey = origin.primaryKeyColumns + } else { + primaryKey = [] + } + return ResultComparisonSide( + title: Self.comparisonTitle(label: resultSet.label, ordinal: ordinal), + detail: comparisonDetail(for: tab), + columns: rows.columns, + columnTypes: rows.columnTypes, + rows: rows.rows, + isTruncated: resultSet.isTruncated, + primaryKeyColumns: primaryKey + ) + } + + func comparisonDetail(for tab: QueryTab) -> String { + String(format: String(localized: "%1$@ (%2$@)"), tab.title, connection.name) + } + + func isComparable(_ resultSet: ResultSet, in tab: QueryTab) -> Bool { + !resultSet.isExplainResult && !comparisonRows(of: resultSet, in: tab).columns.isEmpty + } + + // The active result's rows live in the tab's shared buffer, which page loads and Fetch All write to. + private func comparisonRows(of resultSet: ResultSet, in tab: QueryTab) -> TableRows { + guard tab.display.activeResultSet?.id == resultSet.id else { return resultSet.tableRows } + return tabSessionRegistry.tableRows(for: tab.id) + } +} diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index cb1904979a..702bb7e851 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -1422,6 +1422,14 @@ final class MainContentCommandActions: ObservableObject { coordinator.togglePinResultSet(id: activeId) } + var canCompareResult: Bool { + coordinator?.canCompareActiveResult ?? false + } + + func compareResult() { + coordinator?.compareActiveResult() + } + func closeResultTab() { guard let coordinator else { return } let tab = coordinator.tabManager.selectedTab diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift b/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift index f76c174221..09576271ee 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift +++ b/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift @@ -44,10 +44,7 @@ enum DataGridCellTextMark: Equatable { } } - /// What a client reading the grid is told, since a line it cannot see is no cue at all. - /// - /// Resolved from the state rather than from the mark, because one line covers two changes a - /// reader needs told apart: a whole row that is new, and one edited value in a row that is not. + // Resolved from the state, not the mark: one line covers a new row and an edited value, which a reader must tell apart. static func accessibilityDescription(state: RowVisualState, columnIndex: Int) -> String? { switch state.vocabulary { case .pendingEdit: @@ -59,6 +56,11 @@ enum DataGridCellTextMark: Equatable { if state.isInserted { return String(localized: "only in the source") } if state.isStruck(columnIndex: columnIndex) { return String(localized: "target value, differs") } return state.isModified(columnIndex: columnIndex) ? String(localized: "source value, differs") : nil + case .resultComparison: + if state.isDeleted { return String(localized: "only in the right result") } + if state.isInserted { return String(localized: "only in the left result") } + if state.isStruck(columnIndex: columnIndex) { return String(localized: "right value, differs") } + return state.isModified(columnIndex: columnIndex) ? String(localized: "left value, differs") : nil } } } diff --git a/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift b/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift new file mode 100644 index 0000000000..4ebfeee44d --- /dev/null +++ b/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift @@ -0,0 +1,203 @@ +// +// ResultComparisonGrid.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +internal struct ResultComparisonGridLine: Equatable { + internal let entry: ResultComparisonEntry + internal let isLeft: Bool +} + +@MainActor +internal final class ResultComparisonGridModel: DataGridViewDelegate { + internal static let leadingColumnCount = 2 + + internal struct LoadKey: Hashable { + let comparisonId: UUID + let filter: ResultComparisonFilter + } + + private(set) var lines: [ResultComparisonGridLine] = [] + private(set) var tableRows = TableRows() + private var states: [RowVisualState] = [] + private var loadedKey: LoadKey? + + // A grid row is a display position; the grid's own value filter can reorder it. + private weak var coordinator: TableViewCoordinator? + + internal func ensureLoaded( + key: LoadKey, + comparison: ResultComparison, + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing + ) -> TableRows { + guard loadedKey != key else { return tableRows } + loadedKey = key + lines = Self.lines(for: comparison.entries.filter { key.filter.matches($0.kind) }) + states = lines.map(Self.visualState(for:)) + tableRows = Self.tableRows(for: lines, left: left, right: right, pairing: pairing) + return tableRows + } + + internal static func lines(for entries: [ResultComparisonEntry]) -> [ResultComparisonGridLine] { + entries.flatMap { entry -> [ResultComparisonGridLine] in + switch entry.kind { + case .onlyLeft, .same: + return [ResultComparisonGridLine(entry: entry, isLeft: true)] + case .onlyRight: + return [ResultComparisonGridLine(entry: entry, isLeft: false)] + case .changed: + return [ + ResultComparisonGridLine(entry: entry, isLeft: true), + ResultComparisonGridLine(entry: entry, isLeft: false) + ] + } + } + } + + // The right result reads as the baseline: a row only on the left is marked new, one only on the right removed. + internal static func visualState(for line: ResultComparisonGridLine) -> RowVisualState { + let changed = Set(line.entry.changedPairs.map { $0 + leadingColumnCount }) + switch line.entry.kind { + case .onlyLeft: + return RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: [], vocabulary: .resultComparison) + case .onlyRight: + return RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: [], vocabulary: .resultComparison) + case .changed where line.isLeft: + return RowVisualState( + isDeleted: false, isInserted: false, modifiedColumns: changed, vocabulary: .resultComparison + ) + case .changed: + return RowVisualState( + isDeleted: false, isInserted: false, modifiedColumns: [], struckColumns: changed, + vocabulary: .resultComparison + ) + case .same: + return RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [], vocabulary: .resultComparison) + } + } + + internal static func tableRows( + for lines: [ResultComparisonGridLine], + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing + ) -> TableRows { + let rows = lines.enumerated().map { index, line in + row(at: index, for: line, left: left, right: right, pairing: pairing) + } + return TableRows( + rows: ContiguousArray(rows), + columns: [String(localized: "Status"), String(localized: "Side")] + pairing.pairs.map(\.name), + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: leadingColumnCount) + + pairing.pairs.map { displayType(for: $0, left: left, right: right) } + ) + } + + internal static func statusTitle(for kind: ResultRowDifference) -> String { + switch kind { + case .onlyLeft: return String(localized: "Only Left") + case .onlyRight: return String(localized: "Only Right") + case .changed: return String(localized: "Changed") + case .same: return String(localized: "Same") + } + } + + private static func row( + at index: Int, + for line: ResultComparisonGridLine, + left: ResultComparisonSide, + right: ResultComparisonSide, + pairing: ResultColumnPairing + ) -> Row { + var values = ContiguousArray() + values.reserveCapacity(leadingColumnCount + pairing.pairs.count) + values.append(.text(statusTitle(for: line.entry.kind))) + values.append(.text(line.isLeft ? String(localized: "Left") : String(localized: "Right"))) + let source = line.isLeft + ? line.entry.leftRow.map { left.rows[$0] } + : line.entry.rightRow.map { right.rows[$0] } + var absent: Set = [] + for (position, pair) in pairing.pairs.enumerated() { + let column = line.isLeft ? pair.left : pair.right + values.append(source?[column] ?? .null) + if source?.isAbsent(column) == true { absent.insert(position + leadingColumnCount) } + } + return Row(id: .existing(index), values: values, absentColumns: absent) + } + + private static func displayType( + for pair: ResultColumnPairing.Pair, + left: ResultComparisonSide, + right: ResultComparisonSide + ) -> ColumnType { + CompareRowGridModel.displayType(source: left.columnType(at: pair.left), target: right.columnType(at: pair.right)) + } + + private func lineIndex(forDisplayRow row: Int) -> Int? { + let index = coordinator?.tableRowsIndex(forDisplayRow: row) ?? row + return lines.indices.contains(index) ? index : nil + } + + // MARK: - DataGridViewDelegate + + internal func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + coordinator = tableViewCoordinator + } + + internal func dataGridVisualState(forRow row: Int) -> RowVisualState? { + guard let index = lineIndex(forDisplayRow: row), states.indices.contains(index) else { return nil } + return states[index] + } + + internal func dataGridCopyRows(_ indices: Set) { + coordinator?.copyRowsAsTSV(at: indices, includeHeaders: false) + } +} + +internal struct ResultComparisonGrid: View { + internal let comparison: ResultComparison + internal let left: ResultComparisonSide + internal let right: ResultComparisonSide + internal let pairing: ResultColumnPairing + internal let filter: ResultComparisonFilter + + @State private var model = ResultComparisonGridModel() + @State private var changeManager = AnyChangeManager(DataChangeManager()) + @State private var selectedRows: Set = [] + @State private var columnLayout = ColumnLayoutState() + + internal var body: some View { + let key = ResultComparisonGridModel.LoadKey(comparisonId: comparison.id, filter: filter) + let model = model + let comparison = comparison + let left = left + let right = right + let pairing = pairing + var configuration = DataGridConfiguration() + configuration.showRowNumbers = false + configuration.supportsColumnCommands = false + configuration.supportsValueFilter = false + configuration.supportsColumnSort = false + return DataGridView( + tableRowsProvider: { + model.ensureLoaded(key: key, comparison: comparison, left: left, right: right, pairing: pairing) + }, + changeManager: changeManager, + isEditable: false, + configuration: configuration, + delegate: model, + selectedRowIndices: $selectedRows, + sortState: .constant(SortState()), + columnLayout: $columnLayout, + contentRevision: key.hashValue + ) + .onChange(of: key) { _ in + selectedRows = [] + } + } +} diff --git a/apple/TablePro/Views/Results/Comparison/ResultComparisonView.swift b/apple/TablePro/Views/Results/Comparison/ResultComparisonView.swift new file mode 100644 index 0000000000..63ebb0b1fc --- /dev/null +++ b/apple/TablePro/Views/Results/Comparison/ResultComparisonView.swift @@ -0,0 +1,304 @@ +// +// ResultComparisonView.swift +// TablePro +// + +import SwiftUI + +internal struct ResultComparisonView: View { + @ObservedObject internal var model: ResultComparisonModel + + @Environment(\.controlActiveState) private var controlActiveState + + internal var body: some View { + VStack(spacing: 0) { + header + Divider() + reviewBar + notices + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onChange(of: controlActiveState) { state in + guard state == .key else { return } + model.refreshCandidates() + } + .onDisappear { model.cancel() } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Left") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(model.left.title) + .lineLimit(1) + .truncationMode(.middle) + .padding(.vertical, 2) + .accessibilityIdentifier("result-comparison.left") + Text(model.left.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.left.arrow.right") + .foregroundStyle(.secondary) + .padding(.top, 20) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text("Right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + rightPicker + if let right = model.right { + Text(right.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + } + + private var rightPicker: some View { + Picker(String(localized: "Compare With"), selection: Binding( + get: { model.rightId }, + set: { model.chooseRight($0) } + )) { + if model.rightId == nil { + Text("Choose a Result").tag(UUID?.none) + } + ForEach(model.groupedCandidates, id: \.group) { group in + Section(group.group) { + ForEach(group.candidates) { candidate in + Text(candidate.title).tag(UUID?.some(candidate.id)) + } + } + } + } + .labelsHidden() + .disabled(model.candidates.isEmpty) + .accessibilityIdentifier("result-comparison.right") + } + + // MARK: - Review bar + + private var reviewBar: some View { + HStack(spacing: 12) { + keyColumnsMenu + + Picker(String(localized: "Show"), selection: $model.filter) { + ForEach(ResultComparisonFilter.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + .fixedSize() + .accessibilityIdentifier("result-comparison.show") + + Spacer(minLength: 0) + + if model.isRefreshing || isComparing { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Comparing")) + } + if let comparison = model.comparison { + Text(countsSummary(comparison)) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityIdentifier("result-comparison.counts") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private var keyColumnsMenu: some View { + LabeledContent(String(localized: "Key columns")) { + Menu { + if let pairing = model.pairing { + ForEach(pairing.pairs.indices, id: \.self) { index in + Toggle(pairing.pairs[index].name, isOn: Binding( + get: { model.isKey(index) }, + set: { _ in model.toggleKey(index) } + )) + } + Divider() + Button("Match Whole Rows") { model.matchWholeRows() } + .disabled(model.keyPairs.isEmpty) + } + } label: { + Label(keySummary, systemImage: "key") + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: 240) + .help(String(localized: "Rows with the same values in these columns are compared. With none chosen, whole rows are matched.")) + .accessibilityIdentifier("result-comparison.key") + } + .disabled(model.pairing?.pairs.isEmpty ?? true) + } + + private var isComparing: Bool { + guard case .comparing = model.state else { return false } + return true + } + + private var keySummary: String { + guard let pairing = model.pairing, !model.keyPairs.isEmpty else { + return String(localized: "Whole row") + } + return model.keyPairs.map { pairing.pairs[$0].name }.joined(separator: ", ") + } + + private func countsSummary(_ comparison: ResultComparison) -> String { + String( + format: String(localized: "%1$d only left, %2$d only right, %3$d changed, %4$d same"), + comparison.count(of: .onlyLeft), + comparison.count(of: .onlyRight), + comparison.count(of: .changed), + comparison.count(of: .same) + ) + } + + // MARK: - Notices + + @ViewBuilder + private var notices: some View { + if let truncation = truncationNotice { + notice(truncation, systemImage: "info.circle") + } + if let pairing = model.pairing, !pairing.pairs.isEmpty { + if !pairing.leftOnly.isEmpty { + notice( + String( + format: String(localized: "Only in the left result, not compared: %@"), + pairing.leftOnly.joined(separator: ", ") + ), + systemImage: "info.circle" + ) + } + if !pairing.rightOnly.isEmpty { + notice( + String( + format: String(localized: "Only in the right result, not compared: %@"), + pairing.rightOnly.joined(separator: ", ") + ), + systemImage: "info.circle" + ) + } + } + } + + private var truncationNotice: String? { + let leftCut = model.left.isTruncated + let rightCut = model.right?.isTruncated ?? false + switch (leftCut, rightCut) { + case (true, true): + return String(localized: "Both results stopped at the row cap and are compared on the rows they hold. A row past the cap can show as only on one side.") + case (true, false): + return String( + format: String(localized: "The left result stopped at the row cap and is compared on the %@ rows it holds. A row past the cap can show as only on the right."), + model.left.rows.count.formatted() + ) + case (false, true): + return String( + format: String(localized: "The right result stopped at the row cap and is compared on the %@ rows it holds. A row past the cap can show as only on the left."), + (model.right?.rows.count ?? 0).formatted() + ) + case (false, false): + return nil + } + } + + private func notice(_ text: String, systemImage: String) -> some View { + Label { + Text(text) + } icon: { + Image(systemName: systemImage) + } + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + switch model.state { + case .compared(let answer): + if answer.comparison.entries.contains(where: { model.filter.matches($0.kind) }) { + ResultComparisonGrid( + comparison: answer.comparison, + left: model.left, + right: answer.right, + pairing: answer.pairing, + filter: model.filter + ) + } else { + CompareRowsStandIn(reason: .noRowsMatch) + } + case .comparing: + ProgressView(String(localized: "Comparing…")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() + case .failed(let message): + standIn(String(localized: "Cannot Compare"), systemImage: "exclamationmark.triangle", description: message) + case .idle(let reason): + idleStandIn(reason) + } + } + + @ViewBuilder + private func idleStandIn(_ reason: ResultComparisonIdleReason) -> some View { + switch reason { + case .noCandidates: + standIn( + String(localized: "No Result to Compare With"), + systemImage: "tablecells", + description: String(localized: "Run another query in this tab, or pin a result in another tab.") + ) + case .notChosen: + standIn( + String(localized: "Choose a Result"), + systemImage: "arrow.left.arrow.right", + description: String(localized: "Pick the result to compare this one with.") + ) + case .rightClosed: + standIn( + String(localized: "Result Closed"), + systemImage: "xmark.circle", + description: String(localized: "That result is no longer open. Choose another one.") + ) + case .noCommonColumns: + standIn( + String(localized: "No Columns in Common"), + systemImage: "tablecells", + description: String(localized: "The two results share no column name, so there is nothing to compare.") + ) + } + } + + private func standIn(_ title: String, systemImage: String, description: String) -> some View { + UnavailableStateView { + Label(title, systemImage: systemImage) + } description: { + Text(description) + } + .gridStandInBackground() + } +} diff --git a/apple/TablePro/Views/Results/Comparison/ResultComparisonWindowController.swift b/apple/TablePro/Views/Results/Comparison/ResultComparisonWindowController.swift new file mode 100644 index 0000000000..4246562944 --- /dev/null +++ b/apple/TablePro/Views/Results/Comparison/ResultComparisonWindowController.swift @@ -0,0 +1,27 @@ +// +// ResultComparisonWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +internal final class ResultComparisonWindowController: ValueViewerWindowController { + private var model: ResultComparisonModel? + + @discardableResult + static func open(model: ResultComparisonModel) -> ResultComparisonWindowController { + let controller = ResultComparisonWindowController() + controller.model = model + controller.present( + identifier: "result-comparison", + title: String(localized: "Compare Results"), + autosaveName: "ResultComparisonWindow", + size: NSSize(width: 960, height: 600) + ) { _ in + ResultComparisonView(model: model) + } + return controller + } +} diff --git a/apple/TablePro/Views/Results/DataGridView.swift b/apple/TablePro/Views/Results/DataGridView.swift index 77ebbd99c4..8a4acce0a9 100644 --- a/apple/TablePro/Views/Results/DataGridView.swift +++ b/apple/TablePro/Views/Results/DataGridView.swift @@ -19,6 +19,7 @@ struct CellPosition: Hashable { enum DataGridChangeVocabulary: Equatable { case pendingEdit case comparison + case resultComparison } struct RowVisualState: Equatable { diff --git a/apple/TablePro/Views/Results/ResultSetMenu.swift b/apple/TablePro/Views/Results/ResultSetMenu.swift index 4dbb570fd3..99b74fa10c 100644 --- a/apple/TablePro/Views/Results/ResultSetMenu.swift +++ b/apple/TablePro/Views/Results/ResultSetMenu.swift @@ -12,6 +12,7 @@ struct ResultSetMenu: View { let isSpelledOut: Bool let onActivate: (UUID) -> Void let onTogglePin: (UUID) -> Void + let onCompare: (UUID) -> Void let onClose: (UUID) -> Void let onCloseOthers: (UUID) -> Void @@ -45,6 +46,9 @@ struct ResultSetMenu: View { onTogglePin(active.id) } + Button(String(localized: "Compare Result With…")) { onCompare(active.id) } + .disabled(!model.canCompare(active)) + Button(String(localized: "Close Result")) { onClose(active.id) } .disabled(!model.canClose(active)) diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 610da9d034..35605095ad 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -46,10 +46,10 @@ struct ResultStatusBar: View { /// this window is. It had no surface at all between the centred toolbar item going and this. let isRefreshingSchema: Bool @Binding var viewMode: ResultsViewMode - /// The result-set chooser, absent when the tab holds at most one result. let resultSetMenu: ResultSetMenuModel let onActivateResultSet: (UUID) -> Void let onToggleResultSetPin: (UUID) -> Void + let onCompareResultSet: (UUID) -> Void let onCloseResultSet: (UUID) -> Void let onCloseOtherResultSets: (UUID) -> Void let onToggleFilters: () -> Void @@ -116,6 +116,7 @@ struct ResultStatusBar: View { isSpelledOut: presentation.resultSetMenuIsSpelledOut, onActivate: onActivateResultSet, onTogglePin: onToggleResultSetPin, + onCompare: onCompareResultSet, onClose: onCloseResultSet, onCloseOthers: onCloseOtherResultSets ) diff --git a/apple/TablePro/Views/Results/ValueViewerWindowController.swift b/apple/TablePro/Views/Results/ValueViewerWindowController.swift index adc73d2267..d8c9e8b870 100644 --- a/apple/TablePro/Views/Results/ValueViewerWindowController.swift +++ b/apple/TablePro/Views/Results/ValueViewerWindowController.swift @@ -12,7 +12,7 @@ import TableProTheme @MainActor internal class ValueViewerWindowController { private static var activeWindows: [ObjectIdentifier: ValueViewerWindowController] = [:] - private static let defaultSize = NSSize(width: 640, height: 500) + static let defaultSize = NSSize(width: 640, height: 500) static let minSize = NSSize(width: 400, height: 300) private static let styleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .miniaturizable] @@ -30,9 +30,10 @@ internal class ValueViewerWindowController { identifier: String, title: String, autosaveName: NSWindow.FrameAutosaveName, + size: NSSize = defaultSize, @ViewBuilder content: (@escaping () -> Void) -> Content ) { - let window = Self.makeWindow(identifier: identifier, title: title, content: content) + let window = Self.makeWindow(identifier: identifier, title: title, size: size, content: content) self.window = window windowBackdrop = ThemeWindowBackdrop(window: window) @@ -58,17 +59,15 @@ internal class ValueViewerWindowController { window.makeKeyAndOrderFront(nil) } - /// A hosting view that is a window's content view rewrites that window's size limits from its - /// SwiftUI content on every constraint pass, so `NSWindow.minSize` alone lasts until the first - /// one: a viewer with a small toolbar could be dragged down to it. The floor is part of the - /// content, which is the size the hosting view publishes. + // The hosting view rewrites the window's size limits from its content on every constraint pass, so the floor lives in the content. static func makeWindow( identifier: String, title: String, + size: NSSize = defaultSize, @ViewBuilder content: (@escaping () -> Void) -> Content ) -> NSWindow { let window = ContentWindow( - contentRect: NSRect(origin: .zero, size: defaultSize), + contentRect: NSRect(origin: .zero, size: size), styleMask: styleMask, backing: .buffered, defer: false diff --git a/apple/TableProTests/Core/Compare/ResultSetComparisonTests.swift b/apple/TableProTests/Core/Compare/ResultSetComparisonTests.swift new file mode 100644 index 0000000000..ea4f59f6e1 --- /dev/null +++ b/apple/TableProTests/Core/Compare/ResultSetComparisonTests.swift @@ -0,0 +1,327 @@ +// +// ResultSetComparisonTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +private func side( + _ columns: [String], + _ rows: [[String?]], + types: [ColumnType] = [], + primaryKey: [String] = [], + truncated: Bool = false +) -> ResultComparisonSide { + ResultComparisonSide( + title: "Result", + detail: "Query 1 (Local)", + columns: columns, + columnTypes: types, + rows: ContiguousArray(rows.enumerated().map { index, values in + Row(id: .existing(index), values: ContiguousArray(values.map { $0.map(PluginCellValue.text) ?? .null })) + }), + isTruncated: truncated, + primaryKeyColumns: primaryKey + ) +} + +private func compare( + _ left: ResultComparisonSide, + _ right: ResultComparisonSide, + key: [String] = [] +) throws -> ResultComparison { + let pairing = ResultColumnPairing(left: left.columns, right: right.columns) + let keyPairs = key.compactMap { name in pairing.pairs.firstIndex { $0.name == name } } + return try ResultComparisonEngine.compare(left: left, right: right, pairing: pairing, keyPairs: keyPairs) +} + +private func kinds(_ comparison: ResultComparison) -> [ResultRowDifference] { + comparison.entries.map(\.kind) +} + +struct ResultColumnPairingTests { + @Test("Columns pair by name, and a column on one side only is listed apart") + func pairsByName() { + let pairing = ResultColumnPairing(left: ["id", "name", "added"], right: ["name", "id", "removed"]) + + #expect(pairing.pairs.map(\.name) == ["id", "name"]) + #expect(pairing.pairs.map(\.left) == [0, 1]) + #expect(pairing.pairs.map(\.right) == [1, 0]) + #expect(pairing.leftOnly == ["added"]) + #expect(pairing.rightOnly == ["removed"]) + } + + @Test("A different case pairs only when no exact spelling is left") + func caseFallsBack() { + let pairing = ResultColumnPairing(left: ["ID", "id"], right: ["id", "Id"]) + + #expect(pairing.pairs.map(\.left) == [0, 1]) + #expect(pairing.pairs.map(\.right) == [1, 0]) + #expect(pairing.leftOnly.isEmpty) + #expect(pairing.rightOnly.isEmpty) + } + + @Test("A repeated name pairs by occurrence") + func repeatedNamesPairInOrder() { + let pairing = ResultColumnPairing(left: ["id", "id", "name"], right: ["id", "name", "id"]) + + #expect(pairing.pairs.map(\.right) == [0, 2, 1]) + } +} + +struct ResultComparisonEngineTests { + @Test("Keyed rows split into only left, only right, changed and same") + func keyedComparison() throws { + let left = side(["id", "name"], [["1", "a"], ["2", "x"], ["4", "d"]]) + let right = side(["id", "name"], [["1", "a"], ["2", "b"], ["3", "c"]]) + + let result = try compare(left, right, key: ["id"]) + + #expect(kinds(result) == [.same, .changed, .onlyLeft, .onlyRight]) + #expect(result.entries[1].changedPairs == [1]) + #expect(result.entries[1].leftRow == 1) + #expect(result.entries[1].rightRow == 1) + #expect(result.entries[3].rightRow == 2) + #expect(result.differenceCount == 3) + #expect(result.count(of: .same) == 1) + } + + @Test("Rows match on the key wherever they sit in each result") + func keyIgnoresRowOrder() throws { + let left = side(["id", "name"], [["3", "c"], ["1", "a"]]) + let right = side(["id", "name"], [["1", "a"], ["3", "c"]]) + + #expect(kinds(try compare(left, right, key: ["id"])) == [.same, .same]) + } + + @Test("Without a key whole rows match, and a repeated row counts once per copy") + func wholeRowsCountDuplicates() throws { + let left = side(["id", "name"], [["1", "a"], ["1", "a"], ["2", "b"]]) + let right = side(["id", "name"], [["1", "a"], ["2", "x"]]) + + let result = try compare(left, right) + + #expect(kinds(result) == [.same, .onlyLeft, .onlyLeft, .onlyRight]) + #expect(result.entries[0].rightRow == 0) + #expect(result.entries[1].leftRow == 1) + #expect(result.count(of: .changed) == 0) + } + + @Test("A key repeated on one side is refused and named") + func duplicateKeyIsRefused() { + let left = side(["id", "name"], [["1", "a"], ["1", "b"]]) + let right = side(["id", "name"], [["1", "a"]]) + + #expect(throws: ResultComparisonError.self) { try compare(left, right, key: ["id"]) } + do { + _ = try compare(left, right, key: ["id"]) + } catch { + #expect(error.localizedDescription.contains("1")) + #expect(error.localizedDescription.contains("left result")) + } + } + + @Test("NULL in a key matches NULL on the other side") + func nullKeyMatchesNull() throws { + let left = side(["code", "name"], [[nil, "a"], ["k", "b"]]) + let right = side(["code", "name"], [[nil, "z"], ["k", "b"]]) + + let result = try compare(left, right, key: ["code"]) + + #expect(kinds(result) == [.changed, .same]) + } + + @Test("A column on one side only is not compared") + func oneSidedColumnIsIgnored() throws { + let left = side(["id", "name", "note"], [["1", "a", "left"]]) + let right = side(["id", "name"], [["1", "a"]]) + + #expect(kinds(try compare(left, right, key: ["id"])) == [.same]) + #expect(kinds(try compare(left, right)) == [.same]) + } + + @Test("Two spellings of one instant are the same value") + func timestampsCompareAsInstants() throws { + let left = side(["id", "at"], [["1", "2026-01-02 10:00:00"]], types: [.integer(rawType: nil), .timestamp(rawType: nil)]) + let right = side(["id", "at"], [["1", "2026-01-02T10:00:00"]], types: [.integer(rawType: nil), .timestamp(rawType: nil)]) + + #expect(kinds(try compare(left, right, key: ["id"])) == [.same]) + } + + @Test("Whole-row matching reads two spellings of one instant as the same value") + func wholeRowTimestamps() throws { + let types: [ColumnType] = [.integer(rawType: nil), .timestamp(rawType: nil)] + let left = side(["id", "at"], [["1", "2026-01-02 10:00:00.000001"]], types: types) + let right = side(["id", "at"], [["1", "2026-01-02T10:00:00.000001"]], types: types) + + #expect(kinds(try compare(left, right)) == [.same]) + } + + @Test("A key holding two spellings of one instant still matches") + func keyTimestamps() throws { + let left = side(["at", "v"], [["2026-01-02 10:00:00", "1"]]) + let right = side(["at", "v"], [["2026-01-02T10:00:00", "1"]]) + + #expect(kinds(try compare(left, right, key: ["at"])) == [.same]) + } + + @Test("A field the document lacks is not the same as an explicit NULL") + func absentIsNotNull() throws { + let left = side(["id", "tag"], [["1", nil]]) + var lacking = left.rows + lacking[0].absentColumns = [1] + let right = ResultComparisonSide(title: "Result", detail: "Query", columns: ["id", "tag"], rows: lacking) + + #expect(kinds(try compare(left, right, key: ["id"])) == [.changed]) + #expect(kinds(try compare(left, right)) == [.onlyLeft, .onlyRight]) + } + + @Test("NULL differs from an empty string") + func nullIsNotEmpty() throws { + let left = side(["id", "name"], [["1", nil]]) + let right = side(["id", "name"], [["1", ""]]) + + #expect(kinds(try compare(left, right, key: ["id"])) == [.changed]) + } + + @Test("The primary key of the left result's table is the default key") + func defaultKeyIsPrimaryKey() { + let left = side(["name", "ID"], [], primaryKey: ["id"]) + let right = side(["name", "id"], []) + let pairing = ResultColumnPairing(left: left.columns, right: right.columns) + + #expect(ResultComparisonEngine.defaultKeyPairs(left: left, right: right, pairing: pairing) == [1]) + } + + @Test("The right result's key is used when the left has none") + func defaultKeyFallsBackToRight() { + let left = side(["id", "name"], []) + let right = side(["id", "name"], [], primaryKey: ["id"]) + let pairing = ResultColumnPairing(left: left.columns, right: right.columns) + + #expect(ResultComparisonEngine.defaultKeyPairs(left: left, right: right, pairing: pairing) == [0]) + } + + @Test("A key with a column one result lacks falls back to whole rows") + func defaultKeyNeedsEveryColumn() { + let left = side(["name"], [], primaryKey: ["id"]) + let right = side(["name", "id"], []) + let pairing = ResultColumnPairing(left: left.columns, right: right.columns) + + #expect(ResultComparisonEngine.defaultKeyPairs(left: left, right: right, pairing: pairing).isEmpty) + } + + @Test("Two empty results compare to nothing") + func emptyResults() throws { + let result = try compare(side(["id"], []), side(["id"], []), key: ["id"]) + + #expect(result.entries.isEmpty) + #expect(result.differenceCount == 0) + } +} + +@MainActor +struct ResultComparisonModelTests { + private static func candidate(_ id: UUID, _ side: ResultComparisonSide?) -> ResultComparisonCandidate { + ResultComparisonCandidate(id: id, title: "Result 1: t", group: "This Tab", isPinned: true, read: { side }) + } + + private static func compared(_ model: ResultComparisonModel) async throws -> ResultComparison { + try await waitUntil { model.comparison != nil } + return try #require(model.comparison) + } + + @Test("It compares with the preferred result on the default key") + func comparesOnOpen() async throws { + let rightId = UUID() + let left = side(["id", "name"], [["1", "a"], ["2", "x"]], primaryKey: ["id"]) + let right = side(["id", "name"], [["1", "a"], ["2", "b"]]) + let model = ResultComparisonModel(left: left, preferredRightId: rightId) { + [Self.candidate(rightId, right)] + } + + let result = try await Self.compared(model) + + #expect(model.keyPairs == [0]) + #expect(kinds(result) == [.same, .changed]) + } + + @Test("Clearing the key matches whole rows") + func matchWholeRows() async throws { + let rightId = UUID() + let left = side(["id", "name"], [["1", "a"], ["2", "x"]], primaryKey: ["id"]) + let right = side(["id", "name"], [["1", "a"], ["2", "b"]]) + let model = ResultComparisonModel(left: left, preferredRightId: rightId) { + [Self.candidate(rightId, right)] + } + _ = try await Self.compared(model) + + model.matchWholeRows() + try await waitUntil { model.comparison?.keyPairs.isEmpty == true } + + #expect(kinds(try #require(model.comparison)) == [.same, .onlyLeft, .onlyRight]) + } + + @Test("A result closed before it is read says so") + func closedResult() { + let rightId = UUID() + let model = ResultComparisonModel(left: side(["id"], [["1"]]), preferredRightId: rightId) { + [Self.candidate(rightId, nil)] + } + + guard case .idle(.rightClosed) = model.state else { + Issue.record("Expected the closed state, got \(model.state)") + return + } + } + + @Test("With nothing to compare against it says so") + func noCandidates() { + let model = ResultComparisonModel(left: side(["id"], [["1"]]), preferredRightId: nil) { [] } + + guard case .idle(.noCandidates) = model.state else { + Issue.record("Expected the empty state, got \(model.state)") + return + } + } + + @Test("Results with no column in common are not compared") + func noCommonColumns() { + let rightId = UUID() + let model = ResultComparisonModel(left: side(["a"], [["1"]]), preferredRightId: rightId) { + [Self.candidate(rightId, side(["b"], [["1"]]))] + } + + guard case .idle(.noCommonColumns) = model.state else { + Issue.record("Expected no common columns, got \(model.state)") + return + } + } + + @Test("A duplicate key reports the refusal rather than a comparison") + func duplicateKeyFails() async throws { + let rightId = UUID() + let left = side(["id"], [["1"], ["1"]], primaryKey: ["id"]) + let model = ResultComparisonModel(left: left, preferredRightId: rightId) { + [Self.candidate(rightId, side(["id"], [["1"]]))] + } + + try await waitUntil { + if case .failed = model.state { return true } + return false + } + } + + @Test("Newest result compares with the one before it, any other with the newest") + func defaultPartner() { + let ids = [UUID(), UUID(), UUID()] + + #expect(MainContentCoordinator.defaultComparisonPartner(of: ids[2], among: ids) == ids[1]) + #expect(MainContentCoordinator.defaultComparisonPartner(of: ids[0], among: ids) == ids[2]) + #expect(MainContentCoordinator.defaultComparisonPartner(of: ids[0], among: [ids[0]]) == nil) + } +} diff --git a/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift b/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift index 2fbccb26e9..00192fe223 100644 --- a/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift +++ b/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift @@ -64,6 +64,15 @@ struct ResultSetMenuModelTests { #expect(model.canCloseOthers(active) == false) } + @Test("Compare is offered only for a result that holds columns") + func compareNeedsColumns() throws { + let rows = Self.make(count: 2, activeIndex: 1, comparableIndices: [0, 1]) + let plan = Self.make(count: 2, activeIndex: 1, comparableIndices: [0]) + + #expect(rows.canCompare(try #require(rows.activeEntry))) + #expect(plan.canCompare(try #require(plan.activeEntry)) == false) + } + @Test("An empty model draws no control") func emptyModelIsEmpty() { let model = ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0) @@ -75,7 +84,8 @@ struct ResultSetMenuModelTests { private static func make( count: Int, activeIndex: Int, - pinnedIndices: Set = [] + pinnedIndices: Set = [], + comparableIndices: Set? = nil ) -> ResultSetMenuModel { let entries = (0 ..< count).map { index in ResultSetMenuEntry( @@ -83,7 +93,8 @@ struct ResultSetMenuModelTests { label: "Result \(index + 1)", isPinned: pinnedIndices.contains(index), isActive: index == activeIndex, - ordinal: index + 1 + ordinal: index + 1, + isComparable: comparableIndices.map { $0.contains(index) } ?? true ) } return ResultSetMenuModel( diff --git a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 6c463b67e2..589d44e259 100644 --- a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -95,6 +95,7 @@ struct ResultStatusBarLayoutTests { resultSetMenu: ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0), onActivateResultSet: { _ in }, onToggleResultSetPin: { _ in }, + onCompareResultSet: { _ in }, onCloseResultSet: { _ in }, onCloseOtherResultSets: { _ in }, onToggleFilters: {}, @@ -364,6 +365,7 @@ struct ResultStatusBarLayoutTests { resultSetMenu: ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0), onActivateResultSet: { _ in }, onToggleResultSetPin: { _ in }, + onCompareResultSet: { _ in }, onCloseResultSet: { _ in }, onCloseOtherResultSets: { _ in }, onToggleFilters: {}, diff --git a/apple/TableProTests/Views/Results/ComparisonGridCopyTests.swift b/apple/TableProTests/Views/Results/ComparisonGridCopyTests.swift new file mode 100644 index 0000000000..b8a07b9d94 --- /dev/null +++ b/apple/TableProTests/Views/Results/ComparisonGridCopyTests.swift @@ -0,0 +1,64 @@ +// +// ComparisonGridCopyTests.swift +// TableProTests +// + +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct ComparisonGridCopyTests { + private func copyFirstRow(through delegate: any DataGridViewDelegate, rows: TableRows) { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([0]), + delegate: delegate, + layoutPersister: InMemoryColumnLayoutStore() + ) + coordinator.tableRowsProvider = { rows } + coordinator.rebuildColumnMetadataCache(from: rows) + coordinator.updateCache() + delegate.dataGridAttach(tableViewCoordinator: coordinator) + + coordinator.copyRows(at: [0], as: .rows) + } + + @Test("Copying a result comparison row puts the line on the clipboard") + func resultComparisonCopiesRows() throws { + let clipboard = try #require(RecordingClipboard.current) + let side = ResultComparisonSide( + title: "Result", + detail: "Query", + columns: ["id"], + rows: [Row(id: .existing(0), values: [.text("7")])] + ) + let pairing = ResultColumnPairing(left: side.columns, right: side.columns) + let lines = ResultComparisonGridModel.lines(for: [ResultComparisonEntry(kind: .onlyLeft, leftRow: 0, rightRow: nil)]) + let rows = ResultComparisonGridModel.tableRows(for: lines, left: side, right: side, pairing: pairing) + + copyFirstRow(through: ResultComparisonGridModel(), rows: rows) + + #expect(clipboard.text == "Only Left\tLeft\t7") + } + + @Test("Copying a Compare & Sync row puts the line on the clipboard") + func compareSyncCopiesRows() throws { + let clipboard = try #require(RecordingClipboard.current) + let rows = TableRows.from( + queryRows: [[.text("Insert"), .text("Source"), .text("7")]], + columns: ["Change", "Side", "id"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .integer(rawType: nil)] + ) + + copyFirstRow(through: CompareRowGridModel(), rows: rows) + + #expect(clipboard.text == "Insert\tSource\t7") + } + } +} diff --git a/apple/TableProTests/Views/Results/ResultComparisonGridModelTests.swift b/apple/TableProTests/Views/Results/ResultComparisonGridModelTests.swift new file mode 100644 index 0000000000..32b43e5a82 --- /dev/null +++ b/apple/TableProTests/Views/Results/ResultComparisonGridModelTests.swift @@ -0,0 +1,97 @@ +// +// ResultComparisonGridModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct ResultComparisonGridModelTests { + private static func side(_ columns: [String], _ rows: [[String]]) -> ResultComparisonSide { + ResultComparisonSide( + title: "Result", + detail: "Query", + columns: columns, + rows: ContiguousArray(rows.enumerated().map { index, values in + Row(id: .existing(index), values: ContiguousArray(values.map(PluginCellValue.text))) + }) + ) + } + + private static let left = side(["id", "name"], [["1", "a"], ["2", "x"], ["4", "d"]]) + private static let right = side(["name", "id"], [["a", "1"], ["b", "2"], ["c", "3"]]) + private static let pairing = ResultColumnPairing(left: left.columns, right: right.columns) + + private static func comparison() throws -> ResultComparison { + try ResultComparisonEngine.compare(left: left, right: right, pairing: pairing, keyPairs: [0]) + } + + @Test("A changed row draws both lines, every other row one") + func linesPerKind() throws { + let lines = ResultComparisonGridModel.lines(for: try Self.comparison().entries) + + #expect(lines.map(\.entry.kind) == [.same, .changed, .changed, .onlyLeft, .onlyRight]) + #expect(lines.map(\.isLeft) == [true, true, false, true, false]) + } + + @Test("Only left reads as new, only right as removed, and a changed value is marked on both lines") + func marks() throws { + let lines = ResultComparisonGridModel.lines(for: try Self.comparison().entries) + let states = lines.map(ResultComparisonGridModel.visualState(for:)) + let nameColumn = 1 + ResultComparisonGridModel.leadingColumnCount + + #expect(states[0] == RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [], vocabulary: .resultComparison)) + #expect(states[1].modifiedColumns == [nameColumn]) + #expect(states[2].struckColumns == [nameColumn]) + #expect(states[3].isInserted) + #expect(states[4].isDeleted) + #expect(states.allSatisfy { $0.vocabulary == .resultComparison }) + } + + @Test("Each line shows its own side's values under the left result's column order") + func valuesFollowTheLine() throws { + let lines = ResultComparisonGridModel.lines(for: try Self.comparison().entries) + let rows = ResultComparisonGridModel.tableRows(for: lines, left: Self.left, right: Self.right, pairing: Self.pairing) + + #expect(rows.columns == ["Status", "Side", "id", "name"]) + #expect(Array(rows.rows[1].values) == [.text("Changed"), .text("Left"), .text("2"), .text("x")]) + #expect(Array(rows.rows[2].values) == [.text("Changed"), .text("Right"), .text("2"), .text("b")]) + #expect(Array(rows.rows[4].values) == [.text("Only Right"), .text("Right"), .text("3"), .text("c")]) + } + + @Test("A field one document lacks stays absent on its line") + func absentFieldsSurvive() { + var lacking = Self.right.rows + lacking[1].absentColumns = [0] + let right = ResultComparisonSide(title: "Result", detail: "Query", columns: Self.right.columns, rows: lacking) + let lines = [ResultComparisonGridLine(entry: ResultComparisonEntry(kind: .onlyRight, leftRow: nil, rightRow: 1), isLeft: false)] + + let rows = ResultComparisonGridModel.tableRows(for: lines, left: Self.left, right: right, pairing: Self.pairing) + + #expect(rows.rows[0].absentColumns == [1 + ResultComparisonGridModel.leadingColumnCount]) + } + + @Test("A DATE beside a TIMESTAMP draws as text, so the right line keeps its time") + func mixedTemporalTypesDrawAsText() { + let date = ColumnType.date(rawType: "DATE") + let timestamp = ColumnType.timestamp(rawType: "TIMESTAMP") + + #expect(CompareRowGridModel.displayType(source: date, target: timestamp) == .text(rawType: nil)) + #expect(CompareRowGridModel.displayType(source: timestamp, target: .timestamp(rawType: "timestamp(6)")) == timestamp) + #expect(CompareRowGridModel.displayType(source: nil, target: date) == date) + } + + @Test("VoiceOver names the side rather than the sync direction") + func accessibilityWords() { + let onlyLeft = RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: [], vocabulary: .resultComparison) + let rightLine = RowVisualState( + isDeleted: false, isInserted: false, modifiedColumns: [], struckColumns: [2], vocabulary: .resultComparison + ) + + #expect(DataGridCellTextMark.accessibilityDescription(state: onlyLeft, columnIndex: 2) == "only in the left result") + #expect(DataGridCellTextMark.accessibilityDescription(state: rightLine, columnIndex: 2) == "right value, differs") + } +} diff --git a/apple/TableProUITests/ResultComparisonUITests.swift b/apple/TableProUITests/ResultComparisonUITests.swift new file mode 100644 index 0000000000..fdac07b829 --- /dev/null +++ b/apple/TableProUITests/ResultComparisonUITests.swift @@ -0,0 +1,59 @@ +import XCTest + +final class ResultComparisonUITests: UITestCase { + private let firstRun = "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20 UNION ALL SELECT 3, 30" + private let secondRun = "SELECT 2 AS id, 99 AS v UNION ALL SELECT 3, 30 UNION ALL SELECT 4, 40" + + func testComparingARerunWithThePinnedRun() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window + + app.typeShortcut(.newTab) + typeQuery(firstRun, in: window) + app.typeShortcut(.runQuery) + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "The first run must fill the grid") + XCTAssertTrue(gridCell(showing: "30", in: grid).waitToExist(timeout: 10), "The first run must show its rows") + app.typeShortcut(.pinResult) + + typeQuery(secondRun, in: window) + app.typeShortcut(.runQuery) + let chooser = resultSetChooser(in: window) + XCTAssertTrue( + waitForPredicate(timeout: 20) { chooser.title.contains("of 2") || chooser.title.hasSuffix("/2") }, + "The pinned run must stay beside the second one, got \(chooser.title)" + ) + + // The View menu is long enough to scroll under the pointer on the 1024x768 runner; the chooser's is short. + XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) + chooser.click() + let compareItem = window.menus.firstMatch.menuItems["Compare Result With…"] + XCTAssertTrue(compareItem.waitToExist(timeout: 5), "The chooser must offer Compare Result With…") + compareItem.click() + let comparison = self.window("result-comparison", in: app) + XCTAssertTrue(comparison.waitToExist(timeout: 10), "Compare Result With… must open the comparison") + + let counts = comparison.staticTexts.matching(identifier: "result-comparison.counts").firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 10) { text(of: counts) == "2 only left, 2 only right, 0 changed, 1 same" }, + "With no table behind them the results match on whole rows, got \(text(of: counts))" + ) + + let keyMenu = comparison.descendants(matching: .any).matching(identifier: "result-comparison.key").firstMatch + XCTAssertTrue(waitUntilHittable(keyMenu, timeout: 10)) + keyMenu.click() + let idItem = comparison.menuItems["id"].firstMatch + XCTAssertTrue(idItem.waitToExist(timeout: 5), "The key menu must list the shared columns") + idItem.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { text(of: counts) == "1 only left, 1 only right, 1 changed, 1 same" }, + "Keyed on id the second row reads as changed, got \(text(of: counts))" + ) + } + + // A SwiftUI text publishes its string as the value on some builds and as the label on others. + private func text(of element: XCUIElement) -> String { + (element.value as? String).flatMap { $0.isEmpty ? nil : $0 } ?? element.label + } +} From afe7f9ca171c85a0997259010274e74a4c8cead1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 10:19:14 +0700 Subject: [PATCH 2/3] docs(changelog): add the result comparison fragments --- apple/changelog.d/3739.added.md | 1 + apple/changelog.d/3739.fixed.1.md | 1 + apple/changelog.d/3739.fixed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 apple/changelog.d/3739.added.md create mode 100644 apple/changelog.d/3739.fixed.1.md create mode 100644 apple/changelog.d/3739.fixed.md diff --git a/apple/changelog.d/3739.added.md b/apple/changelog.d/3739.added.md new file mode 100644 index 0000000000..78d2f2e5aa --- /dev/null +++ b/apple/changelog.d/3739.added.md @@ -0,0 +1 @@ +Compare two query results row by row, matched on key columns or whole rows, from the result chooser or the View menu. diff --git a/apple/changelog.d/3739.fixed.1.md b/apple/changelog.d/3739.fixed.1.md new file mode 100644 index 0000000000..fef86f6b0e --- /dev/null +++ b/apple/changelog.d/3739.fixed.1.md @@ -0,0 +1 @@ +The Compare & Sync Rows tab showed a DATE beside a TIMESTAMP as the same value, hiding the time that differed. diff --git a/apple/changelog.d/3739.fixed.md b/apple/changelog.d/3739.fixed.md new file mode 100644 index 0000000000..65432099c5 --- /dev/null +++ b/apple/changelog.d/3739.fixed.md @@ -0,0 +1 @@ +In the Compare & Sync Rows tab, copying a row did nothing and clicking a heading offered a sort that did nothing. From a6d95451ff619d13152754671cd283bd6a981566 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 10:50:08 +0700 Subject: [PATCH 3/3] fix(results): cover the vector column type in the comparison display shape --- apple/TablePro/Views/Compare/CompareRowGrid.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apple/TablePro/Views/Compare/CompareRowGrid.swift b/apple/TablePro/Views/Compare/CompareRowGrid.swift index 83f68553a6..386c82e9d1 100644 --- a/apple/TablePro/Views/Compare/CompareRowGrid.swift +++ b/apple/TablePro/Views/Compare/CompareRowGrid.swift @@ -228,7 +228,7 @@ internal struct CompareRowGrid: View { private extension ColumnType { enum DisplayShape { - case text, integer, decimal, date, timestamp, datetime, boolean, blob, json, enumType, set, spatial, array + case text, integer, decimal, date, timestamp, datetime, boolean, blob, json, enumType, set, spatial, vector, array } var displayShape: DisplayShape { @@ -245,6 +245,7 @@ private extension ColumnType { case .enumType: return .enumType case .set: return .set case .spatial: return .spatial + case .vector: return .vector case .array: return .array } }