This commit is contained in:
Mark Gregory
2026-10-16 21:40:07 +01:00
parent 7387be60b5
commit 7a0a8a1511
22 changed files with 790 additions and 43 deletions
+512
View File
@@ -25,6 +25,8 @@ struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var showingDatabaseTransfer = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@@ -66,6 +68,9 @@ struct MainView: View {
) == "dark" ? .dark : .light
)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingDatabaseTransfer) {
WorkbenchDatabaseTransferView(model: model)
}
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
@@ -102,6 +107,13 @@ struct MainView: View {
showingEditor = true
}
toolbarButton(
"Import / Export",
icon: "arrow.up.arrow.down.square"
) {
showingDatabaseTransfer = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
@@ -1539,3 +1551,503 @@ struct WorkbenchAdministrationView: View {
}
}
}
// MARK: - Built-in Database Import / Export
struct WorkbenchDatabaseTransferView: View {
@ObservedObject var model: DatabaseModel
@Environment(\.dismiss) private var dismiss
@State private var selectedMode = 0
@State private var database = ""
@State private var isWorking = false
@State private var progress = 0.0
@State private var status = "Ready"
@State private var operationTask: Task<Void, Never>?
@State private var showingImportConfirmation = false
@State private var pendingImportURL: URL?
private var selectedProfile: ConnectionProfile? {
model.selected
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
Picker("Operation", selection: $selectedMode) {
Text("Data Export").tag(0)
Text("Data Import / Restore").tag(1)
}
.pickerStyle(.segmented)
.padding(20)
Form {
Section("MySQL Connection") {
LabeledContent("Connection") {
Text(selectedProfile?.name ?? "Not selected")
}
Picker("Database", selection: $database) {
Text("Select Database").tag("")
ForEach(model.databases, id: \.self) { name in
Text(name).tag(name)
}
}
}
if selectedMode == 0 {
Section("Export Options") {
Text("Export the SQL definitions of all base tables in the selected database.")
.foregroundStyle(.secondary)
Text("This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Section("Import Options") {
Text("Import SQL statements into the selected database.")
.foregroundStyle(.secondary)
Text("Supports ordinary SQL statements. Dumps using custom DELIMITER directives or stored routines are not supported yet.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Progress") {
ProgressView(value: progress)
.frame(maxWidth: .infinity)
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
.formStyle(.grouped)
Divider()
HStack {
Button("Close") {
operationTask?.cancel()
dismiss()
}
Spacer()
if isWorking {
Button("Cancel") {
operationTask?.cancel()
status = "Cancelling after current statement…"
}
} else {
Button(
selectedMode == 0
? "Export SQL…"
: "Import SQL…"
) {
if selectedMode == 0 {
chooseExportDestination()
} else {
chooseImportSource()
}
}
.buttonStyle(.borderedProminent)
.disabled(
selectedProfile == nil ||
database.isEmpty
)
}
}
.padding(16)
}
.frame(minWidth: 650, idealWidth: 780, minHeight: 500)
.onAppear {
if database.isEmpty {
database = model.selectedDatabase.isEmpty
? (model.databases.first ?? "")
: model.selectedDatabase
}
}
.confirmationDialog(
"Import SQL into \(database)?",
isPresented: $showingImportConfirmation
) {
Button("Import SQL") {
guard let url = pendingImportURL else { return }
operationTask = Task {
await importSQL(from: url)
}
}
Button("Cancel", role: .cancel) {
pendingImportURL = nil
}
} message: {
Text(
"SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing."
)
}
}
private var header: some View {
HStack(spacing: 12) {
Image(systemName: "externaldrive.badge.timemachine")
.font(.system(size: 28))
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("Database Import / Export")
.font(.system(size: 22, weight: .semibold))
Text("Built-in native MySQL database tools")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(20)
}
private func chooseExportDestination() {
let panel = NSSavePanel()
panel.title = "Export MySQL Database"
panel.nameFieldStringValue = "\(database).sql"
panel.canCreateDirectories = true
guard panel.runModal() == .OK,
let url = panel.url
else { return }
operationTask = Task {
await exportSQL(to: url)
}
}
private func chooseImportSource() {
let panel = NSOpenPanel()
panel.title = "Import MySQL SQL File"
panel.allowedContentTypes = [.plainText]
panel.allowsOtherFileTypes = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK,
let url = panel.url
else { return }
pendingImportURL = url
showingImportConfirmation = true
}
@MainActor
private func execute(_ statement: String) async throws -> QueryResult {
guard let profile = selectedProfile else {
throw WorkbenchTransferError.message(
"No MySQL connection selected"
)
}
return try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: PasswordStore.read(profile.id),
sql: statement
)
}
private func quoteIdentifier(_ identifier: String) -> String {
"`" + identifier.replacingOccurrences(
of: "`",
with: "``"
) + "`"
}
@MainActor
private func exportSQL(to url: URL) async {
isWorking = true
progress = 0
status = "Reading database structure…"
defer {
isWorking = false
operationTask = nil
}
do {
let quotedDatabase = quoteIdentifier(database)
let tableResult = try await execute(
"SHOW FULL TABLES FROM \(quotedDatabase)"
)
let tables = tableResult.rows.compactMap { row -> String? in
guard row.count >= 2,
row[1].uppercased() == "BASE TABLE"
else { return nil }
return row[0]
}
var output = """
-- WorkbenchReplacement SQL Export
-- Database: \(database)
-- Generated: \(Date())
SET NAMES utf8mb4;
"""
for (index, table) in tables.enumerated() {
try Task.checkCancellation()
status = "Exporting structure: \(table)"
let result = try await execute(
"SHOW CREATE TABLE \(quotedDatabase).\(quoteIdentifier(table))"
)
guard let row = result.rows.first,
row.count >= 2
else {
throw WorkbenchTransferError.message(
"Cannot retrieve CREATE TABLE for \(table)"
)
}
output += "\n-- Table: \(table)\n"
output += row[1]
output += ";\n\n"
progress = Double(index + 1) /
Double(max(tables.count, 1))
}
try Task.checkCancellation()
try output.write(
to: url,
atomically: true,
encoding: .utf8
)
progress = 1
status = "Export complete: \(tables.count) table definitions"
} catch is CancellationError {
status = "Export cancelled"
} catch {
status = "Export failed: \(error.localizedDescription)"
}
}
@MainActor
private func importSQL(from url: URL) async {
isWorking = true
progress = 0
status = "Reading SQL file…"
defer {
isWorking = false
operationTask = nil
}
do {
let sql = try String(
contentsOf: url,
encoding: .utf8
)
let statements = try WorkbenchSQLImportParser.parse(sql)
guard !statements.isEmpty else {
status = "No SQL statements found"
return
}
for (index, statement) in statements.enumerated() {
try Task.checkCancellation()
status = "Executing statement \(index + 1) of \(statements.count)"
_ = try await execute(statement)
progress = Double(index + 1) /
Double(statements.count)
}
status = "Import complete: \(statements.count) statements"
model.loadDatabases()
} catch is CancellationError {
status = "Import cancelled; previously executed statements may remain applied"
} catch {
status = "Import failed: \(error.localizedDescription)"
}
}
}
enum WorkbenchTransferError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message):
return message
}
}
}
// MARK: - SQL Import Parser
//
// Handles ordinary SQL statements with quoted strings,
// quoted identifiers, line comments and block comments.
// Stored routines and custom DELIMITER directives are
// deliberately rejected rather than executed incorrectly.
enum WorkbenchSQLImportParser {
static func parse(_ sql: String) throws -> [String] {
var statements: [String] = []
var current = ""
var quote: Character?
var escaped = false
var lineComment = false
var blockComment = false
let characters = Array(sql)
var index = 0
while index < characters.count {
let char = characters[index]
let next: Character? = index + 1 < characters.count
? characters[index + 1]
: nil
if lineComment {
if char == "\n" {
lineComment = false
current.append("\n")
}
index += 1
continue
}
if blockComment {
if char == "*", next == "/" {
blockComment = false
index += 2
} else {
index += 1
}
continue
}
if let activeQuote = quote {
current.append(char)
if escaped {
escaped = false
} else if char == "\\" && activeQuote != "`" {
escaped = true
} else if char == activeQuote {
if next == activeQuote {
current.append(next!)
index += 1
} else {
quote = nil
}
}
index += 1
continue
}
if char == "'" || char == "\"" || char == "`" {
quote = char
current.append(char)
index += 1
continue
}
if char == "#"
|| (char == "-" && next == "-"
&& (index + 2 >= characters.count
|| characters[index + 2].isWhitespace)) {
lineComment = true
index += char == "#" ? 1 : 2
continue
}
if char == "/", next == "*" {
// Executable MySQL comments are not safe to discard.
if index + 2 < characters.count,
characters[index + 2] == "!" {
throw WorkbenchTransferError.message(
"This SQL file contains MySQL executable comments, which are not supported by this importer yet."
)
}
blockComment = true
index += 2
continue
}
if char == ";" {
let statement = current.trimmingCharacters(
in: .whitespacesAndNewlines
)
if !statement.isEmpty {
statements.append(statement)
}
current = ""
index += 1
continue
}
current.append(char)
index += 1
}
if quote != nil || blockComment {
throw WorkbenchTransferError.message(
"SQL file contains an unterminated quote or comment."
)
}
let remaining = current.trimmingCharacters(
in: .whitespacesAndNewlines
)
if !remaining.isEmpty {
statements.append(remaining)
}
for statement in statements {
let upper = statement.uppercased()
if upper.hasPrefix("DELIMITER ")
|| upper.hasPrefix("CREATE PROCEDURE")
|| upper.hasPrefix("CREATE FUNCTION")
|| upper.hasPrefix("CREATE TRIGGER")
|| upper.hasPrefix("CREATE EVENT") {
throw WorkbenchTransferError.message(
"Stored routines, triggers, events and DELIMITER directives require the advanced SQL importer."
)
}
}
return statements
}
}