From d1cd3c20048fac137fd30351f86bf55df2103805 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 18 Oct 2026 17:24:28 +0100 Subject: [PATCH] added typeaheead and introspetion --- DBSyn/DatabaseModel.swift | 54 ++++++++++++++++++++++++++++ DBSyn/MainView.swift | 40 +++++++++++++++++++-- docs/PHASE67-DATA-SYNCHRONISATION.md | 14 ++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 docs/PHASE67-DATA-SYNCHRONISATION.md diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index e108e8c..0d979a3 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -233,6 +233,60 @@ final class DatabaseModel: ObservableObject { return DataComparisonReport(sourceRows: a.count, targetRows: b.count, truncated: truncated, text: lines.joined(separator: "\n")) } + // Phase 67: safe, explicit same-server INSERT preview for missing primary keys. + // No SQL is executed by this method; the user reviews the statement first. + struct DataSyncPreview { + let sql: String + let keyColumns: [String] + let explanation: String + } + + func previewMissingRowsSync(source: String, target: String, table: String) throws -> DataSyncPreview { + guard selected != nil else { + throw NSError(domain: "DBSyn.DataSync", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."]) + } + guard source != target, !source.isEmpty, !target.isEmpty else { + throw NSError(domain: "DBSyn.DataSync", code: 2, userInfo: [NSLocalizedDescriptionKey: "Choose different source and target databases."]) + } + guard let left = introspection(for: source), let right = introspection(for: target), + left.tables.contains(table), right.tables.contains(table) else { + throw NSError(domain: "DBSyn.DataSync", code: 3, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases; the table must exist in both."]) + } + let leftColumns = left.columnsByTable[table] ?? [] + let rightColumns = right.columnsByTable[table] ?? [] + guard !leftColumns.isEmpty, + leftColumns.map(\.name) == rightColumns.map(\.name), + zip(leftColumns, rightColumns).allSatisfy({ $0.dataType == $1.dataType && $0.nullable == $1.nullable }) else { + throw NSError(domain: "DBSyn.DataSync", code: 4, userInfo: [NSLocalizedDescriptionKey: "Column names, order, types and nullability must match before generating SQL."]) + } + let keys = leftColumns.filter { $0.key == "PRI" }.map(\.name) + let rightKeys = rightColumns.filter { $0.key == "PRI" }.map(\.name) + guard !keys.isEmpty, keys == rightKeys else { + throw NSError(domain: "DBSyn.DataSync", code: 5, userInfo: [NSLocalizedDescriptionKey: "A matching primary key is required. No statement was generated."]) + } + func q(_ value: String) -> String { "`" + value.replacingOccurrences(of: "`", with: "``") + "`" } + let sourceTable = "\(q(source)).\(q(table))" + let targetTable = "\(q(target)).\(q(table))" + let columns = leftColumns.map { q($0.name) }.joined(separator: ", ") + let sourceValues = leftColumns.map { "s.\(q($0.name))" }.joined(separator: ", ") + let predicates = keys.map { "t.\(q($0)) = s.\(q($0))" }.joined(separator: " AND ") + let sql = """ + -- DBSyn Phase 67: REVIEW BEFORE EXECUTING. + -- Inserts only rows whose primary key is missing in the target. + -- Existing target rows are NEVER updated or deleted. + -- Take a backup and verify privileges, triggers and foreign keys first. + INSERT INTO \(targetTable) (\(columns)) + SELECT \(sourceValues) + FROM \(sourceTable) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM \(targetTable) AS t + WHERE \(predicates) + ); + """ + return DataSyncPreview(sql: sql, keyColumns: keys, + explanation: "Preview only. Uses matching primary key (\(keys.joined(separator: ", "))). Inserts missing keys; does not update changed rows. Review generated columns, triggers, constraints and backups before execution.") + } + @Published var selectedID: UUID? @Published var queryTabs: [WorkbenchQueryTab] = [ WorkbenchQueryTab( diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index da8f53c..5477652 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -6563,6 +6563,7 @@ private struct DBSynDataCompareView: View { @State private var table = "" @State private var isComparing = false @State private var report: DatabaseModel.DataComparisonReport? + @State private var syncPreview: DatabaseModel.DataSyncPreview? @State private var errorMessage: String? private var commonTables: [String] { @@ -6602,6 +6603,30 @@ private struct DBSynDataCompareView: View { Spacer() Button("Compare") { compare() } .disabled(table.isEmpty || targetDatabase.isEmpty || isComparing) + Button("Preview Missing-Row Sync…") { previewSync() } + .disabled(table.isEmpty || targetDatabase.isEmpty || isComparing) + } + if let syncPreview { + VStack(alignment: .leading, spacing: 8) { + Label("SQL preview — nothing has been executed", systemImage: "exclamationmark.shield") + .font(.headline) + Text(syncPreview.explanation).foregroundStyle(.secondary) + HStack { + Spacer() + Button("Copy SQL") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(syncPreview.sql, forType: .string) + } + } + ScrollView { + Text(syncPreview.sql) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + }.frame(minHeight: 170) + } + .padding(12) + .background(Color(nsColor: .textBackgroundColor)) } if isComparing { ProgressView("Comparing table samples…") } if let errorMessage { @@ -6641,8 +6666,19 @@ private struct DBSynDataCompareView: View { targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? "" } } - .onChange(of: targetDatabase) { _, _ in table = ""; report = nil } - .onChange(of: table) { _, _ in report = nil } + .onChange(of: targetDatabase) { _, _ in table = ""; report = nil; syncPreview = nil } + .onChange(of: table) { _, _ in report = nil; syncPreview = nil } + } + + private func previewSync() { + errorMessage = nil + report = nil + syncPreview = nil + do { + syncPreview = try model.previewMissingRowsSync(source: sourceDatabase, target: targetDatabase, table: table) + } catch { + errorMessage = error.localizedDescription + } } private func compare() { diff --git a/docs/PHASE67-DATA-SYNCHRONISATION.md b/docs/PHASE67-DATA-SYNCHRONISATION.md new file mode 100644 index 0000000..524de15 --- /dev/null +++ b/docs/PHASE67-DATA-SYNCHRONISATION.md @@ -0,0 +1,14 @@ +# Phase 67 — Safe missing-row synchronisation preview + +From **Compare Table Data…**, choose source, target, and a shared base table, then select **Preview Missing-Row Sync…**. + +The generated `INSERT INTO ... SELECT ... WHERE NOT EXISTS` statement only proposes insertion of rows whose primary key is absent in the target. It does not update differing records or delete target records. DBSyn does not execute the preview: the user must explicitly copy and review it. + +## Safeguards and limitations + +- Requires matching introspected column names, order, types, nullability, and primary key columns on both sides. +- SQL identifiers are backtick-escaped. Works only on the same MySQL server/connection. +- SQL may still fail due to foreign keys, triggers, generated columns, permissions, incompatible constraints, or concurrent writes. Inspect and adapt SQL before execution. +- The existing Phase 66 comparison remains limited to unordered 500-row samples and **must not** be treated as a complete diff. +- No automated conflict resolution, deletion, or update is implemented. +- Refresh introspection immediately before generating a preview; take a backup before applying any SQL.