new tests
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
B73200000000000000000002 /* SchemaDiagramPrintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramPrintView.swift; sourceTree = "<group>"; };
|
||||
B73000000000000000000002 /* SchemaDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramView.swift; sourceTree = "<group>"; };
|
||||
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 */,
|
||||
);
|
||||
|
||||
+27
-1
@@ -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") {
|
||||
|
||||
|
||||
@@ -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..<end]); var lines: [String] = []
|
||||
for definition in splitColumns(body) {
|
||||
let item = definition.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let upper = item.uppercased()
|
||||
if upper.hasPrefix("PRIMARY KEY") {
|
||||
let cols = backtickNames(item)
|
||||
if cols.count == 1 { lines.append(" $table->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 """
|
||||
<?php
|
||||
|
||||
use Illuminate\\Database\\Migrations\\Migration;
|
||||
use Illuminate\\Database\\Schema\\Blueprint;
|
||||
use Illuminate\\Support\\Facades\\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('\(safeName)', function (Blueprint $table) {
|
||||
\(lines.joined(separator: "\n"))
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('\(safeName)');
|
||||
}
|
||||
};
|
||||
"""
|
||||
}
|
||||
private static func backtickNames(_ text: String) -> [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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user