diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index 620c34e..6f68df7 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -37,6 +37,7 @@ struct MainView: View { @State private var showingEditor = false @State private var showingDatabaseTransfer = false @State private var schemaEditorTarget: SchemaEditorTarget? = nil + @State private var objectEditorTarget: WorkbenchObjectEditorTarget? = nil @State private var showingQueryPlan = false @State private var showingQueryHistory = false @State private var showingSavedQueries = false @@ -117,6 +118,11 @@ struct MainView: View { }) { target in WorkbenchSchemaEditorView(profile: model.selected, target: target) } + .sheet(item: $objectEditorTarget, onDismiss: { + if !model.selectedDatabase.isEmpty { model.loadTables(database: model.selectedDatabase) } + }) { target in + WorkbenchObjectEditorView(profile: model.selected, target: target) + } .sheet(isPresented: $showingEditor) { ConnectionEditor( profile: $editing, @@ -640,6 +646,12 @@ struct MainView: View { Button(kind == .view ? "Select First Rows" : "Show Definition") { openSchemaObject(database: database, name: name, kind: kind) } + Button("Edit Definition…") { + objectEditorTarget = WorkbenchObjectEditorTarget( + database: database, name: name, + kind: kind == .view ? .view : (kind == .procedure ? .procedure : .function) + ) + } } } if objects.isEmpty { @@ -5186,3 +5198,171 @@ private struct WorkbenchSchemaEditorView: View { } } } + + +// MARK: - View and Stored Routine Definition Editor + +private struct WorkbenchObjectEditorTarget: Identifiable { + enum Kind: String { case view = "VIEW", procedure = "PROCEDURE", function = "FUNCTION" } + let database: String + let name: String + let kind: Kind + var id: String { database + "." + kind.rawValue + "." + name } +} + +private struct WorkbenchObjectEditorView: View { + let profile: ConnectionProfile? + let target: WorkbenchObjectEditorTarget + @Environment(\.dismiss) private var dismiss + @State private var definition = "" + @State private var originalDefinition = "" + @State private var message: String? + @State private var isError = false + @State private var busy = false + @State private var confirmSave = false + + private func quote(_ value: String) -> String { + "`" + value.replacingOccurrences(of: "`", with: "``") + "`" + } + private var qualified: String { quote(target.database) + "." + quote(target.name) } + + private func query(_ sql: String) async throws -> QueryResult { + guard let profile else { + throw NSError(domain: "WorkbenchObjectEditor", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."]) + } + return try await NativeMySQLRunner.run( + profile: profile, database: target.database, + password: PasswordStore.read(profile.id), sql: sql + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Edit " + target.kind.rawValue.capitalized, systemImage: "curlybraces") + .font(.title2.bold()) + Text(qualified).font(.subheadline.monospaced()).foregroundStyle(.secondary) + Spacer() + Button("Close") { dismiss() } + } + if let message { + Text(message) + .foregroundStyle(isError ? .red : .green) + .textSelection(.enabled) + } + Text("Edit the complete CREATE statement. The original definition is kept in memory until this window closes.") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $definition) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 350) + .border(Color.secondary.opacity(0.3)) + if target.kind != .view { + Text("MySQL does not support CREATE OR REPLACE for routines. Saving drops and recreates the routine. This is not atomic: grants, DEFINER and concurrent callers may be affected. Back up the routine first.") + .font(.caption).foregroundStyle(.orange) + } + HStack { + Button("Reload Original") { definition = originalDefinition }.disabled(busy) + Spacer() + ProgressView().controlSize(.small).opacity(busy ? 1 : 0) + Button("Save Definition…") { confirmSave = true } + .disabled(busy || definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || definition == originalDefinition) + .buttonStyle(.borderedProminent) + } + } + .padding(20) + .frame(minWidth: 850, minHeight: 560) + .task { await load() } + .confirmationDialog("Apply database definition?", isPresented: $confirmSave, titleVisibility: .visible) { + Button(target.kind == .view ? "Replace View" : "Drop and Recreate " + target.kind.rawValue.capitalized, + role: .destructive) { Task { await save() } } + Button("Cancel", role: .cancel) { } + } message: { + Text(target.kind == .view + ? "This will replace the view definition on the MySQL server." + : "This drops the existing routine before recreating it. MySQL cannot roll back this operation.") + } + } + + @MainActor + private func load() async { + busy = true + defer { busy = false } + do { + let result = try await query("SHOW CREATE " + target.kind.rawValue + " " + qualified) + guard let row = result.rows.first, + let index = result.columns.firstIndex(where: { $0.lowercased().hasPrefix("create ") }), + row.indices.contains(index) else { + throw NSError(domain: "WorkbenchObjectEditor", code: 2, + userInfo: [NSLocalizedDescriptionKey: "MySQL did not return the CREATE definition. Check SHOW VIEW or routine privileges."]) + } + originalDefinition = row[index] + definition = originalDefinition + message = nil + } catch { + isError = true + message = error.localizedDescription + } + } + + private func normalized(_ sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasSuffix(";") ? String(trimmed.dropLast()) : trimmed + } + + @MainActor + private func save() async { + busy = true + defer { busy = false } + let sql = normalized(definition) + guard sql.range(of: #"^CREATE\s+"#, options: [.regularExpression, .caseInsensitive]) != nil else { + isError = true + message = "The definition must start with CREATE." + return + } + let expectedKind = target.kind.rawValue + guard sql.range(of: "\\b" + expectedKind + "\\b", options: [.regularExpression, .caseInsensitive]) != nil else { + isError = true + message = "Expected a CREATE " + expectedKind + " statement." + return + } + do { + if target.kind == .view { + // SHOW CREATE VIEW may include ALGORITHM / DEFINER / SQL SECURITY. + // Insert OR REPLACE immediately after CREATE, retaining those clauses. + let replacement = sql.replacingOccurrences( + of: #"^CREATE\s+(?:OR\s+REPLACE\s+)?"#, + with: "CREATE OR REPLACE ", + options: [.regularExpression, .caseInsensitive] + ) + _ = try await query(replacement) + } else { + // MySQL has no CREATE OR REPLACE PROCEDURE/FUNCTION. + _ = try await query("DROP " + target.kind.rawValue + " " + qualified) + do { + _ = try await query(sql) + } catch { + // Best effort restoration of the original definition. + do { + _ = try await query(normalized(originalDefinition)) + throw NSError(domain: "WorkbenchObjectEditor", code: 3, + userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). The original definition was restored; verify privileges and grants."]) + } catch let restoreError { + if (restoreError as NSError).code == 3 && (restoreError as NSError).domain == "WorkbenchObjectEditor" { + throw restoreError + } + throw NSError(domain: "WorkbenchObjectEditor", code: 4, + userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). Restoration also failed: \(restoreError.localizedDescription). Restore from your backup immediately."]) + } + } + } + originalDefinition = sql + definition = sql + isError = false + message = "Definition saved successfully." + } catch { + isError = true + message = error.localizedDescription + } + } +} diff --git a/docs/PHASE56-EDIT-SCHEMA-OBJECTS.md b/docs/PHASE56-EDIT-SCHEMA-OBJECTS.md new file mode 100644 index 0000000..2cc0a9a --- /dev/null +++ b/docs/PHASE56-EDIT-SCHEMA-OBJECTS.md @@ -0,0 +1,11 @@ +# Phase 56 — Edit schema objects + +Baseline: Phase 55. Schema navigator context menus for Views, Stored Procedures and Functions now provide **Edit Definition…**. + +- Loads original DDL with `SHOW CREATE VIEW`, `SHOW CREATE PROCEDURE` or `SHOW CREATE FUNCTION`. +- Displays an editable full-definition SQL text editor, a reload-original button, and explicit save confirmation. +- Views are updated with `CREATE OR REPLACE VIEW`, preserving ALGORITHM / DEFINER clauses from MySQL's output. +- MySQL routines require DROP then CREATE; save explicitly warns that this is not atomic, and attempts to restore the original definition if recreation fails. Restoration may also fail. Backup and check grants/DEFINER before editing. +- Save errors are displayed without closing the editor. Schema objects are refreshed when the editor closes. + +Swift syntax parsed on Linux; native macOS compilation and interactive database testing were not available.