From c6af133e76cf470ff641bf764496be98c9833c7b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 19 Oct 2026 10:05:48 +0100 Subject: [PATCH] new tests --- DBSyn.xcodeproj/project.pbxproj | 4 + DBSyn/App.swift | 28 ++++- DBSyn/LaravelMigrationView.swift | 189 +++++++++++++++++++++++++++++++ docs/PHASE73-8-LARAVEL.md | 7 ++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 DBSyn/LaravelMigrationView.swift create mode 100644 docs/PHASE73-8-LARAVEL.md diff --git a/DBSyn.xcodeproj/project.pbxproj b/DBSyn.xcodeproj/project.pbxproj index e723248..f1b432e 100644 --- a/DBSyn.xcodeproj/project.pbxproj +++ b/DBSyn.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ /* End PBXTargetDependency section */ /* Begin PBXBuildFile section */ + B74000000000000000000001 /* LaravelMigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74000000000000000000002 /* LaravelMigrationView.swift */; }; B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73200000000000000000002 /* SchemaDiagramPrintView.swift */; }; B73000000000000000000001 /* SchemaDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73000000000000000000002 /* SchemaDiagramView.swift */; }; 000000000000000000000013 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000010 /* App.swift */; }; @@ -29,6 +30,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B74000000000000000000002 /* LaravelMigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaravelMigrationView.swift; sourceTree = ""; }; B73200000000000000000002 /* SchemaDiagramPrintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramPrintView.swift; sourceTree = ""; }; B73000000000000000000002 /* SchemaDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramView.swift; sourceTree = ""; }; 00000000000000000000000F /* DBSyn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DBSyn.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -73,6 +75,7 @@ 000000000000000000000012 /* DatabaseModel.swift */, F07370000000000000000001 /* DiagramLogic.swift */, B73000000000000000000002 /* SchemaDiagramView.swift */, + B74000000000000000000002 /* LaravelMigrationView.swift */, B73200000000000000000002 /* SchemaDiagramPrintView.swift */, 000000000000000000000016 /* MySQLProtocol.swift */, @@ -176,6 +179,7 @@ 000000000000000000000015 /* DatabaseModel.swift in Sources */, F07370000000000000000002 /* DiagramLogic.swift in Sources */, B73000000000000000000001 /* SchemaDiagramView.swift in Sources */, + B74000000000000000000001 /* LaravelMigrationView.swift in Sources */, B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */, 000000000000000000000017 /* MySQLProtocol.swift in Sources */, ); diff --git a/DBSyn/App.swift b/DBSyn/App.swift index 6684d45..b8da462 100644 --- a/DBSyn/App.swift +++ b/DBSyn/App.swift @@ -9,6 +9,7 @@ extension Notification.Name { struct DBSynApp: App { @StateObject private var model = DatabaseModel() @AppStorage("preferences.appearance") private var appearance = "system" + @State private var showingMigrationGenerator = false var body: some Scene { WindowGroup("DBSyn") { @@ -21,6 +22,7 @@ struct DBSynApp: App { } } .environmentObject(model) + .sheet(isPresented: $showingMigrationGenerator) { LaravelMigrationView(model: model) } .frame(minWidth: 800, minHeight: 520) } .defaultSize(width: 1200, height: 800) @@ -38,6 +40,9 @@ struct DBSynApp: App { } } WorkbenchFileCommands(model: model) + CommandMenu("Tools") { + Button("Generate Laravel Migration…") { showingMigrationGenerator = true } + } } @@ -117,6 +122,9 @@ struct WorkbenchSettingsView: View { @AppStorage("preferences.appearance") private var appearance = "system" + @AppStorage("preferences.migrationFilenamePrefix") + private var migrationFilenamePrefix = "create" + var body: some View { TabView { @@ -144,6 +152,9 @@ struct WorkbenchSettingsView: View { ) } + migrationSettings + .tabItem { Label("Laravel", systemImage: "curlybraces") } + generalSettings .tabItem { Label( @@ -152,10 +163,25 @@ struct WorkbenchSettingsView: View { ) } } - .frame(width: 620, height: 390) + .frame(width: 760, height: 480) .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) } + private var migrationSettings: some View { + settingsPage("Laravel & Developer Tools") { + Text("Generate Laravel migrations from a connected MySQL table or an imported CREATE TABLE SQL file.") + .foregroundStyle(.secondary) + settingsRow("Filename prefix") { + TextField("create", text: $migrationFilenamePrefix) + .textFieldStyle(.roundedBorder) + } + Text("Open Tools → Generate Laravel Migration to preview, edit and save the generated PHP file.") + .font(.callout).foregroundStyle(.secondary) + Text("Generated migrations must be reviewed before execution. DBSyn never executes generated PHP.") + .font(.caption).foregroundStyle(.secondary) + } + } + private var editorSettings: some View { settingsPage("SQL Editor") { diff --git a/DBSyn/LaravelMigrationView.swift b/DBSyn/LaravelMigrationView.swift new file mode 100644 index 0000000..88976d8 --- /dev/null +++ b/DBSyn/LaravelMigrationView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +// Pure conversion engine; can be exercised independently of the UI. +enum LaravelMigrationGenerator { + static func generate(_ ddl: String) throws -> String { + guard let match = ddl.range(of: #"(?is)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?\s*\("#, options: .regularExpression) else { + throw NSError(domain: "DBSyn.Migration", code: 1, userInfo: [NSLocalizedDescriptionKey: "No CREATE TABLE definition found. Supply MySQL CREATE TABLE SQL."]) + } + let prefix = String(ddl[match]); let name = prefix.replacingOccurrences(of: #"(?is)^.*?CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"#, with: "", options: .regularExpression).replacingOccurrences(of: "(", with: "").replacingOccurrences(of: "`", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + let start = match.upperBound + var depth = 1; var end = start; var quote: Character? = nil + for index in ddl[start...].indices { + let c = ddl[index] + if let q = quote { if c == q { quote = nil }; continue } + if c == "'" || c == "\"" || c == "`" { quote = c; continue } + if c == "(" { depth += 1 }; if c == ")" { depth -= 1; if depth == 0 { end = index; break } } + } + guard depth == 0 else { throw NSError(domain: "DBSyn.Migration", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unbalanced CREATE TABLE parentheses."]) } + let body = String(ddl[start..primary('\\(cols[0])');") } + else if !cols.isEmpty { lines.append(" $table->primary([" + cols.map { "'\\($0)'" }.joined(separator: ", ") + "]);" ) } + continue + } + if upper.hasPrefix("UNIQUE KEY") || upper.hasPrefix("UNIQUE INDEX") || upper.hasPrefix("KEY ") || upper.hasPrefix("INDEX ") { + let cols = backtickNames(item); if cols.count > 1 { lines.append(" $table->" + (upper.hasPrefix("UNIQUE") ? "unique" : "index") + "([" + cols.dropFirst().map { "'\\($0)'" }.joined(separator: ", ") + "]);") }; continue + } + if upper.hasPrefix("CONSTRAINT") || upper.hasPrefix("FOREIGN KEY") || upper.hasPrefix("CHECK") { lines.append(" // Review manually: " + item.replacingOccurrences(of: "\n", with: " ")); continue } + guard let column = item.range(of: #"^`?([A-Za-z_][\w]*)`?\s+"#, options: .regularExpression) else { continue } + let col = String(item[column]).trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "`", with: "") + let spec = String(item[column.upperBound...]); let type = spec.lowercased() + let method: String + if type.hasPrefix("bigint") { method = "bigInteger" } + else if type.hasPrefix("tinyint(1)") || type.hasPrefix("boolean") { method = "boolean" } + else if type.hasPrefix("tinyint") { method = "tinyInteger" } + else if type.hasPrefix("smallint") { method = "smallInteger" } + else if type.hasPrefix("mediumint") { method = "mediumInteger" } + else if type.hasPrefix("int") || type.hasPrefix("integer") { method = "integer" } + else if type.hasPrefix("varchar") { method = "string" } + else if type.hasPrefix("char") { method = "char" } + else if type.hasPrefix("longtext") { method = "longText" } + else if type.hasPrefix("mediumtext") { method = "mediumText" } + else if type.hasPrefix("text") { method = "text" } + else if type.hasPrefix("datetime") { method = "dateTime" } + else if type.hasPrefix("timestamp") { method = "timestamp" } + else if type.hasPrefix("date") { method = "date" } + else if type.hasPrefix("time") { method = "time" } + else if type.hasPrefix("json") { method = "json" } + else if type.hasPrefix("double") { method = "double" } + else if type.hasPrefix("float") { method = "float" } + else if type.hasPrefix("decimal") { method = "decimal" } + else if type.hasPrefix("blob") { method = "binary" } + else { method = "string" } + var args = "'\(col)'" + if method == "string" || method == "char", let range = type.range(of: #"\(\s*\d+\s*\)"#, options: .regularExpression) { args += ", " + type[range].filter(\.isNumber) } + if method == "decimal", let range = type.range(of: #"\(\s*\d+\s*,\s*\d+\s*\)"#, options: .regularExpression) { args += ", " + type[range].dropFirst().dropLast() } + var expr = " $table->\(method)(\(args))" + if upper.contains("UNSIGNED") { expr += "->unsigned()" } + if !upper.contains("NOT NULL") { expr += "->nullable()" } + if upper.contains("AUTO_INCREMENT") { expr += "->autoIncrement()" } + lines.append(expr + ";") + } + let safeName = name.replacingOccurrences(of: "'", with: "\\'") + return """ + [String] { + let regex = try! NSRegularExpression(pattern: "`([^`]+)`") + 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)) } + } + private static func splitColumns(_ text: String) -> [String] { + var parts: [String] = []; var current = ""; var depth = 0; var quote: Character? = nil + for c in text { + if let q = quote { current.append(c); if c == q { quote = nil }; continue } + if c == "'" || c == "\"" || c == "`" { quote = c; current.append(c); continue } + if c == "(" { depth += 1 }; if c == ")" { depth -= 1 } + if c == "," && depth == 0 { parts.append(current); current = "" } else { current.append(c) } + } + if !current.isEmpty { parts.append(current) }; return parts + } +} + +struct LaravelMigrationView: View { + @ObservedObject var model: DatabaseModel + @AppStorage("preferences.migrationFilenamePrefix") private var prefix = "create" + @State private var sourceSQL = "" + @State private var generated = "" + @State private var errorText = "" + @State private var tableName = "" + @State private var isLoading = false + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Image(systemName: "curlybraces.square").font(.title2).foregroundStyle(.tint) + VStack(alignment: .leading) { + Text("Laravel Migration Generator").font(.title2.bold()) + Text("Convert a MySQL table or CREATE TABLE SQL into a Laravel migration.").foregroundStyle(.secondary) + } + Spacer() + } + HStack { + TextField("Table name", text: $tableName).frame(maxWidth: 260) + Button("Load from Connected Database") { loadTable() }.disabled(tableName.isEmpty || model.selected == nil || isLoading) + Button("Open SQL File…") { openFile() } + Spacer() + } + HSplitView { + VStack(alignment: .leading) { + Text("MySQL CREATE TABLE").font(.headline) + TextEditor(text: $sourceSQL).font(.system(.body, design: .monospaced)).frame(minWidth: 330) + } + VStack(alignment: .leading) { + Text("Laravel Migration · PHP").font(.headline) + TextEditor(text: $generated).font(.system(.body, design: .monospaced)).frame(minWidth: 330) + } + } + if !errorText.isEmpty { Text(errorText).foregroundStyle(.red).font(.callout) } + HStack { + Text("Review indexes, foreign keys, defaults and unsupported MySQL types before running a migration.").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Generate") { generate() }.buttonStyle(.borderedProminent) + Button("Copy PHP") { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(generated, forType: .string) }.disabled(generated.isEmpty) + Button("Save Migration…") { save() }.disabled(generated.isEmpty) + } + } + .padding(22) + .frame(minWidth: 850, minHeight: 600) + } + private func generate() { + do { generated = try LaravelMigrationGenerator.generate(sourceSQL); errorText = "" } + catch { errorText = error.localizedDescription } + } + private func openFile() { + let panel = NSOpenPanel(); panel.allowedContentTypes = [UTType(filenameExtension: "sql") ?? .plainText, .plainText]; panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + do { sourceSQL = try String(contentsOf: url, encoding: .utf8); generate() } + catch { errorText = error.localizedDescription } + } + } + private func loadTable() { + guard let profile = model.selected else { return } + isLoading = true + let db = model.selectedDatabase.isEmpty ? profile.database : model.selectedDatabase + let escaped = tableName.replacingOccurrences(of: "`", with: "``") + Task { + do { + let result = try await NativeMySQLRunner.run(profile: profile, database: db, password: PasswordStore.read(profile.id), sql: "SHOW CREATE TABLE `\(escaped)`") + guard let ddl = result.rows.first?.last else { throw NSError(domain: "DBSyn.Migration", code: 3, userInfo: [NSLocalizedDescriptionKey: "No table definition returned."]) } + sourceSQL = ddl; generate() + } catch { errorText = error.localizedDescription } + isLoading = false + } + } + private func save() { + let panel = NSSavePanel(); panel.allowedContentTypes = [UTType(filenameExtension: "php") ?? .plainText] + let name = tableName.isEmpty ? "table" : tableName.lowercased() + panel.nameFieldStringValue = "\(prefix)_\(name)_table.php" + if panel.runModal() == .OK, let url = panel.url { + do { try generated.write(to: url, atomically: true, encoding: .utf8); errorText = "" } + catch { errorText = error.localizedDescription } + } + } +} diff --git a/docs/PHASE73-8-LARAVEL.md b/docs/PHASE73-8-LARAVEL.md new file mode 100644 index 0000000..e489257 --- /dev/null +++ b/docs/PHASE73-8-LARAVEL.md @@ -0,0 +1,7 @@ +# Phase 73.8 — Laravel Migration Generator + +Tools > Generate Laravel Migration opens a two-pane converter. Load a table using SHOW CREATE TABLE or import a .sql file, then Generate, review, copy, or save PHP. Laravel settings include a filename prefix. + +Current converter handles common scalar MySQL column types, nullability, unsigned integers, auto increment and simple indexes. Complex foreign keys, CHECK constraints, defaults, generated columns, enum types and multi-table SQL require manual review. No generated migration is executed automatically. + +Existing server status, connections, privileges, system variables, dashboard, performance and log features remain available through the Administration navigator. The reference screenshots show MySQL Workbench menus, not a requirement to clone its proprietary help destinations.