diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index d3ef1b2..e108e8c 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -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( diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index c3b5400..da8f53c 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -35,6 +35,7 @@ struct MainView: View { @State private var showingQueryPlan = false @State private var schemaChangesDatabase: String? = nil @State private var schemaCompareDatabase: String? = nil + @State private var dataCompareDatabase: String? = nil @State private var showingQueryHistory = false @State private var showingSavedQueries = false @State private var showingSaveQuery = false @@ -98,6 +99,14 @@ struct MainView: View { 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( get: { schemaCompareDatabase != nil }, set: { if !$0 { schemaCompareDatabase = nil } } @@ -500,6 +509,7 @@ struct MainView: View { } Button("Review Schema Changes…") { schemaChangesDatabase = db } Button("Compare Schema Structures…") { schemaCompareDatabase = db } + Button("Compare Table Data…") { dataCompareDatabase = db } Divider() Button("Set as Default Schema") { model.selectedDatabase = 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 + } + } +} diff --git a/docs/PHASE66-DATA-COMPARISON.md b/docs/PHASE66-DATA-COMPARISON.md new file mode 100644 index 0000000..b7d6943 --- /dev/null +++ b/docs/PHASE66-DATA-COMPARISON.md @@ -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.