connection updates
This commit is contained in:
@@ -110,7 +110,7 @@ enum LaravelMigrationGenerator {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
private static func backtickNames(_ text: String) -> [String] {
|
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
|
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)) }
|
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)
|
.padding(.horizontal, 12)
|
||||||
.frame(height: 34)
|
.frame(height: 34)
|
||||||
|
|
||||||
TextField("Filter schemas", text: $schemaFilter)
|
HStack(spacing: 5) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("Filter schemas", text: $schemaFilter)
|
||||||
.padding(.horizontal, 9)
|
.textFieldStyle(.roundedBorder)
|
||||||
.padding(.bottom, 5)
|
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()
|
Divider()
|
||||||
|
|
||||||
@@ -6352,6 +6363,9 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
@State private var message: String?
|
@State private var message: String?
|
||||||
@State private var confirmExecution = false
|
@State private var confirmExecution = false
|
||||||
@State private var successful = 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 {
|
private func identifier(_ value: String) -> String {
|
||||||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||||||
@@ -6389,7 +6403,7 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
}
|
}
|
||||||
if target.table != nil {
|
if target.table != nil {
|
||||||
TabView {
|
TabView {
|
||||||
schemaGrid(columns).tabItem { Text("Columns") }
|
editableColumnsGrid.tabItem { Text("Columns") }
|
||||||
schemaGrid(indexes).tabItem { Text("Indexes") }
|
schemaGrid(indexes).tabItem { Text("Indexes") }
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Text(originalDDL).font(.system(.body, design: .monospaced))
|
Text(originalDDL).font(.system(.body, design: .monospaced))
|
||||||
@@ -6401,6 +6415,10 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text("SQL schema changes").font(.headline)
|
Text("SQL schema changes").font(.headline)
|
||||||
|
if target.table != nil {
|
||||||
|
Button("Generate SQL from Edits") { generateColumnSQL() }
|
||||||
|
.disabled(busy || editedColumns == originalColumns)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Review before execution").font(.caption).foregroundStyle(.orange)
|
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.")
|
Text("DDL may implicitly commit and cannot reliably be rolled back.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
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("Reload") { Task { await load() } }.disabled(busy)
|
||||||
Button("Execute DDL…") { confirmExecution = true }
|
Button("Execute DDL…") { confirmExecution = true }
|
||||||
.disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
@@ -6421,6 +6447,26 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(minWidth: 820, minHeight: 620)
|
.frame(minWidth: 820, minHeight: 620)
|
||||||
.task { await load() }
|
.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,
|
.confirmationDialog("Execute schema modification?", isPresented: $confirmExecution,
|
||||||
titleVisibility: .visible) {
|
titleVisibility: .visible) {
|
||||||
Button("Execute SQL", role: .destructive) { Task { await executeDDL() } }
|
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 {
|
private func schemaGrid(_ data: QueryResult) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if data.columns.isEmpty {
|
if data.columns.isEmpty {
|
||||||
@@ -6468,6 +6632,8 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
do {
|
do {
|
||||||
columns = try await query("SHOW FULL COLUMNS FROM " + qualifiedName)
|
columns = try await query("SHOW FULL COLUMNS FROM " + qualifiedName)
|
||||||
|
originalColumns = columns.rows
|
||||||
|
editedColumns = columns.rows
|
||||||
indexes = try await query("SHOW INDEX FROM " + qualifiedName)
|
indexes = try await query("SHOW INDEX FROM " + qualifiedName)
|
||||||
let ddl = try await query("SHOW CREATE TABLE " + qualifiedName)
|
let ddl = try await query("SHOW CREATE TABLE " + qualifiedName)
|
||||||
originalDDL = ddl.rows.first?.last ?? ""
|
originalDDL = ddl.rows.first?.last ?? ""
|
||||||
@@ -6490,10 +6656,45 @@ private struct WorkbenchSchemaEditorView: View {
|
|||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
do {
|
do {
|
||||||
|
let expected = editedColumns
|
||||||
|
let wasGenerated = expected != originalColumns
|
||||||
_ = try await query(statement)
|
_ = try await query(statement)
|
||||||
successful = true
|
successful = true
|
||||||
message = "Schema statement executed successfully."
|
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 {
|
} catch {
|
||||||
successful = false
|
successful = false
|
||||||
message = error.localizedDescription
|
message = error.localizedDescription
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ struct DBSynSchemaDiagramView: View {
|
|||||||
|
|
||||||
private func printDiagram() {
|
private func printDiagram() {
|
||||||
let view = printLayout()
|
let view = printLayout()
|
||||||
let info = NSPrintInfo.shared.copy() as! NSPrintInfo
|
guard let info = NSPrintInfo.shared.copy() as? NSPrintInfo else { return }
|
||||||
info.horizontalPagination = .fit
|
info.horizontalPagination = .fit
|
||||||
info.verticalPagination = .fit
|
info.verticalPagination = .fit
|
||||||
info.isHorizontallyCentered = true
|
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