added typeaheead and introspetion
This commit is contained in:
@@ -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 selectedID: UUID?
|
||||||
@Published var queryTabs: [WorkbenchQueryTab] = [
|
@Published var queryTabs: [WorkbenchQueryTab] = [
|
||||||
WorkbenchQueryTab(
|
WorkbenchQueryTab(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct MainView: View {
|
|||||||
@State private var showingQueryPlan = false
|
@State private var showingQueryPlan = false
|
||||||
@State private var schemaChangesDatabase: String? = nil
|
@State private var schemaChangesDatabase: String? = nil
|
||||||
@State private var schemaCompareDatabase: String? = nil
|
@State private var schemaCompareDatabase: String? = nil
|
||||||
|
@State private var dataCompareDatabase: String? = nil
|
||||||
@State private var showingQueryHistory = false
|
@State private var showingQueryHistory = false
|
||||||
@State private var showingSavedQueries = false
|
@State private var showingSavedQueries = false
|
||||||
@State private var showingSaveQuery = false
|
@State private var showingSaveQuery = false
|
||||||
@@ -98,6 +99,14 @@ struct MainView: View {
|
|||||||
DBSynSchemaChangesView(model: model, database: database)
|
DBSynSchemaChangesView(model: model, database: database)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { dataCompareDatabase != nil },
|
||||||
|
set: { if !$0 { dataCompareDatabase = nil } }
|
||||||
|
)) {
|
||||||
|
if let database = dataCompareDatabase {
|
||||||
|
DBSynDataCompareView(model: model, sourceDatabase: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { schemaCompareDatabase != nil },
|
get: { schemaCompareDatabase != nil },
|
||||||
set: { if !$0 { schemaCompareDatabase = nil } }
|
set: { if !$0 { schemaCompareDatabase = nil } }
|
||||||
@@ -500,6 +509,7 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
Button("Review Schema Changes…") { schemaChangesDatabase = db }
|
Button("Review Schema Changes…") { schemaChangesDatabase = db }
|
||||||
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
|
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
|
||||||
|
Button("Compare Table Data…") { dataCompareDatabase = db }
|
||||||
Divider()
|
Divider()
|
||||||
Button("Set as Default Schema") { model.selectedDatabase = db }
|
Button("Set as Default Schema") { model.selectedDatabase = db }
|
||||||
Button("Filter to This Schema") { schemaFilter = db }
|
Button("Filter to This Schema") { schemaFilter = db }
|
||||||
@@ -6542,3 +6552,114 @@ private struct DBSynSchemaCompareView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Phase 66: bounded, read-only table data comparison
|
||||||
|
private struct DBSynDataCompareView: View {
|
||||||
|
@ObservedObject var model: DatabaseModel
|
||||||
|
let sourceDatabase: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var targetDatabase = ""
|
||||||
|
@State private var table = ""
|
||||||
|
@State private var isComparing = false
|
||||||
|
@State private var report: DatabaseModel.DataComparisonReport?
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private var commonTables: [String] {
|
||||||
|
guard let source = model.introspection(for: sourceDatabase),
|
||||||
|
let target = model.introspection(for: targetDatabase) else { return [] }
|
||||||
|
return Array(Set(source.tables).intersection(target.tables)).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text("Compare Table Data").font(.title2).bold()
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
Text("Read-only comparison of up to 500 rows per table. No data is modified.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Text("Source: \(sourceDatabase)")
|
||||||
|
Spacer()
|
||||||
|
Picker("Target database", selection: $targetDatabase) {
|
||||||
|
Text("Choose database").tag("")
|
||||||
|
ForEach(model.databases.filter { $0 != sourceDatabase }, id: \.self) { name in
|
||||||
|
Text(name).tag(name)
|
||||||
|
}
|
||||||
|
}.frame(width: 340)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Picker("Table", selection: $table) {
|
||||||
|
Text("Choose table").tag("")
|
||||||
|
ForEach(commonTables, id: \.self) { name in Text(name).tag(name) }
|
||||||
|
}.frame(width: 420)
|
||||||
|
Button("Refresh Metadata") {
|
||||||
|
model.introspect(database: sourceDatabase)
|
||||||
|
if !targetDatabase.isEmpty { model.introspect(database: targetDatabase) }
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Compare") { compare() }
|
||||||
|
.disabled(table.isEmpty || targetDatabase.isEmpty || isComparing)
|
||||||
|
}
|
||||||
|
if isComparing { ProgressView("Comparing table samples…") }
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage).foregroundStyle(.red).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if let report {
|
||||||
|
HStack {
|
||||||
|
Text("Source: \(report.sourceRows) rows sampled")
|
||||||
|
Text("Target: \(report.targetRows) rows sampled")
|
||||||
|
Spacer()
|
||||||
|
Button("Copy Report") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(report.text, forType: .string)
|
||||||
|
}
|
||||||
|
}.font(.caption)
|
||||||
|
if report.truncated {
|
||||||
|
Label("At least one table exceeded 500 rows. Differences apply only to the retrieved samples; this is NOT a complete comparison.", systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
ScrollView {
|
||||||
|
Text(report.text)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
|
} else if !isComparing && errorMessage == nil {
|
||||||
|
ContentUnavailableView("Select two databases and a table", systemImage: "tablecells", description: Text("Introspect both databases to list their shared base tables."))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(minWidth: 820, minHeight: 520)
|
||||||
|
.onAppear {
|
||||||
|
if targetDatabase.isEmpty {
|
||||||
|
targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: targetDatabase) { _, _ in table = ""; report = nil }
|
||||||
|
.onChange(of: table) { _, _ in report = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compare() {
|
||||||
|
isComparing = true
|
||||||
|
errorMessage = nil
|
||||||
|
report = nil
|
||||||
|
let source = sourceDatabase
|
||||||
|
let target = targetDatabase
|
||||||
|
let selectedTable = table
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let value = try await model.compareTableData(source: source, target: target, table: selectedTable)
|
||||||
|
report = value
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isComparing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Phase 66 — Advanced Data Comparison (bounded preview)
|
||||||
|
|
||||||
|
The Schemas navigator now provides **Compare Table Data…** on each database.
|
||||||
|
Select another database on the same connection and a shared base table, then click Compare.
|
||||||
|
|
||||||
|
The comparison is **read-only**. It compares exact returned row values, including duplicates,
|
||||||
|
using counts of canonical JSON-encoded row arrays. Both databases must have matching column
|
||||||
|
names and order in their introspection snapshots. Views are excluded.
|
||||||
|
|
||||||
|
## Important limitations
|
||||||
|
|
||||||
|
- A maximum of **500 rows per database** are compared. An extra row is retrieved to detect truncation.
|
||||||
|
- Without a deterministic ordering, MySQL may return different subsets on repeated queries.
|
||||||
|
- No primary-key alignment or update detection is implemented in this phase; changed records
|
||||||
|
appear as removed/added row values.
|
||||||
|
- The native MySQL protocol currently represents result cells as strings, so SQL NULL and
|
||||||
|
a literal string may not be distinguishable. Do not use this comparison as a data-integrity audit.
|
||||||
|
- For large datasets, Phase 67 should implement paginated, key-aware comparison with safe
|
||||||
|
conflict handling before any data synchronisation is offered.
|
||||||
|
|
||||||
|
This phase does not modify database records or create synchronization SQL.
|
||||||
Reference in New Issue
Block a user