added typeaheead and introspetion
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user