added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-18 17:18:14 +01:00
parent 7f6b63d05b
commit 8e5515afc0
3 changed files with 213 additions and 0 deletions
+71
View File
@@ -162,6 +162,77 @@ final class DatabaseModel: ObservableObject {
}
}
// Phase 66: compare bounded table samples without modifying either database.
struct DataComparisonReport {
let sourceRows: Int
let targetRows: Int
let truncated: Bool
let text: String
}
func compareTableData(source: String, target: String, table: String) async throws -> DataComparisonReport {
guard let profile = selected else {
throw NSError(domain: "DBSyn.DataCompare", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."])
}
guard let sourceMetadata = introspection(for: source),
let targetMetadata = introspection(for: target),
sourceMetadata.tables.contains(table), targetMetadata.tables.contains(table) else {
throw NSError(domain: "DBSyn.DataCompare", code: 2, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases and select a shared base table."])
}
let sourceColumns = (sourceMetadata.columnsByTable[table] ?? []).map(\.name)
let targetColumns = (targetMetadata.columnsByTable[table] ?? []).map(\.name)
guard !sourceColumns.isEmpty, sourceColumns == targetColumns else {
throw NSError(domain: "DBSyn.DataCompare", code: 3, userInfo: [NSLocalizedDescriptionKey: "The table columns must match in name and order before comparing data."])
}
func quote(_ identifier: String) -> String {
"`" + identifier.replacingOccurrences(of: "`", with: "``") + "`"
}
let password = PasswordStore.read(profile.id)
let sql = "SELECT * FROM \(quote(table)) LIMIT 501"
let left = try await NativeMySQLRunner.run(profile: profile, database: source, password: password, sql: sql)
let right = try await NativeMySQLRunner.run(profile: profile, database: target, password: password, sql: sql)
guard left.columns == right.columns else {
throw NSError(domain: "DBSyn.DataCompare", code: 4, userInfo: [NSLocalizedDescriptionKey: "Returned column layouts differ; comparison aborted."])
}
let limit = 500
let truncated = left.rows.count > limit || right.rows.count > limit
let a = Array(left.rows.prefix(limit))
let b = Array(right.rows.prefix(limit))
// JSON encoding gives unambiguous row keys, including embedded delimiters.
func counts(_ rows: [[String]]) -> [String: Int] {
var output: [String: Int] = [:]
for row in rows {
guard let bytes = try? JSONEncoder().encode(row),
let key = String(data: bytes, encoding: .utf8) else { continue }
output[key, default: 0] += 1
}
return output
}
let leftCounts = counts(a)
let rightCounts = counts(b)
let keys = Set(leftCounts.keys).union(rightCounts.keys).sorted()
var lines = ["DBSyn Phase 66 — Read-only Table Data Comparison",
"Source: \(source).\(table) — \(a.count) sampled rows",
"Target: \(target).\(table) — \(b.count) sampled rows",
"Columns: \(left.columns.joined(separator: ", "))", ""]
if truncated {
lines.append("WARNING: Truncated to 500 rows per side; without ORDER BY, MySQL does not guarantee which rows are returned. These results are not a complete or deterministic comparison.")
lines.append("")
}
var differences = 0
for key in keys {
let leftCount = leftCounts[key, default: 0]
let rightCount = rightCounts[key, default: 0]
if leftCount != rightCount {
differences += 1
lines.append("Source count \(leftCount), target count \(rightCount): \(key)")
}
}
if differences == 0 { lines.append("No differences detected in retrieved rows.") }
else { lines.insert("Distinct differing row values: \(differences)", at: 4) }
return DataComparisonReport(sourceRows: a.count, targetRows: b.count, truncated: truncated, text: lines.joined(separator: "\n"))
}
@Published var selectedID: UUID?
@Published var queryTabs: [WorkbenchQueryTab] = [
WorkbenchQueryTab(