diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 4ac2af7..c3b5400 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -6328,6 +6328,8 @@ private struct DBSynSchemaCompareView: View { let sourceDatabase: String @State private var targetDatabase = "" @Environment(\.dismiss) private var dismiss + @State private var showMigrationPreview = false + @State private var selectedMigrationIDs: Set = [] private var source: DatabaseModel.SchemaIntrospection? { model.introspection(for: sourceDatabase) } private var target: DatabaseModel.SchemaIntrospection? { model.introspection(for: targetDatabase) } @@ -6374,6 +6376,53 @@ private struct DBSynSchemaCompareView: View { return items } + // Phase 65: Generate only additive column DDL. Cached introspection does not + // contain complete CREATE TABLE or CREATE VIEW definitions, so those objects + // are deliberately excluded rather than producing unsafe guesses. + private struct Migration: Identifiable { + let id: String + let label: String + let sql: String + } + + private func identifier(_ name: String) -> String { + "`" + name.replacingOccurrences(of: "`", with: "``") + "`" + } + + private var migrations: [Migration] { + guard let source, let target, !targetDatabase.isEmpty else { return [] } + var result: [Migration] = [] + let sharedTables = Set(source.tables).intersection(target.tables) + for table in sharedTables.sorted() { + let existing = Set((target.columnsByTable[table] ?? []).map(\.name)) + for column in source.columnsByTable[table] ?? [] where !existing.contains(column.name) { + // Avoid inventing defaults or generating DDL that may fail on + // populated tables. Require manual review of every statement. + var definition = "\(identifier(column.name)) \(column.dataType)" + definition += column.nullable ? " NULL" : " NOT NULL" + if column.extra.uppercased().contains("AUTO_INCREMENT") { + // Auto-increment needs key/constraint context absent from the cache. + continue + } + let statement = "ALTER TABLE \(identifier(targetDatabase)).\(identifier(table)) ADD COLUMN \(definition);" + result.append(Migration(id: "\(table).\(column.name)", label: "\(table).\(column.name)", sql: statement)) + } + } + return result + } + + private var selectedMigrations: [Migration] { + migrations.filter { selectedMigrationIDs.contains($0.id) } + } + + private var migrationSQL: String { + let header = "-- DBSyn Phase 65: review carefully before execution.\n" + + "-- Source: \(sourceDatabase) Target: \(targetDatabase)\n" + + "-- Additive columns only; no tables, views, keys or destructive changes.\n" + + "-- MySQL DDL implicitly commits; take a backup first.\n\n" + return header + selectedMigrations.map(\.sql).joined(separator: "\n") + } + var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { @@ -6408,6 +6457,18 @@ private struct DBSynSchemaCompareView: View { } } } + if source != nil && target != nil { + HStack { + Button("Review Additive Migration…") { + selectedMigrationIDs = Set(migrations.map(\.id)) + showMigrationPreview = true + } + .disabled(migrations.isEmpty) + Text("\(migrations.count) eligible missing columns · tables and views require full DDL") + .font(.caption).foregroundStyle(.secondary) + Spacer() + } + } Divider() if targetDatabase.isEmpty { ContentUnavailableView("Choose a target database", systemImage: "square.on.square", description: Text("Select another schema on this connection.")) @@ -6432,5 +6493,52 @@ private struct DBSynSchemaCompareView: View { .onAppear { if targetDatabase.isEmpty { targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? "" } } + .sheet(isPresented: $showMigrationPreview) { + VStack(alignment: .leading, spacing: 12) { + Text("Review Additive Migration").font(.title2).bold() + Text("Select columns to add to \(targetDatabase). Nothing is executed automatically.") + .foregroundStyle(.secondary) + List(migrations) { migration in + Toggle(migration.label, isOn: Binding( + get: { selectedMigrationIDs.contains(migration.id) }, + set: { enabled in + if enabled { selectedMigrationIDs.insert(migration.id) } + else { selectedMigrationIDs.remove(migration.id) } + } + )) + } + .frame(minHeight: 130) + Text("SQL Preview").font(.headline) + ScrollView { + Text(migrationSQL) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .background(.quaternary.opacity(0.3)) + .frame(minHeight: 150) + Text("Warning: NOT NULL columns without defaults may fail on populated tables. Verify types, indexes and constraints manually. MySQL DDL may implicitly commit.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Spacer() + Button("Cancel") { showMigrationPreview = false } + Button("Copy SQL") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(migrationSQL, forType: .string) + } + .disabled(selectedMigrations.isEmpty) + Button("Open in SQL Editor") { + model.newQueryTab() + model.sql = migrationSQL + showMigrationPreview = false + dismiss() + } + .disabled(selectedMigrations.isEmpty) + } + } + .padding(20) + .frame(minWidth: 750, minHeight: 560) + } } } diff --git a/docs/PHASE65-SCHEMA-SYNCHRONISATION.md b/docs/PHASE65-SCHEMA-SYNCHRONISATION.md new file mode 100644 index 0000000..8e44a8f --- /dev/null +++ b/docs/PHASE65-SCHEMA-SYNCHRONISATION.md @@ -0,0 +1,10 @@ +# Phase 65 — Reviewed additive schema migration + +From **Compare Schema Structures…**, select **Review Additive Migration…**. +Choose missing columns, review the SQL, copy it, or open it in a new SQL editor tab. + +## Safety boundary + +The generator only creates `ALTER TABLE ... ADD COLUMN` for columns present in the source but absent in an existing target table. It deliberately does **not** generate `DROP`, `MODIFY`, table creation, view creation, key/index changes, or automatically execute any DDL. Full table and view definitions and index dependencies are not available in the existing introspection snapshot. Auto-increment columns are skipped. No statement is applied without an explicit subsequent user action in the SQL editor. + +Review constraints, defaults, data compatibility, and backups before execution. MySQL DDL generally causes implicit commits. Re-introspect both databases before preparing a migration.