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('\(columns[0])');") } else if !columns.isEmpty { let quotedColumns = columns.map { "'\($0)'" }.joined(separator: ", ") lines.append(" $table->primary([\(quotedColumns)]);") } continue } if upper.hasPrefix("UNIQUE KEY") || upper.hasPrefix("UNIQUE INDEX") || upper.hasPrefix("KEY ") || upper.hasPrefix("INDEX ") { let columns = backtickNames(item) if columns.count > 1 { let columnNames = Array(columns.dropFirst()) let quotedColumns = columnNames.map { "'\($0)'" }.joined(separator: ", ") let indexMethod = upper.hasPrefix("UNIQUE") ? "unique" : "index" lines.append(" $table->\(indexMethod)([\(quotedColumns)]);") } 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 } } } }