Skip to content

Commit aa43041

Browse files
authored
feat(results): compare two query results row by row (#3739)
1 parent ee782b3 commit aa43041

28 files changed

Lines changed: 1968 additions & 38 deletions
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
//
2+
// ResultSetComparison.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
internal struct ResultComparisonSide: Sendable {
10+
internal let title: String
11+
internal let detail: String
12+
internal let columns: [String]
13+
internal let columnTypes: [ColumnType]
14+
internal let rows: ContiguousArray<Row>
15+
internal let isTruncated: Bool
16+
internal let primaryKeyColumns: [String]
17+
18+
internal init(
19+
title: String,
20+
detail: String,
21+
columns: [String],
22+
columnTypes: [ColumnType] = [],
23+
rows: ContiguousArray<Row>,
24+
isTruncated: Bool = false,
25+
primaryKeyColumns: [String] = []
26+
) {
27+
self.title = title
28+
self.detail = detail
29+
self.columns = columns
30+
self.columnTypes = columnTypes
31+
self.rows = rows
32+
self.isTruncated = isTruncated
33+
self.primaryKeyColumns = primaryKeyColumns
34+
}
35+
36+
internal func columnType(at index: Int) -> ColumnType? {
37+
columnTypes.indices.contains(index) ? columnTypes[index] : nil
38+
}
39+
}
40+
41+
internal struct ResultColumnPairing: Equatable, Sendable {
42+
internal struct Pair: Equatable, Sendable {
43+
internal let name: String
44+
internal let left: Int
45+
internal let right: Int
46+
}
47+
48+
internal let pairs: [Pair]
49+
internal let leftOnly: [String]
50+
internal let rightOnly: [String]
51+
52+
// Exact names pair first, so `ID` only falls back to `id` when nothing spelled the same is left.
53+
// A repeated name pairs by occurrence: the second `id` on the left with the second on the right.
54+
internal init(left: [String], right: [String]) {
55+
var rightTaken = Array(repeating: false, count: right.count)
56+
var partner = [Int?](repeating: nil, count: left.count)
57+
58+
for (leftIndex, name) in left.enumerated() {
59+
guard let rightIndex = right.indices.first(where: { !rightTaken[$0] && right[$0] == name }) else { continue }
60+
rightTaken[rightIndex] = true
61+
partner[leftIndex] = rightIndex
62+
}
63+
for (leftIndex, name) in left.enumerated() where partner[leftIndex] == nil {
64+
guard let rightIndex = right.indices.first(where: {
65+
!rightTaken[$0] && right[$0].caseInsensitiveCompare(name) == .orderedSame
66+
}) else { continue }
67+
rightTaken[rightIndex] = true
68+
partner[leftIndex] = rightIndex
69+
}
70+
71+
pairs = left.indices.compactMap { index in
72+
partner[index].map { Pair(name: left[index], left: index, right: $0) }
73+
}
74+
leftOnly = left.indices.filter { partner[$0] == nil }.map { left[$0] }
75+
rightOnly = right.indices.filter { !rightTaken[$0] }.map { right[$0] }
76+
}
77+
}
78+
79+
internal enum ResultRowDifference: CaseIterable, Hashable, Sendable {
80+
case onlyLeft
81+
case onlyRight
82+
case changed
83+
case same
84+
85+
internal var isDifference: Bool {
86+
self != .same
87+
}
88+
}
89+
90+
internal struct ResultComparisonEntry: Hashable, Sendable {
91+
internal let kind: ResultRowDifference
92+
internal let leftRow: Int?
93+
internal let rightRow: Int?
94+
internal let changedPairs: [Int]
95+
96+
internal init(kind: ResultRowDifference, leftRow: Int?, rightRow: Int?, changedPairs: [Int] = []) {
97+
self.kind = kind
98+
self.leftRow = leftRow
99+
self.rightRow = rightRow
100+
self.changedPairs = changedPairs
101+
}
102+
}
103+
104+
internal struct ResultComparison: Sendable {
105+
internal let id = UUID()
106+
internal let entries: [ResultComparisonEntry]
107+
internal let keyPairs: [Int]
108+
109+
private let counts: [ResultRowDifference: Int]
110+
111+
internal init(entries: [ResultComparisonEntry], keyPairs: [Int]) {
112+
self.entries = entries
113+
self.keyPairs = keyPairs
114+
var counts: [ResultRowDifference: Int] = [:]
115+
for entry in entries {
116+
counts[entry.kind, default: 0] += 1
117+
}
118+
self.counts = counts
119+
}
120+
121+
internal func count(of kind: ResultRowDifference) -> Int {
122+
counts[kind] ?? 0
123+
}
124+
125+
internal var differenceCount: Int {
126+
count(of: .onlyLeft) + count(of: .onlyRight) + count(of: .changed)
127+
}
128+
}
129+
130+
internal enum ResultComparisonError: Error, Equatable, LocalizedError {
131+
case duplicateKey(String)
132+
133+
internal var errorDescription: String? {
134+
switch self {
135+
case .duplicateKey(let message):
136+
return message
137+
}
138+
}
139+
}
140+
141+
internal enum ResultComparisonEngine {
142+
private static let cancellationStride = 4_096
143+
144+
// Empty `keyPairs` matches whole rows: a row that differs in one value reads as one row only on each side.
145+
internal static func compare(
146+
left: ResultComparisonSide,
147+
right: ResultComparisonSide,
148+
pairing: ResultColumnPairing,
149+
keyPairs: [Int]
150+
) throws -> ResultComparison {
151+
let keyPairs = keyPairs.filter { pairing.pairs.indices.contains($0) }
152+
guard !keyPairs.isEmpty else {
153+
return try compareWholeRows(left: left, right: right, pairing: pairing)
154+
}
155+
return try compareByKey(left: left, right: right, pairing: pairing, keyPairs: keyPairs)
156+
}
157+
158+
// The left result's table key first; a key with a column missing from either result is skipped.
159+
internal static func defaultKeyPairs(
160+
left: ResultComparisonSide,
161+
right: ResultComparisonSide,
162+
pairing: ResultColumnPairing
163+
) -> [Int] {
164+
let candidates: [(key: [String], names: [String])] = [
165+
(left.primaryKeyColumns, pairing.pairs.map(\.name)),
166+
(right.primaryKeyColumns, pairing.pairs.map { right.columns[$0.right] })
167+
]
168+
for candidate in candidates where !candidate.key.isEmpty {
169+
let indices = candidate.key.compactMap { column in
170+
candidate.names.firstIndex(of: column)
171+
?? candidate.names.firstIndex { $0.caseInsensitiveCompare(column) == .orderedSame }
172+
}
173+
guard indices.count == candidate.key.count, Set(indices).count == indices.count else { continue }
174+
return indices.sorted()
175+
}
176+
return []
177+
}
178+
179+
private static func compareByKey(
180+
left: ResultComparisonSide,
181+
right: ResultComparisonSide,
182+
pairing: ResultColumnPairing,
183+
keyPairs: [Int]
184+
) throws -> ResultComparison {
185+
let kinds = valueKinds(pairing: pairing, left: left, right: right)
186+
let keySet = Set(keyPairs)
187+
let compared = pairing.pairs.indices.filter { !keySet.contains($0) }
188+
189+
var rightByKey: [[CellToken]: Int] = [:]
190+
rightByKey.reserveCapacity(right.rows.count)
191+
for index in right.rows.indices {
192+
try checkCancellation(at: index)
193+
let key = keyPairs.map { token(right.rows[index], pairing.pairs[$0].right, kinds[$0]) }
194+
guard rightByKey.updateValue(index, forKey: key) == nil else {
195+
throw duplicateKeyError(right.rows[index], keyPairs.map { pairing.pairs[$0].right }, onLeft: false)
196+
}
197+
}
198+
199+
var entries: [ResultComparisonEntry] = []
200+
entries.reserveCapacity(max(left.rows.count, right.rows.count))
201+
var seenOnLeft = Set<[CellToken]>()
202+
var rightMatched = [Bool](repeating: false, count: right.rows.count)
203+
204+
for index in left.rows.indices {
205+
try checkCancellation(at: index)
206+
let leftRow = left.rows[index]
207+
let key = keyPairs.map { token(leftRow, pairing.pairs[$0].left, kinds[$0]) }
208+
guard seenOnLeft.insert(key).inserted else {
209+
throw duplicateKeyError(leftRow, keyPairs.map { pairing.pairs[$0].left }, onLeft: true)
210+
}
211+
guard let partner = rightByKey[key] else {
212+
entries.append(ResultComparisonEntry(kind: .onlyLeft, leftRow: index, rightRow: nil))
213+
continue
214+
}
215+
rightMatched[partner] = true
216+
let rightRow = right.rows[partner]
217+
let changed = compared.filter { pairIndex in
218+
let pair = pairing.pairs[pairIndex]
219+
return token(leftRow, pair.left, kinds[pairIndex]) != token(rightRow, pair.right, kinds[pairIndex])
220+
}
221+
entries.append(ResultComparisonEntry(
222+
kind: changed.isEmpty ? .same : .changed,
223+
leftRow: index,
224+
rightRow: partner,
225+
changedPairs: changed
226+
))
227+
}
228+
229+
for index in right.rows.indices where !rightMatched[index] {
230+
entries.append(ResultComparisonEntry(kind: .onlyRight, leftRow: nil, rightRow: index))
231+
}
232+
return ResultComparison(entries: entries, keyPairs: keyPairs)
233+
}
234+
235+
// A multiset: two equal rows on the left and one on the right leave one of the two only on the left.
236+
private static func compareWholeRows(
237+
left: ResultComparisonSide,
238+
right: ResultComparisonSide,
239+
pairing: ResultColumnPairing
240+
) throws -> ResultComparison {
241+
let kinds = valueKinds(pairing: pairing, left: left, right: right)
242+
243+
var rightByValues: [[CellToken]: [Int]] = [:]
244+
for index in right.rows.indices {
245+
try checkCancellation(at: index)
246+
let values = pairing.pairs.indices.map { token(right.rows[index], pairing.pairs[$0].right, kinds[$0]) }
247+
rightByValues[values, default: []].append(index)
248+
}
249+
250+
var entries: [ResultComparisonEntry] = []
251+
entries.reserveCapacity(max(left.rows.count, right.rows.count))
252+
var taken: [[CellToken]: Int] = [:]
253+
var rightMatched = [Bool](repeating: false, count: right.rows.count)
254+
255+
for index in left.rows.indices {
256+
try checkCancellation(at: index)
257+
let values = pairing.pairs.indices.map { token(left.rows[index], pairing.pairs[$0].left, kinds[$0]) }
258+
let next = taken[values, default: 0]
259+
guard let bucket = rightByValues[values], next < bucket.count else {
260+
entries.append(ResultComparisonEntry(kind: .onlyLeft, leftRow: index, rightRow: nil))
261+
continue
262+
}
263+
taken[values] = next + 1
264+
rightMatched[bucket[next]] = true
265+
entries.append(ResultComparisonEntry(kind: .same, leftRow: index, rightRow: bucket[next]))
266+
}
267+
268+
for index in right.rows.indices where !rightMatched[index] {
269+
entries.append(ResultComparisonEntry(kind: .onlyRight, leftRow: nil, rightRow: index))
270+
}
271+
return ResultComparison(entries: entries, keyPairs: [])
272+
}
273+
274+
// One equality for keys, whole rows and values, matching `CellValueComparator` at its default
275+
// options: an absent field is not NULL, and two spellings of one instant agree to the microsecond.
276+
private enum CellToken: Hashable {
277+
case absent
278+
case null
279+
case text(String)
280+
case bytes(Data)
281+
case instant(Int64)
282+
}
283+
284+
private static func token(_ row: Row, _ column: Int, _ kind: ValueComparisonKind) -> CellToken {
285+
guard !row.isAbsent(column) else { return .absent }
286+
switch row[column] {
287+
case .null:
288+
return .null
289+
case .bytes(let data):
290+
return .bytes(data)
291+
case .text(let text):
292+
guard kind == .temporal || kind == .unknown, let microseconds = instantMicroseconds(text) else {
293+
return .text(text)
294+
}
295+
return .instant(microseconds)
296+
}
297+
}
298+
299+
// Every pattern `TimestampValue` reads starts with `yyyy-`, so anything else skips the formatters.
300+
private static func instantMicroseconds(_ text: String) -> Int64? {
301+
let head = Array(text.utf8.drop { $0 == UInt8(ascii: " ") }.prefix(5))
302+
guard head.count == 5, head[4] == UInt8(ascii: "-"),
303+
head[0 ..< 4].allSatisfy({ $0 >= UInt8(ascii: "0") && $0 <= UInt8(ascii: "9") }),
304+
let instant = TimestampValue.parse(text) else { return nil }
305+
let nanoseconds = instant.nanosecondsSinceEpoch
306+
let quotient = nanoseconds / 1_000
307+
return nanoseconds % 1_000 < 0 ? quotient - 1 : quotient
308+
}
309+
310+
private static func valueKinds(
311+
pairing: ResultColumnPairing,
312+
left: ResultComparisonSide,
313+
right: ResultComparisonSide
314+
) -> [ValueComparisonKind] {
315+
pairing.pairs.map { pair in
316+
let leftKind = ValueComparisonKind(columnType: left.columnType(at: pair.left))
317+
let rightKind = ValueComparisonKind(columnType: right.columnType(at: pair.right))
318+
return leftKind == rightKind ? leftKind : .unknown
319+
}
320+
}
321+
322+
private static func checkCancellation(at index: Int) throws {
323+
guard index % cancellationStride == 0 else { return }
324+
try Task.checkCancellation()
325+
}
326+
327+
private static func duplicateKeyError(_ row: Row, _ columns: [Int], onLeft: Bool) -> ResultComparisonError {
328+
let format = onLeft
329+
? String(localized: "Key %@ matches more than one row in the left result. Choose key columns that identify a single row, or match whole rows.")
330+
: String(localized: "Key %@ matches more than one row in the right result. Choose key columns that identify a single row, or match whole rows.")
331+
return .duplicateKey(String(format: format, KeyOrdering.description(of: columns.map { row[$0] })))
332+
}
333+
}

‎apple/TablePro/Core/Menu/ViewMenuBuilder.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ enum ViewMenuBuilder {
130130
shortcut: .pinResultTab,
131131
keyboard: keyboard
132132
),
133+
MenuItemFactory.item(
134+
String(localized: "Compare Result With…"),
135+
action: #selector(MainSplitViewController.compareResult(_:))
136+
),
133137
MenuItemFactory.item(
134138
String(localized: "Close Result Tab"),
135139
action: #selector(MainSplitViewController.closeResultTab(_:)),

‎apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ struct MenuValidationContext: Equatable {
105105
/// window's tabs when there is no editor tab to switch to.
106106
var hasOtherWindowTabs = false
107107
var canPinResultTab = false
108+
var canCompareResult = false
108109
/// The selected tab's browse history. Separate flags rather than one, because Back and Forward
109110
/// run out independently and an item that is disabled has to say which one it is.
110111
var canNavigateBack = false
@@ -329,6 +330,8 @@ extension MainSplitViewController: NSMenuItemValidation {
329330
return context.isConnected && context.canPresentHighlightRules
330331
case #selector(pinResult(_:)):
331332
return context.canPinResultTab
333+
case #selector(compareResult(_:)):
334+
return context.canCompareResult
332335
case #selector(navigateBack(_:)):
333336
return context.isConnected && context.canNavigateBack
334337
case #selector(navigateForward(_:)):
@@ -666,6 +669,7 @@ extension MainSplitViewController: NSMenuItemValidation {
666669
hasRecentTabToSwitchTo: hasRecentTabToSwitchTo,
667670
hasOtherWindowTabs: hasOtherWindowTabs,
668671
canPinResultTab: actions.canPinResultTab,
672+
canCompareResult: actions.canCompareResult,
669673
canNavigateBack: actions.canNavigateBack,
670674
canNavigateForward: actions.canNavigateForward,
671675
canNavigatePages: actions.canNavigatePages,

‎apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ extension MainSplitViewController {
135135
commandActions?.pinResultTab()
136136
}
137137

138+
@objc func compareResult(_ sender: Any?) {
139+
commandActions?.compareResult()
140+
}
141+
138142
@objc func closeResultTab(_ sender: Any?) {
139143
commandActions?.closeResultTab()
140144
}

0 commit comments

Comments
 (0)