diff --git a/DBSyn/LaravelMigrationView.swift b/DBSyn/LaravelMigrationView.swift index f5cc682..11f8b9a 100644 --- a/DBSyn/LaravelMigrationView.swift +++ b/DBSyn/LaravelMigrationView.swift @@ -110,7 +110,7 @@ enum LaravelMigrationGenerator { """ } private static func backtickNames(_ text: String) -> [String] { - let regex = try! NSRegularExpression(pattern: "`([^`]+)`") + guard let regex = try? NSRegularExpression(pattern: "`([^`]+)`") else { return [] } let ns = text as NSString return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).map { ns.substring(with: $0.range(at: 1)) } } diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index cec16ea..dabea58 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -379,10 +379,21 @@ struct MainView: View { .padding(.horizontal, 12) .frame(height: 34) - TextField("Filter schemas", text: $schemaFilter) - .textFieldStyle(.roundedBorder) - .padding(.horizontal, 9) - .padding(.bottom, 5) + HStack(spacing: 5) { + TextField("Filter schemas", text: $schemaFilter) + .textFieldStyle(.roundedBorder) + if !schemaFilter.isEmpty { + Button { schemaFilter = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Clear schema filter") + .accessibilityLabel("Clear schema filter") + } + } + .padding(.horizontal, 9) + .padding(.bottom, 5) Divider() @@ -6352,6 +6363,9 @@ private struct WorkbenchSchemaEditorView: View { @State private var message: String? @State private var confirmExecution = false @State private var successful = false + @State private var editedColumns: [[String]] = [] + @State private var originalColumns: [[String]] = [] + @State private var showSQLReview = false private func identifier(_ value: String) -> String { "`" + value.replacingOccurrences(of: "`", with: "``") + "`" @@ -6389,7 +6403,7 @@ private struct WorkbenchSchemaEditorView: View { } if target.table != nil { TabView { - schemaGrid(columns).tabItem { Text("Columns") } + editableColumnsGrid.tabItem { Text("Columns") } schemaGrid(indexes).tabItem { Text("Indexes") } ScrollView { Text(originalDDL).font(.system(.body, design: .monospaced)) @@ -6401,6 +6415,10 @@ private struct WorkbenchSchemaEditorView: View { } HStack { Text("SQL schema changes").font(.headline) + if target.table != nil { + Button("Generate SQL from Edits") { generateColumnSQL() } + .disabled(busy || editedColumns == originalColumns) + } Spacer() Text("Review before execution").font(.caption).foregroundStyle(.orange) } @@ -6412,6 +6430,14 @@ private struct WorkbenchSchemaEditorView: View { Text("DDL may implicitly commit and cannot reliably be rolled back.") .font(.caption).foregroundStyle(.secondary) Spacer() + Button("Copy SQL") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(proposedSQL, forType: .string) + } + .disabled(proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Copy generated SQL to paste into a DBSyn query editor") + Button("Review SQL…") { showSQLReview = true } + .disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Button("Reload") { Task { await load() } }.disabled(busy) Button("Execute DDL…") { confirmExecution = true } .disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -6421,6 +6447,26 @@ private struct WorkbenchSchemaEditorView: View { .padding(20) .frame(minWidth: 820, minHeight: 620) .task { await load() } + .sheet(isPresented: $showSQLReview) { + VStack(alignment: .leading, spacing: 12) { + Text("Review Schema Changes").font(.title2.bold()) + Text("Copy this SQL into any DBSyn query editor, or execute it here after review.") + .foregroundStyle(.secondary) + TextEditor(text: $proposedSQL) + .font(.system(.body, design: .monospaced)) + .frame(minWidth: 720, minHeight: 320) + HStack { + Button("Copy SQL") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(proposedSQL, forType: .string) + } + Spacer() + Button("Close") { showSQLReview = false } + Button("Apply…") { showSQLReview = false; confirmExecution = true } + .buttonStyle(.borderedProminent) + } + }.padding(20) + } .confirmationDialog("Execute schema modification?", isPresented: $confirmExecution, titleVisibility: .visible) { Button("Execute SQL", role: .destructive) { Task { await executeDDL() } } @@ -6430,6 +6476,124 @@ private struct WorkbenchSchemaEditorView: View { } } + // Only editable column properties are offered. Keys and indexes require separate DDL. + private var editableColumnsGrid: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Edit Type, Null, Default or Extra in place, then generate and review the ALTER TABLE SQL.") + .font(.caption).foregroundStyle(.secondary) + ScrollView([.horizontal, .vertical]) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + ForEach(columns.columns, id: \.self) { heading in + Text(heading).fontWeight(.semibold).frame(minWidth: 100, alignment: .leading) + } + } + Divider() + ForEach(editedColumns.indices, id: \.self) { row in + GridRow { + ForEach(editedColumns[row].indices, id: \.self) { col in + if [1, 3, 5, 6].contains(col) { + TextField(columns.columns[col], text: $editedColumns[row][col]) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 115) + } else { + Text(editedColumns[row][col]) + .font(.system(.caption, design: .monospaced)) + .frame(minWidth: 100, alignment: .leading) + .textSelection(.enabled) + } + } + } + } + }.padding(8) + } + HStack { + if editedColumns != originalColumns { + Text("Unsaved column edits").foregroundStyle(.orange) + Button("Revert Edits") { editedColumns = originalColumns } + } else { + Text("No pending column edits").foregroundStyle(.secondary) + } + Spacer() + Button("Generate ALTER TABLE SQL") { generateColumnSQL() } + .disabled(editedColumns == originalColumns) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func generateColumnSQL() { + guard target.table != nil else { return } + let names = columns.columns + func value(_ row: [String], _ name: String) -> String { + guard let index = names.firstIndex(of: name), index < row.count else { return "" } + return row[index] + } + guard originalColumns.count == editedColumns.count else { + message = "Column metadata changed. Reload before generating DDL." + successful = false + return + } + var statements: [String] = [] + for (old, updated) in zip(originalColumns, editedColumns) where old != updated { + let field = value(old, "Field") + guard field == value(updated, "Field"), + value(old, "Key") == value(updated, "Key"), + value(old, "Collation") == value(updated, "Collation"), + value(old, "Comment") == value(updated, "Comment") else { + message = "Only Type, Null, Default and Extra can be modified. Reload and retry." + successful = false + return + } + let type = value(updated, "Type").trimmingCharacters(in: .whitespacesAndNewlines) + let null = value(updated, "Null").uppercased() + let defaultValue = value(updated, "Default") + let extra = value(updated, "Extra").trimmingCharacters(in: .whitespacesAndNewlines) + // SHOW FULL COLUMNS does not contain generated-column expressions or + // every attribute from SHOW CREATE TABLE. Never reconstruct those columns. + if value(old, "Extra").uppercased().contains("GENERATED") || + extra.uppercased().contains("GENERATED") || + value(old, "Type").lowercased().contains("vector(") { + message = "Complex/generated columns cannot be safely modified in place. Use Original DDL and the SQL editor." + successful = false + return + } + guard !field.isEmpty, !type.isEmpty, (null == "YES" || null == "NO"), + !type.contains(";"), !type.contains("`"), !extra.contains(";"), !extra.contains("`"), + !extra.contains("--"), !extra.contains("/*") else { + message = "Invalid column definition for \(field). Check Type, Null and Extra." + successful = false + return + } + // SHOW FULL COLUMNS reports SQL NULL as the literal string NULL. + // Preserve the existing default when unchanged; generated SQL must be reviewed. + let defaultSQL: String + if defaultValue.uppercased() == "NULL" { + defaultSQL = null == "YES" ? " DEFAULT NULL" : "" + } else if defaultValue.uppercased() == "CURRENT_TIMESTAMP" || defaultValue.uppercased().hasPrefix("CURRENT_TIMESTAMP(") { + defaultSQL = " DEFAULT " + defaultValue + } else { + defaultSQL = " DEFAULT '" + defaultValue.replacingOccurrences(of: "'", with: "''") + "'" + } + let collation = value(updated, "Collation") + let collationSQL = collation != "NULL" && !collation.isEmpty && + collation.range(of: "^[A-Za-z0-9_]+$", options: .regularExpression) != nil + ? " COLLATE " + collation : "" + let comment = value(old, "Comment") + let commentSQL = comment.isEmpty || comment == "NULL" ? "" : + " COMMENT '" + comment.replacingOccurrences(of: "'", with: "''") + "'" + let clause = "MODIFY COLUMN " + identifier(field) + " " + type + collationSQL + + (null == "YES" ? " NULL" : " NOT NULL") + defaultSQL + + (extra.isEmpty || extra == "NULL" ? "" : " " + extra) + commentSQL + statements.append(clause) + } + if !statements.isEmpty { + proposedSQL = "ALTER TABLE " + qualifiedName + "\n " + statements.joined(separator: ",\n ") + ";" + message = "Generated \(statements.count) statement(s). Review SQL before applying." + successful = true + showSQLReview = true + } + } + private func schemaGrid(_ data: QueryResult) -> some View { VStack(alignment: .leading, spacing: 6) { if data.columns.isEmpty { @@ -6468,6 +6632,8 @@ private struct WorkbenchSchemaEditorView: View { defer { busy = false } do { columns = try await query("SHOW FULL COLUMNS FROM " + qualifiedName) + originalColumns = columns.rows + editedColumns = columns.rows indexes = try await query("SHOW INDEX FROM " + qualifiedName) let ddl = try await query("SHOW CREATE TABLE " + qualifiedName) originalDDL = ddl.rows.first?.last ?? "" @@ -6490,10 +6656,45 @@ private struct WorkbenchSchemaEditorView: View { busy = true defer { busy = false } do { + let expected = editedColumns + let wasGenerated = expected != originalColumns _ = try await query(statement) successful = true message = "Schema statement executed successfully." - if target.table != nil { await load() } + if target.table != nil { + await load() + // Verify the server metadata after execution, rather than trusting success alone. + if wasGenerated { + let fieldIndex = columns.columns.firstIndex(of: "Field") + let actualByName: [String: [String]] = fieldIndex.map { index in + Dictionary(columns.rows.compactMap { row -> (String, [String])? in + guard row.indices.contains(index) else { return nil } + return (row[index], row) + }, uniquingKeysWith: { first, _ in first }) + } ?? [:] + let expectedNames = expected.compactMap { row -> String? in + guard let index = fieldIndex, row.indices.contains(index) else { return nil } + return row[index] + } + let verified = expectedNames.count == expected.count && + Set(expectedNames).count == expected.count && + columns.rows.count == expected.count && + actualByName.count == expected.count && + zip(expectedNames, expected).allSatisfy { name, wanted in + guard let actual = actualByName[name], wanted.count == actual.count else { return false } + return [1, 3, 5, 6].allSatisfy { index in + wanted.indices.contains(index) && actual.indices.contains(index) && + wanted[index].caseInsensitiveCompare(actual[index]) == .orderedSame + } + } + successful = verified + message = verified + ? "DDL executed and edited column metadata verified against MySQL." + : "DDL executed, but reloaded metadata differs from the requested values. Review the columns." + } else { + message = "DDL executed. Schema metadata reloaded from MySQL." + } + } } catch { successful = false message = error.localizedDescription diff --git a/DBSyn/SchemaDiagramView.swift b/DBSyn/SchemaDiagramView.swift index 10874c6..6392bc6 100644 --- a/DBSyn/SchemaDiagramView.swift +++ b/DBSyn/SchemaDiagramView.swift @@ -473,7 +473,7 @@ struct DBSynSchemaDiagramView: View { private func printDiagram() { let view = printLayout() - let info = NSPrintInfo.shared.copy() as! NSPrintInfo + guard let info = NSPrintInfo.shared.copy() as? NSPrintInfo else { return } info.horizontalPagination = .fit info.verticalPagination = .fit info.isHorizontallyCentered = true diff --git a/docs/PHASE_74_9_1_REVIEW.md b/docs/PHASE_74_9_1_REVIEW.md new file mode 100644 index 0000000..65fda49 --- /dev/null +++ b/docs/PHASE_74_9_1_REVIEW.md @@ -0,0 +1,14 @@ +# Phase 74.9.1 — schema safety corrections + +## Corrected +- Post-DDL verification checks complete column counts, unique column names, and matches properties by column name rather than zip truncation. +- Refuses inline DDL generation if the column list changed or unsupported properties were modified. +- Preserves existing SHOW FULL COLUMNS column comments in generated MODIFY COLUMN clauses. +- Refuses inline reconstruction of generated columns; edit their original DDL instead. +- Replaced a forced regular-expression construction and forced NSPrintInfo cast. + +## Still unverified / outstanding +- Full Xcode/macOS compilation and UI/integration tests (Linux environment cannot execute Xcode). +- Full preservation of all possible MySQL column attributes: generated SQL MUST be reviewed; for advanced column definitions prefer Original DDL. +- Live TLS certificate verification, socket deadlines and cancellation, account administration operations, query-grid editing and large-result performance benchmarks. +- Do not execute generated DDL on production without a backup and SQL review.