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"))
|
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 selectedID: UUID?
|
||||||
@Published var queryTabs: [WorkbenchQueryTab] = [
|
@Published var queryTabs: [WorkbenchQueryTab] = [
|
||||||
WorkbenchQueryTab(
|
WorkbenchQueryTab(
|
||||||
|
|||||||
+38
-2
@@ -6563,6 +6563,7 @@ private struct DBSynDataCompareView: View {
|
|||||||
@State private var table = ""
|
@State private var table = ""
|
||||||
@State private var isComparing = false
|
@State private var isComparing = false
|
||||||
@State private var report: DatabaseModel.DataComparisonReport?
|
@State private var report: DatabaseModel.DataComparisonReport?
|
||||||
|
@State private var syncPreview: DatabaseModel.DataSyncPreview?
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
private var commonTables: [String] {
|
private var commonTables: [String] {
|
||||||
@@ -6602,6 +6603,30 @@ private struct DBSynDataCompareView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button("Compare") { compare() }
|
Button("Compare") { compare() }
|
||||||
.disabled(table.isEmpty || targetDatabase.isEmpty || isComparing)
|
.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 isComparing { ProgressView("Comparing table samples…") }
|
||||||
if let errorMessage {
|
if let errorMessage {
|
||||||
@@ -6641,8 +6666,19 @@ private struct DBSynDataCompareView: View {
|
|||||||
targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? ""
|
targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: targetDatabase) { _, _ in table = ""; report = nil }
|
.onChange(of: targetDatabase) { _, _ in table = ""; report = nil; syncPreview = nil }
|
||||||
.onChange(of: table) { _, _ in report = 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() {
|
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