added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-18 17:24:28 +01:00
parent 8e5515afc0
commit d1cd3c2004
3 changed files with 106 additions and 2 deletions
+54
View File
@@ -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(
+38 -2
View File
@@ -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() {
+14
View File
@@ -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.