connection updates
This commit is contained in:
@@ -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)) }
|
||||
}
|
||||
|
||||
+207
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user