new tests

This commit is contained in:
Mark Gregory
2026-10-19 10:05:48 +01:00
parent 16be4e8cfc
commit c6af133e76
4 changed files with 227 additions and 1 deletions
+4
View File
@@ -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
View File
@@ -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") {
+189
View File
@@ -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 }
}
}
}
+7
View File
@@ -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.