This commit is contained in:
Mark Gregory
2026-10-16 21:45:53 +01:00
parent 7a0a8a1511
commit 8f7fcd97a7
45 changed files with 320 additions and 12219 deletions
+177 -66
View File
@@ -35,7 +35,7 @@ struct MainView: View {
@State private var selectedTableName: String? = nil
@State private var outputExpanded = true
@State private var navigatorMode = 0
@State private var navigatorMode = 1
@State private var administrationPage: WorkbenchAdminPage? = nil
@@ -1567,6 +1567,14 @@ struct WorkbenchDatabaseTransferView: View {
@State private var operationTask: Task<Void, Never>?
@State private var showingImportConfirmation = false
@State private var pendingImportURL: URL?
@State private var availableTables: [String] = []
@State private var selectedTables: Set<String> = []
@State private var exportStructure = true
@State private var exportData = true
@State private var includeDrop = false
@State private var loadingTables = false
@State private var availableDatabases: [String] = []
@State private var loadingDatabases = false
private var selectedProfile: ConnectionProfile? {
model.selected
@@ -1594,20 +1602,50 @@ struct WorkbenchDatabaseTransferView: View {
Picker("Database", selection: $database) {
Text("Select Database").tag("")
ForEach(model.databases, id: \.self) { name in
ForEach(availableDatabases, id: \.self) { name in
Text(name).tag(name)
}
}
HStack {
if loadingDatabases { ProgressView().controlSize(.small) }
Button("Refresh Databases") {
Task { await loadTransferDatabases() }
}
.disabled(loadingDatabases || selectedProfile == nil)
}
}
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)
Toggle("Table structure", isOn: $exportStructure)
Toggle("Table data", isOn: $exportData)
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
.disabled(!exportStructure)
HStack {
Text("Tables (\(selectedTables.count) selected)")
Spacer()
Button("All") { selectedTables = Set(availableTables) }
Button("None") { selectedTables.removeAll() }
Button("Refresh") { Task { await loadTables() } }
.disabled(loadingTables)
}
if loadingTables { ProgressView("Loading tables…") }
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(availableTables, id: \.self) { table in
Toggle(table, isOn: Binding(
get: { selectedTables.contains(table) },
set: { checked in
if checked { selectedTables.insert(table) }
else { selectedTables.remove(table) }
}
))
}
}
}
.frame(maxHeight: 180)
Text("Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Section("Import Options") {
@@ -1662,7 +1700,8 @@ struct WorkbenchDatabaseTransferView: View {
.buttonStyle(.borderedProminent)
.disabled(
selectedProfile == nil ||
database.isEmpty
database.isEmpty ||
(selectedMode == 0 && (selectedTables.isEmpty || (!exportStructure && !exportData)))
)
}
}
@@ -1676,6 +1715,12 @@ struct WorkbenchDatabaseTransferView: View {
: model.selectedDatabase
}
}
.task(id: selectedProfile?.id) {
await loadTransferDatabases()
}
.task(id: database) {
if !database.isEmpty { await loadTables() }
}
.confirmationDialog(
"Import SQL into \(database)?",
isPresented: $showingImportConfirmation
@@ -1759,7 +1804,7 @@ struct WorkbenchDatabaseTransferView: View {
return try await NativeMySQLRunner.run(
profile: profile,
database: database,
database: statement == "SHOW DATABASES" ? "" : database,
password: PasswordStore.read(profile.id),
sql: statement
)
@@ -1772,84 +1817,150 @@ struct WorkbenchDatabaseTransferView: View {
) + "`"
}
@MainActor
private func loadTransferDatabases() async {
guard selectedProfile != nil else {
availableDatabases = []
database = ""
status = "Select a MySQL connection first"
return
}
loadingDatabases = true
defer { loadingDatabases = false }
do {
// Do not depend on the schema navigator having been opened first.
let response = try await execute("SHOW DATABASES")
let names = response.rows.compactMap { $0.first }
availableDatabases = names
if !names.contains(database) {
let preferred = model.selectedDatabase
database = names.contains(preferred) ? preferred : (names.first ?? "")
}
status = names.isEmpty ? "No accessible databases" : "Ready"
} catch {
availableDatabases = []
database = ""
status = "Cannot load databases: \(error.localizedDescription)"
}
}
@MainActor
private func loadTables() async {
guard !database.isEmpty else { availableTables = []; selectedTables = []; return }
loadingTables = true
defer { loadingTables = false }
do {
let rows = try await execute("SHOW FULL TABLES FROM \(quoteIdentifier(database))")
availableTables = rows.rows.compactMap { row in
row.count > 1 && row[1].uppercased() == "BASE TABLE" ? row[0] : nil
}
selectedTables = Set(availableTables)
} catch {
status = "Cannot list tables: \(error.localizedDescription)"
availableTables = []
selectedTables = []
}
}
// The server constructs SQL literals, so NULL, quotes and binary data are
// preserved rather than reconstructed from QueryResult's String values.
@MainActor
private func exportSQL(to url: URL) async {
guard !selectedTables.isEmpty, exportStructure || exportData else { return }
isWorking = true
progress = 0
status = "Reading database structure…"
let tables = availableTables.filter { selectedTables.contains($0) }
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
var handle: FileHandle?
defer {
try? handle?.close()
try? FileManager.default.removeItem(at: tempURL)
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]
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
throw WorkbenchTransferError.message("Cannot create temporary export file")
}
var output = """
-- WorkbenchReplacement SQL Export
-- Database: \(database)
-- Generated: \(Date())
SET NAMES utf8mb4;
"""
handle = try FileHandle(forWritingTo: tempURL)
func write(_ value: String) throws {
try handle?.write(contentsOf: Data(value.utf8))
}
try write("-- WorkbenchReplacement native export\n-- Database: \(database)\n-- Generated: \(Date())\nSET NAMES utf8mb4;\n\n")
let db = quoteIdentifier(database)
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)"
)
let qualified = "\(db).\(quoteIdentifier(table))"
status = "Exporting \(table) (\(index + 1)/\(tables.count))"
try write("\n-- Table \(qualified)\n")
if exportStructure {
let definition = try await execute("SHOW CREATE TABLE \(qualified)")
guard let row = definition.rows.first, row.count > 1 else {
throw WorkbenchTransferError.message("Missing CREATE TABLE for \(table)")
}
if includeDrop { try write("DROP TABLE IF EXISTS \(qualified);\n") }
try write(row[1] + ";\n")
}
output += "\n-- Table: \(table)\n"
output += row[1]
output += ";\n\n"
progress = Double(index + 1) /
Double(max(tables.count, 1))
if exportData {
// Column metadata drives binary-safe SQL literal formatting.
let metadata = try await execute("""
SELECT COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table))
ORDER BY ORDINAL_POSITION
""")
guard !metadata.rows.isEmpty else {
throw WorkbenchTransferError.message("No column metadata for \(table)")
}
let names = metadata.rows.map { quoteIdentifier($0[0]) }.joined(separator: ", ")
let expressions = metadata.rows.map { row -> String in
let col = quoteIdentifier(row[0])
let kind = row[1].lowercased()
// HEX avoids decoding arbitrary bytes as UTF-8.
if ["binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob", "bit"].contains(kind) {
return "IF(\(col) IS NULL, 'NULL', CONCAT('0x', HEX(\(col))))"
}
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
}.joined(separator: ", ")
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET "
var offset = 0
while true {
try Task.checkCancellation()
let batch = try await execute(statement + String(offset))
if batch.rows.isEmpty { break }
for row in batch.rows {
guard let literalRow = row.first else { continue }
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
}
offset += batch.rows.count
status = "Exporting \(table): \(offset) rows"
if batch.rows.count < 250 { break }
}
}
progress = Double(index + 1) / Double(tables.count)
}
try Task.checkCancellation()
try output.write(
to: url,
atomically: true,
encoding: .utf8
)
try handle?.close()
handle = nil
if FileManager.default.fileExists(atPath: url.path) {
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
} else {
try FileManager.default.moveItem(at: tempURL, to: url)
}
progress = 1
status = "Export complete: \(tables.count) table definitions"
status = "Export complete: \(tables.count) tables"
} catch is CancellationError {
status = "Export cancelled"
status = "Export cancelled; no final backup was written"
} catch {
status = "Export failed: \(error.localizedDescription)"
}
}
private func sqlLiteral(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "''") + "'"
}
@MainActor
private func importSQL(from url: URL) async {
isWorking = true