This allows the application to detect whether the backup file has changed or become corrupted. 2. Verify Backup button The Import/Export window now includes Verify Backup…. Select an exported SQL file, and the application calculates its checksum and compares it with the accompanying checksum file. 3. Automatic verification before import When importing an SQL file, WorkbenchReplacement checks for an accompanying .sha256 file. If one exists, its checksum must match before SQL execution begins. Third-party SQL files without checksum files can still be imported. 4. Large-file handling Checksum calculation reads files in 1 MB blocks rather than loading the entire file into memory. This reduces memory usage when verifying large backups.
2470 lines
83 KiB
Swift
2470 lines
83 KiB
Swift
|
|
import SwiftUI
|
|
import AppKit
|
|
import CryptoKit
|
|
|
|
// MARK: - Classic Workbench Colours
|
|
|
|
private enum WB {
|
|
static let toolbar = Color(nsColor: NSColor(
|
|
calibratedWhite: 0.88, alpha: 1
|
|
))
|
|
static let sidebar = Color(nsColor: NSColor(
|
|
calibratedWhite: 0.94, alpha: 1
|
|
))
|
|
static let border = Color(nsColor: NSColor(
|
|
calibratedWhite: 0.76, alpha: 1
|
|
))
|
|
static let header = Color(nsColor: NSColor(
|
|
calibratedWhite: 0.91, alpha: 1
|
|
))
|
|
}
|
|
|
|
// MARK: - Main Window
|
|
|
|
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 = ""
|
|
|
|
@State private var expandedSchemas: Set<String> = []
|
|
@State private var expandedTableFolders: Set<String> = []
|
|
@State private var selectedTableName: String? = nil
|
|
@State private var outputExpanded = true
|
|
|
|
@State private var navigatorMode = 1
|
|
@State private var administrationPage: WorkbenchAdminPage? = nil
|
|
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
mainToolbar
|
|
|
|
Rectangle()
|
|
.fill(WB.border)
|
|
.frame(height: 1)
|
|
|
|
HSplitView {
|
|
navigator
|
|
.frame(
|
|
minWidth: 180,
|
|
idealWidth: 250,
|
|
maxWidth: 420
|
|
)
|
|
|
|
workspace
|
|
.frame(minWidth: 480)
|
|
}
|
|
|
|
statusBar
|
|
}
|
|
.frame(minWidth: 800, minHeight: 520)
|
|
.preferredColorScheme(
|
|
UserDefaults.standard.string(
|
|
forKey: "preferences.appearance"
|
|
) == "dark" ? .dark : .light
|
|
)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.task {
|
|
if model.databases.isEmpty, model.selected != nil {
|
|
model.loadDatabases()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingDatabaseTransfer) {
|
|
WorkbenchDatabaseTransferView(model: model)
|
|
}
|
|
.sheet(isPresented: $showingEditor) {
|
|
ConnectionEditor(
|
|
profile: $editing,
|
|
password: $editingPassword
|
|
) {
|
|
model.save(editing, password: editingPassword)
|
|
showingEditor = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Toolbar
|
|
|
|
private var mainToolbar: some View {
|
|
HStack(spacing: 16) {
|
|
Image(systemName: "externaldrive.connected.to.line.below")
|
|
.font(.system(size: 23))
|
|
.foregroundStyle(.blue)
|
|
|
|
Text("MySQL Workbench")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Rectangle()
|
|
.fill(WB.border)
|
|
.frame(width: 1, height: 30)
|
|
|
|
toolbarButton(
|
|
"New Connection",
|
|
icon: "plus.rectangle.on.rectangle"
|
|
) {
|
|
editing = ConnectionProfile(name: "New Connection")
|
|
editingPassword = ""
|
|
showingEditor = true
|
|
}
|
|
|
|
toolbarButton(
|
|
"Import / Export",
|
|
icon: "arrow.up.arrow.down.square"
|
|
) {
|
|
showingDatabaseTransfer = true
|
|
}
|
|
|
|
toolbarButton(
|
|
"Refresh",
|
|
icon: "arrow.clockwise"
|
|
) {
|
|
model.loadDatabases()
|
|
}
|
|
|
|
toolbarButton(
|
|
"Execute",
|
|
icon: "bolt.fill"
|
|
) {
|
|
model.runSQL()
|
|
}
|
|
.keyboardShortcut(.return, modifiers: [.command])
|
|
|
|
toolbarButton(
|
|
"Format SQL",
|
|
icon: "text.alignleft"
|
|
) {
|
|
model.formatSQL()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(model.selected?.name ?? "No Connection")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
|
|
Text(model.selectedDatabase.isEmpty
|
|
? "No default schema"
|
|
: model.selectedDatabase)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.frame(height: 46)
|
|
.background(WB.toolbar)
|
|
}
|
|
|
|
private func toolbarButton(
|
|
_ title: String,
|
|
icon: String,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
VStack(spacing: 3) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 19))
|
|
.frame(height: 23)
|
|
|
|
Text(title)
|
|
.font(.system(size: 10))
|
|
}
|
|
.foregroundStyle(.primary)
|
|
.frame(minWidth: 65)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(title)
|
|
}
|
|
|
|
// MARK: - Navigator
|
|
|
|
private var navigator: some View {
|
|
VStack(spacing: 0) {
|
|
Picker("Navigator", selection: $navigatorMode) {
|
|
Text("Administration").tag(0)
|
|
Text("Schemas").tag(1)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.padding(8)
|
|
|
|
if navigatorMode == 0 {
|
|
administrationNavigator
|
|
} else {
|
|
schemasNavigator
|
|
}
|
|
|
|
|
|
}
|
|
.background(WB.sidebar)
|
|
}
|
|
|
|
private var schemasNavigator: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("SCHEMAS")
|
|
.font(.system(size: 11, weight: .bold))
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
model.loadDatabases()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 34)
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 2) {
|
|
ForEach(model.databases, id: \.self) { db in
|
|
schemaNode(db)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
Divider()
|
|
|
|
sectionHeader("MYSQL CONNECTIONS")
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(model.profiles) { profile in
|
|
Button {
|
|
model.selectedID = profile.id
|
|
model.loadDatabases()
|
|
} label: {
|
|
Label(
|
|
profile.name,
|
|
systemImage: "server.rack"
|
|
)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: .leading
|
|
)
|
|
.padding(6)
|
|
.background(
|
|
model.selectedID == profile.id
|
|
? Color.blue.opacity(0.14)
|
|
: Color.clear
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button("Edit Connection") {
|
|
edit(profile)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
}
|
|
.frame(height: 125)
|
|
}
|
|
}
|
|
|
|
private var administrationNavigator: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
administrationSection(
|
|
"MANAGEMENT",
|
|
pages: [
|
|
.serverStatus,
|
|
.clientConnections,
|
|
.users,
|
|
.variables
|
|
]
|
|
)
|
|
|
|
administrationSection(
|
|
"PERFORMANCE",
|
|
pages: [
|
|
.dashboard,
|
|
.performanceReports,
|
|
.performanceSchema
|
|
]
|
|
)
|
|
}
|
|
.padding(8)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func administrationSection(
|
|
_ title: String,
|
|
pages: [WorkbenchAdminPage]
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 4)
|
|
|
|
ForEach(pages) { page in
|
|
Button {
|
|
administrationPage = page
|
|
} label: {
|
|
Label(page.rawValue, systemImage: page.icon)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: .leading
|
|
)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 7)
|
|
.background(
|
|
administrationPage == page
|
|
? Color.accentColor.opacity(0.18)
|
|
: Color.clear
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Compact Schema Tree
|
|
|
|
private func schemaNode(_ db: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
|
HStack(spacing: 5) {
|
|
Image(systemName:
|
|
expandedSchemas.contains(db)
|
|
? "chevron.down"
|
|
: "chevron.right"
|
|
)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 13)
|
|
|
|
Image(systemName: "cylinder.split.1x2")
|
|
.foregroundStyle(.blue)
|
|
.frame(width: 17)
|
|
|
|
Text(db)
|
|
.font(.system(size: 12, weight:
|
|
model.selectedDatabase == db
|
|
? .semibold : .regular))
|
|
.lineLimit(1)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 25)
|
|
.padding(.leading, 10)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
toggleSchema(db)
|
|
}
|
|
.onTapGesture(count: 2) {
|
|
model.selectedDatabase = db
|
|
expandedSchemas.insert(db)
|
|
expandedTableFolders.insert(db)
|
|
|
|
if model.schemaTables[db] == nil {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button("Set as Default Schema") {
|
|
model.selectedDatabase = db
|
|
}
|
|
|
|
Button("Refresh Tables") {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
|
|
if expandedSchemas.contains(db) {
|
|
schemaChildren(db)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func toggleSchema(_ db: String) {
|
|
if expandedSchemas.contains(db) {
|
|
expandedSchemas.remove(db)
|
|
} else {
|
|
expandedSchemas.insert(db)
|
|
expandedTableFolders.insert(db)
|
|
|
|
if model.schemaTables[db] == nil {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func schemaChildren(_ db: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
|
// Tables folder
|
|
HStack(spacing: 5) {
|
|
Image(systemName:
|
|
expandedTableFolders.contains(db)
|
|
? "chevron.down"
|
|
: "chevron.right"
|
|
)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 13)
|
|
|
|
Image(systemName: "folder")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 17)
|
|
|
|
Text("Tables")
|
|
.font(.system(size: 12))
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 24)
|
|
.padding(.leading, 28)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if expandedTableFolders.contains(db) {
|
|
expandedTableFolders.remove(db)
|
|
} else {
|
|
expandedTableFolders.insert(db)
|
|
|
|
if model.schemaTables[db] == nil {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tables
|
|
if expandedTableFolders.contains(db) {
|
|
if let tables = model.schemaTables[db] {
|
|
ForEach(tables, id: \.self) { table in
|
|
tableNode(database: db, table: table)
|
|
}
|
|
} else {
|
|
Button("Load Tables") {
|
|
model.loadTables(database: db)
|
|
}
|
|
.font(.system(size: 11))
|
|
.buttonStyle(.borderless)
|
|
.padding(.leading, 66)
|
|
.frame(height: 24)
|
|
}
|
|
}
|
|
|
|
folderNode("Views", indent: 28)
|
|
folderNode("Stored Procedures", indent: 28)
|
|
folderNode("Functions", indent: 28)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func tableNode(database: String, table: String) -> some View {
|
|
let identifier = database + "." + table
|
|
let isSelected = selectedTableName == identifier
|
|
|
|
return HStack(spacing: 5) {
|
|
Image(systemName: "tablecells")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(
|
|
isSelected ? Color.blue : Color.gray
|
|
)
|
|
.frame(width: 17)
|
|
|
|
Text(table)
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 23)
|
|
.padding(.leading, 49)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
isSelected
|
|
? Color.blue.opacity(0.14)
|
|
: Color.clear
|
|
)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
selectedTableName = identifier
|
|
}
|
|
.onTapGesture(count: 2) {
|
|
selectedTableName = identifier
|
|
model.selectTable(
|
|
database: database,
|
|
table: table
|
|
)
|
|
}
|
|
.contextMenu {
|
|
Button("Select First 100 Rows") {
|
|
selectedTableName = identifier
|
|
model.selectTable(
|
|
database: database,
|
|
table: table
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func folderNode(
|
|
_ title: String,
|
|
indent: CGFloat
|
|
) -> some View {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 13)
|
|
|
|
Image(systemName: "folder")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 17)
|
|
|
|
Text(title)
|
|
.font(.system(size: 12))
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 24)
|
|
.padding(.leading, indent)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Workspace
|
|
|
|
private var workspace: some View {
|
|
Group {
|
|
if let page = administrationPage {
|
|
WorkbenchAdministrationView(
|
|
page: page,
|
|
profile: model.selected
|
|
) {
|
|
administrationPage = nil
|
|
navigatorMode = 1
|
|
}
|
|
} else {
|
|
sqlWorkspace
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sqlWorkspace: some View {
|
|
VStack(spacing: 0) {
|
|
queryTabBar
|
|
|
|
Divider()
|
|
|
|
queryToolbar
|
|
|
|
Divider()
|
|
|
|
VSplitView {
|
|
sqlEditor
|
|
.frame(minHeight: 150)
|
|
|
|
bottomWorkspace
|
|
.frame(minHeight: 150)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private var queryTabBar: some View {
|
|
HStack(spacing: 0) {
|
|
ScrollView(.horizontal) {
|
|
HStack(spacing: 2) {
|
|
ForEach(model.queryTabs) { tab in
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
model.activateQuery(tab.id)
|
|
} label: {
|
|
Label(tab.title, systemImage: "doc.text")
|
|
.lineLimit(1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button {
|
|
model.closeQuery(tab.id)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 9))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 10)
|
|
.frame(height: 30)
|
|
.background(
|
|
model.activeQueryID == tab.id
|
|
? Color(nsColor: .textBackgroundColor)
|
|
: Color.clear
|
|
)
|
|
.clipShape(
|
|
UnevenRoundedRectangle(
|
|
topLeadingRadius: 5,
|
|
topTrailingRadius: 5
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
|
|
Button {
|
|
model.newQueryTab()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.padding(.horizontal, 10)
|
|
.help("New Query Tab")
|
|
}
|
|
.background(WB.header)
|
|
}
|
|
|
|
private var queryToolbar: some View {
|
|
HStack(spacing: 14) {
|
|
Button {
|
|
model.runSQL()
|
|
} label: {
|
|
Image(systemName: "bolt.fill")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.help("Execute SQL")
|
|
|
|
Button {
|
|
model.formatSQL()
|
|
} label: {
|
|
Image(systemName: "text.alignleft")
|
|
}
|
|
.help("Format SQL")
|
|
|
|
Divider()
|
|
.frame(height: 17)
|
|
|
|
Text("Default Schema:")
|
|
.font(.system(size: 11))
|
|
|
|
Text(model.selectedDatabase.isEmpty
|
|
? "None"
|
|
: model.selectedDatabase)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
|
|
Spacer()
|
|
|
|
Text("SQL Editor")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 30)
|
|
.background(WB.header)
|
|
}
|
|
|
|
private var sqlEditor: some View {
|
|
HStack(spacing: 0) {
|
|
if UserDefaults.standard.object(
|
|
forKey: "preferences.showLineNumbers"
|
|
) as? Bool ?? true {
|
|
lineNumbers
|
|
|
|
Rectangle()
|
|
.fill(WB.border)
|
|
.frame(width: 1)
|
|
}
|
|
|
|
Rectangle()
|
|
.fill(WB.border)
|
|
.frame(width: 1)
|
|
|
|
SQLTextView(text: $model.sql)
|
|
}
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
}
|
|
|
|
private var lineNumbers: some View {
|
|
ScrollView {
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
ForEach(
|
|
1...max(1, model.sql.components(
|
|
separatedBy: "\n"
|
|
).count),
|
|
id: \.self
|
|
) { number in
|
|
Text("\(number)")
|
|
.frame(height: 19)
|
|
}
|
|
}
|
|
.padding(.horizontal, 9)
|
|
.padding(.top, 8)
|
|
}
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 48)
|
|
.background(WB.header)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
// MARK: - Results and Action Output
|
|
|
|
private var bottomWorkspace: some View {
|
|
VSplitView {
|
|
resultsPanel
|
|
.frame(minHeight: 230)
|
|
|
|
if outputExpanded {
|
|
actionOutput
|
|
.frame(minHeight: 90)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var resultsPanel: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "tablecells")
|
|
Text("Result Grid")
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
Text(model.result.message)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 28)
|
|
.background(WB.header)
|
|
|
|
Divider()
|
|
|
|
if model.result.columns.isEmpty {
|
|
VStack {
|
|
Spacer()
|
|
Text("No query results")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
} else {
|
|
resultGrid
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private var resultGrid: some View {
|
|
NativeResultGrid(
|
|
columns: model.result.columns,
|
|
rows: model.result.rows
|
|
)
|
|
}
|
|
|
|
private var actionOutput: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "list.bullet.rectangle")
|
|
Text("Action Output")
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
outputExpanded = false
|
|
} label: {
|
|
Image(systemName: "chevron.down")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 26)
|
|
.background(WB.header)
|
|
|
|
Divider()
|
|
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName:
|
|
model.status == "Error"
|
|
? "xmark.circle.fill"
|
|
: "checkmark.circle.fill"
|
|
)
|
|
.foregroundStyle(
|
|
model.status == "Error"
|
|
? .red : .green
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(model.status)
|
|
.fontWeight(.semibold)
|
|
|
|
Text(model.result.message)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(12)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Status Bar
|
|
|
|
private var statusBar: some View {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(
|
|
model.status == "Error"
|
|
? Color.red : Color.green
|
|
)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(model.status)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
Text(model.selected?.name ?? "Disconnected")
|
|
|
|
Divider()
|
|
.frame(height: 13)
|
|
|
|
Text(model.selectedDatabase.isEmpty
|
|
? "No Schema"
|
|
: model.selectedDatabase)
|
|
|
|
Divider()
|
|
.frame(height: 13)
|
|
|
|
Text("UTF-8")
|
|
}
|
|
.font(.system(size: 11))
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 22)
|
|
.background(WB.header)
|
|
}
|
|
|
|
private func sectionHeader(_ title: String) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 30)
|
|
.background(WB.header)
|
|
}
|
|
|
|
private func edit(_ profile: ConnectionProfile) {
|
|
editing = profile
|
|
editingPassword = PasswordStore.read(profile.id)
|
|
showingEditor = true
|
|
}
|
|
}
|
|
|
|
// MARK: - Native SQL Text Editor
|
|
|
|
struct SQLTextView: NSViewRepresentable {
|
|
@Binding var text: String
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scroll = NSTextView.scrollableTextView()
|
|
let editor = scroll.documentView as! NSTextView
|
|
|
|
scroll.appearance = NSAppearance(named: .aqua)
|
|
editor.appearance = NSAppearance(named: .aqua)
|
|
editor.backgroundColor = .white
|
|
editor.textColor = .black
|
|
scroll.backgroundColor = .white
|
|
|
|
editor.isEditable = true
|
|
editor.isRichText = false
|
|
editor.allowsUndo = true
|
|
editor.isAutomaticQuoteSubstitutionEnabled = false
|
|
editor.isAutomaticDashSubstitutionEnabled = false
|
|
editor.font = NSFont.monospacedSystemFont(
|
|
ofSize: CGFloat(
|
|
UserDefaults.standard.object(
|
|
forKey: "preferences.editorFontSize"
|
|
) as? Double ?? 13
|
|
),
|
|
weight: .regular
|
|
)
|
|
editor.textContainerInset = NSSize(
|
|
width: 10,
|
|
height: 8
|
|
)
|
|
editor.delegate = context.coordinator
|
|
editor.string = text
|
|
|
|
context.coordinator.highlight(editor)
|
|
|
|
return scroll
|
|
}
|
|
|
|
func updateNSView(
|
|
_ scroll: NSScrollView,
|
|
context: Context
|
|
) {
|
|
guard let editor = scroll.documentView as? NSTextView else {
|
|
return
|
|
}
|
|
|
|
if editor.string != text {
|
|
let selection = editor.selectedRange()
|
|
editor.string = text
|
|
|
|
let safeLocation = min(
|
|
selection.location,
|
|
(text as NSString).length
|
|
)
|
|
|
|
editor.setSelectedRange(
|
|
NSRange(location: safeLocation, length: 0)
|
|
)
|
|
|
|
context.coordinator.highlight(editor)
|
|
}
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: SQLTextView
|
|
|
|
init(_ parent: SQLTextView) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard let editor = notification.object as? NSTextView else {
|
|
return
|
|
}
|
|
|
|
parent.text = editor.string
|
|
highlight(editor)
|
|
}
|
|
|
|
func highlight(_ editor: NSTextView) {
|
|
guard let storage = editor.textStorage else {
|
|
return
|
|
}
|
|
|
|
let text = editor.string as NSString
|
|
let range = NSRange(
|
|
location: 0,
|
|
length: text.length
|
|
)
|
|
|
|
storage.beginEditing()
|
|
|
|
storage.setAttributes([
|
|
.foregroundColor: NSColor.textColor,
|
|
.font: NSFont.monospacedSystemFont(
|
|
ofSize: CGFloat(
|
|
UserDefaults.standard.object(
|
|
forKey: "preferences.editorFontSize"
|
|
) as? Double ?? 13
|
|
),
|
|
weight: .regular
|
|
)
|
|
], range: range)
|
|
|
|
let pattern = """
|
|
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
|
|
"""
|
|
|
|
if let regex = try? NSRegularExpression(
|
|
pattern: pattern,
|
|
options: [.caseInsensitive]
|
|
) {
|
|
for match in regex.matches(
|
|
in: editor.string,
|
|
range: range
|
|
) {
|
|
storage.addAttribute(
|
|
.foregroundColor,
|
|
value: NSColor.systemBlue,
|
|
range: match.range
|
|
)
|
|
}
|
|
}
|
|
|
|
storage.endEditing()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Connection Editor
|
|
|
|
struct ConnectionEditor: View {
|
|
@Binding var profile: ConnectionProfile
|
|
@Binding var password: String
|
|
|
|
let save: () -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Connection Settings")
|
|
.font(.title2.bold())
|
|
|
|
Form {
|
|
TextField(
|
|
"Connection Name",
|
|
text: $profile.name
|
|
)
|
|
|
|
TextField(
|
|
"Hostname",
|
|
text: $profile.host
|
|
)
|
|
|
|
TextField(
|
|
"Port",
|
|
value: $profile.port,
|
|
format: .number
|
|
)
|
|
|
|
TextField(
|
|
"Username",
|
|
text: $profile.username
|
|
)
|
|
|
|
SecureField(
|
|
"Password (macOS Keychain)",
|
|
text: $password
|
|
)
|
|
|
|
TextField(
|
|
"Default Schema",
|
|
text: $profile.database
|
|
)
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
|
|
Button("Save") {
|
|
save()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 520)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - High Performance Native Result Grid
|
|
|
|
struct NativeResultGrid: NSViewRepresentable {
|
|
|
|
let columns: [String]
|
|
let rows: [[String]]
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scroll = NSScrollView()
|
|
scroll.hasVerticalScroller = true
|
|
scroll.hasHorizontalScroller = true
|
|
scroll.autohidesScrollers = true
|
|
scroll.drawsBackground = true
|
|
scroll.backgroundColor = .white
|
|
|
|
let table = NSTableView()
|
|
table.headerView = NSTableHeaderView()
|
|
table.usesAlternatingRowBackgroundColors =
|
|
UserDefaults.standard.object(
|
|
forKey: "preferences.alternatingRows"
|
|
) as? Bool ?? true
|
|
table.rowHeight = CGFloat(
|
|
UserDefaults.standard.object(
|
|
forKey: "preferences.resultRowHeight"
|
|
) as? Double ?? 24
|
|
)
|
|
table.intercellSpacing = NSSize(width: 1, height: 1)
|
|
table.allowsMultipleSelection = true
|
|
table.allowsColumnResizing = true
|
|
table.allowsColumnReordering = true
|
|
table.columnAutoresizingStyle = .noColumnAutoresizing
|
|
table.backgroundColor = .white
|
|
|
|
context.coordinator.columns = columns
|
|
context.coordinator.rows = rows
|
|
|
|
table.delegate = context.coordinator
|
|
table.dataSource = context.coordinator
|
|
|
|
configureColumns(table)
|
|
|
|
scroll.documentView = table
|
|
table.reloadData()
|
|
|
|
return scroll
|
|
}
|
|
|
|
func updateNSView(
|
|
_ scroll: NSScrollView,
|
|
context: Context
|
|
) {
|
|
guard let table = scroll.documentView as? NSTableView else {
|
|
return
|
|
}
|
|
|
|
let oldColumns = context.coordinator.columns
|
|
|
|
context.coordinator.columns = columns
|
|
context.coordinator.rows = rows
|
|
|
|
if oldColumns != columns {
|
|
configureColumns(table)
|
|
}
|
|
|
|
table.reloadData()
|
|
}
|
|
|
|
private func configureColumns(_ table: NSTableView) {
|
|
for column in Array(table.tableColumns) {
|
|
table.removeTableColumn(column)
|
|
}
|
|
|
|
for (index, name) in columns.enumerated() {
|
|
let identifier = NSUserInterfaceItemIdentifier(
|
|
"column_\(index)"
|
|
)
|
|
|
|
let column = NSTableColumn(identifier: identifier)
|
|
column.title = name
|
|
column.width = CGFloat(
|
|
UserDefaults.standard.object(
|
|
forKey: "preferences.resultColumnWidth"
|
|
) as? Double ?? 170
|
|
)
|
|
column.minWidth = 70
|
|
column.maxWidth = 700
|
|
column.resizingMask = .userResizingMask
|
|
|
|
table.addTableColumn(column)
|
|
}
|
|
}
|
|
|
|
final class Coordinator: NSObject,
|
|
NSTableViewDataSource,
|
|
NSTableViewDelegate {
|
|
|
|
var columns: [String] = []
|
|
var rows: [[String]] = []
|
|
|
|
func numberOfRows(in tableView: NSTableView) -> Int {
|
|
rows.count
|
|
}
|
|
|
|
func tableView(
|
|
_ tableView: NSTableView,
|
|
viewFor tableColumn: NSTableColumn?,
|
|
row: Int
|
|
) -> NSView? {
|
|
|
|
guard let tableColumn,
|
|
row >= 0,
|
|
row < rows.count else {
|
|
return nil
|
|
}
|
|
|
|
let identifier = tableColumn.identifier.rawValue
|
|
|
|
guard identifier.hasPrefix("column_"),
|
|
let columnIndex = Int(identifier.dropFirst(7)),
|
|
columnIndex < columns.count else {
|
|
return nil
|
|
}
|
|
|
|
let cellIdentifier =
|
|
NSUserInterfaceItemIdentifier("ResultCell")
|
|
|
|
let cell: NSTableCellView
|
|
|
|
if let reused = tableView.makeView(
|
|
withIdentifier: cellIdentifier,
|
|
owner: nil
|
|
) as? NSTableCellView {
|
|
cell = reused
|
|
} else {
|
|
cell = NSTableCellView()
|
|
cell.identifier = cellIdentifier
|
|
|
|
let field = NSTextField(labelWithString: "")
|
|
field.font = NSFont.monospacedSystemFont(
|
|
ofSize: 12,
|
|
weight: .regular
|
|
)
|
|
field.lineBreakMode = .byTruncatingTail
|
|
field.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
cell.addSubview(field)
|
|
cell.textField = field
|
|
|
|
NSLayoutConstraint.activate([
|
|
field.leadingAnchor.constraint(
|
|
equalTo: cell.leadingAnchor,
|
|
constant: 7
|
|
),
|
|
field.trailingAnchor.constraint(
|
|
equalTo: cell.trailingAnchor,
|
|
constant: -7
|
|
),
|
|
field.centerYAnchor.constraint(
|
|
equalTo: cell.centerYAnchor
|
|
)
|
|
])
|
|
}
|
|
|
|
let values = rows[row]
|
|
|
|
cell.textField?.stringValue =
|
|
columnIndex < values.count
|
|
? values[columnIndex]
|
|
: ""
|
|
|
|
return cell
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - MySQL Administration
|
|
|
|
enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
|
|
case serverStatus = "Server Status"
|
|
case clientConnections = "Client Connections"
|
|
case users = "Users and Privileges"
|
|
case variables = "Status and System Variables"
|
|
case dashboard = "Dashboard"
|
|
case performanceReports = "Performance Reports"
|
|
case performanceSchema = "Performance Schema Setup"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .serverStatus: return "server.rack"
|
|
case .clientConnections: return "person.2"
|
|
case .users: return "person.crop.circle.badge.checkmark"
|
|
case .variables: return "slider.horizontal.3"
|
|
case .dashboard: return "chart.xyaxis.line"
|
|
case .performanceReports: return "chart.bar.xaxis"
|
|
case .performanceSchema: return "gauge.with.dots.needle.67percent"
|
|
}
|
|
}
|
|
|
|
var statement: String {
|
|
switch self {
|
|
case .serverStatus:
|
|
return """
|
|
SHOW GLOBAL STATUS WHERE Variable_name IN (
|
|
'Uptime',
|
|
'Threads_connected',
|
|
'Threads_running',
|
|
'Questions',
|
|
'Queries',
|
|
'Connections',
|
|
'Bytes_received',
|
|
'Bytes_sent'
|
|
)
|
|
"""
|
|
|
|
case .clientConnections:
|
|
return "SHOW FULL PROCESSLIST"
|
|
|
|
case .users:
|
|
return """
|
|
SELECT User, Host, plugin, account_locked
|
|
FROM mysql.user
|
|
ORDER BY User, Host
|
|
"""
|
|
|
|
case .variables:
|
|
return "SHOW GLOBAL VARIABLES"
|
|
|
|
case .dashboard:
|
|
return """
|
|
SHOW GLOBAL STATUS WHERE Variable_name IN (
|
|
'Uptime',
|
|
'Connections',
|
|
'Threads_connected',
|
|
'Threads_running',
|
|
'Questions',
|
|
'Queries',
|
|
'Bytes_received',
|
|
'Bytes_sent',
|
|
'Innodb_buffer_pool_reads',
|
|
'Innodb_buffer_pool_read_requests',
|
|
'Slow_queries'
|
|
)
|
|
"""
|
|
|
|
case .performanceReports:
|
|
return """
|
|
SELECT *
|
|
FROM sys.statement_analysis
|
|
LIMIT 100
|
|
"""
|
|
|
|
case .performanceSchema:
|
|
return """
|
|
SELECT NAME, ENABLED, TIMED
|
|
FROM performance_schema.setup_instruments
|
|
LIMIT 250
|
|
"""
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WorkbenchAdministrationView: View {
|
|
let page: WorkbenchAdminPage
|
|
let profile: ConnectionProfile?
|
|
let close: () -> Void
|
|
|
|
@State private var result = QueryResult()
|
|
@State private var loading = false
|
|
@State private var errorMessage: String?
|
|
@State private var lastUpdated: Date?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
|
|
Divider()
|
|
|
|
if loading && result.columns.isEmpty {
|
|
Spacer()
|
|
|
|
ProgressView("Loading \(page.rawValue)…")
|
|
|
|
Spacer()
|
|
} else if let errorMessage {
|
|
ContentUnavailableView {
|
|
Label(
|
|
"Unable to Load \(page.rawValue)",
|
|
systemImage: "exclamationmark.triangle"
|
|
)
|
|
} description: {
|
|
Text(errorMessage)
|
|
} actions: {
|
|
Button("Retry") {
|
|
Task { await refresh() }
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if result.columns.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Data",
|
|
systemImage: "tablecells",
|
|
description: Text(
|
|
"The server did not return any columns."
|
|
)
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
resultContent
|
|
}
|
|
|
|
Divider()
|
|
|
|
footer
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.task(id: "\(page.id)-\(profile?.id.uuidString ?? "")") {
|
|
await refresh()
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: page.icon)
|
|
.font(.system(size: 27))
|
|
.foregroundStyle(.blue)
|
|
.frame(width: 40)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(profile?.name ?? "No Connection")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(page.rawValue)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
Task { await refresh() }
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(loading)
|
|
|
|
Button {
|
|
close()
|
|
} label: {
|
|
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
|
|
}
|
|
}
|
|
.padding(18)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var resultContent: some View {
|
|
if page == .dashboard || page == .serverStatus {
|
|
ScrollView {
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.adaptive(minimum: 180), spacing: 12)
|
|
],
|
|
spacing: 12
|
|
) {
|
|
ForEach(result.rows.indices, id: \.self) { index in
|
|
let row = result.rows[index]
|
|
|
|
if row.count >= 2 {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(
|
|
row[0]
|
|
.replacingOccurrences(
|
|
of: "_",
|
|
with: " "
|
|
)
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(row[1])
|
|
.font(
|
|
.system(
|
|
size: 23,
|
|
weight: .semibold,
|
|
design: .rounded
|
|
)
|
|
)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.65)
|
|
}
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
minHeight: 90,
|
|
alignment: .leading
|
|
)
|
|
.padding(14)
|
|
.background(
|
|
Color(nsColor: .controlBackgroundColor)
|
|
)
|
|
.clipShape(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(18)
|
|
}
|
|
} else {
|
|
NativeResultGrid(
|
|
columns: result.columns,
|
|
rows: result.rows
|
|
)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
maxHeight: .infinity
|
|
)
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
if let lastUpdated {
|
|
Text(
|
|
"Updated \(lastUpdated.formatted(date: .omitted, time: .standard))"
|
|
)
|
|
} else {
|
|
Text("Not loaded")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(result.rows.count) rows")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 16)
|
|
.frame(height: 30)
|
|
}
|
|
|
|
@MainActor
|
|
private func refresh() async {
|
|
guard let profile else {
|
|
errorMessage = "Select a MySQL connection first."
|
|
return
|
|
}
|
|
|
|
loading = true
|
|
errorMessage = nil
|
|
|
|
defer {
|
|
loading = false
|
|
}
|
|
|
|
do {
|
|
let response = try await NativeMySQLRunner.run(
|
|
profile: profile,
|
|
database: "",
|
|
password: PasswordStore.read(profile.id),
|
|
sql: page.statement
|
|
)
|
|
|
|
result = response
|
|
lastUpdated = Date()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// 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?
|
|
@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 includeViews = true
|
|
@State private var includeTriggers = false
|
|
@State private var includeRoutines = false
|
|
@State private var availableViews: [String] = []
|
|
@State private var selectedViews: Set<String> = []
|
|
@State private var loadingTables = false
|
|
@State private var availableDatabases: [String] = []
|
|
@State private var loadingDatabases = false
|
|
|
|
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)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
GroupBox("MySQL Connection") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
LabeledContent("Connection") {
|
|
Text(selectedProfile?.name ?? "Not selected")
|
|
}
|
|
|
|
Picker("Database", selection: $database) {
|
|
Text("Select Database").tag("")
|
|
|
|
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 {
|
|
GroupBox("Export Options") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Table structure", isOn: $exportStructure)
|
|
Toggle("Table data", isOn: $exportData)
|
|
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
|
|
.disabled(!exportStructure)
|
|
Toggle("Include views (definitions)", isOn: $includeViews)
|
|
.disabled(!exportStructure)
|
|
Toggle("Include triggers", isOn: $includeTriggers)
|
|
.disabled(!exportStructure)
|
|
Toggle("Include stored procedures and functions", isOn: $includeRoutines)
|
|
.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…") }
|
|
if availableTables.isEmpty && !loadingTables {
|
|
Text("No tables found. Select a database or refresh the list.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
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) }
|
|
}
|
|
))
|
|
}
|
|
if includeViews && exportStructure {
|
|
HStack {
|
|
Text("Views (\(selectedViews.count) selected)")
|
|
Spacer()
|
|
Button("All") { selectedViews = Set(availableViews) }
|
|
Button("None") { selectedViews.removeAll() }
|
|
}
|
|
ForEach(availableViews, id: \.self) { view in
|
|
Toggle(view, isOn: Binding(
|
|
get: { selectedViews.contains(view) },
|
|
set: { checked in
|
|
if checked { selectedViews.insert(view) }
|
|
else { selectedViews.remove(view) }
|
|
}
|
|
))
|
|
}
|
|
}
|
|
Text("InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else {
|
|
GroupBox("Import Options") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Import SQL statements into the selected database.")
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("Supports MySQL DELIMITER directives, triggers and stored routines. Import is not transactional; use a verified backup before restoring.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
GroupBox("Progress") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ProgressView(value: progress)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Text(status)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(20)
|
|
|
|
Divider()
|
|
|
|
HStack {
|
|
Button("Close") {
|
|
operationTask?.cancel()
|
|
dismiss()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !isWorking {
|
|
Button("Verify Backup…") {
|
|
chooseBackupToVerify()
|
|
}
|
|
.help("Verify an SQL file against its SHA-256 checksum sidecar")
|
|
}
|
|
|
|
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 ||
|
|
(selectedMode == 0 && ((!exportStructure && !exportData) || (selectedTables.isEmpty && !(exportStructure && includeViews && !selectedViews.isEmpty))))
|
|
)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.frame(minWidth: 650, idealWidth: 780, minHeight: 500)
|
|
.onAppear {
|
|
if database.isEmpty {
|
|
database = model.selectedDatabase.isEmpty
|
|
? (model.databases.first ?? "")
|
|
: model.selectedDatabase
|
|
}
|
|
}
|
|
.task(id: selectedProfile?.id) {
|
|
await loadTransferDatabases()
|
|
}
|
|
.task(id: database) {
|
|
availableTables = []
|
|
selectedTables = []
|
|
availableViews = []
|
|
selectedViews = []
|
|
if !database.isEmpty { await loadTables() }
|
|
}
|
|
.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 chooseBackupToVerify() {
|
|
let panel = NSOpenPanel()
|
|
panel.title = "Verify SQL Backup"
|
|
panel.canChooseDirectories = false
|
|
panel.canChooseFiles = true
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
operationTask = Task {
|
|
isWorking = true
|
|
progress = 0
|
|
defer { isWorking = false; operationTask = nil }
|
|
do {
|
|
try await Task.detached(priority: .utility) {
|
|
try WorkbenchBackupIntegrity.verify(url)
|
|
}.value
|
|
progress = 1
|
|
status = "Verified SHA-256: \(url.lastPathComponent)"
|
|
} catch {
|
|
status = "Verification failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Export operations use a distinct authenticated session so their snapshot
|
|
// cannot be affected by SQL editor queries or administration refreshes.
|
|
@State private var exportSession: ConnectionProfile?
|
|
@State private var exportPassword = ""
|
|
|
|
@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: exportSession ?? profile,
|
|
database: statement == "SHOW DATABASES" ? "" : database,
|
|
password: exportSession == nil ? PasswordStore.read(profile.id) : exportPassword,
|
|
sql: statement
|
|
)
|
|
}
|
|
|
|
private func quoteIdentifier(_ identifier: String) -> String {
|
|
"`" + identifier.replacingOccurrences(
|
|
of: "`",
|
|
with: "``"
|
|
) + "`"
|
|
}
|
|
|
|
@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 = []
|
|
availableViews = []; selectedViews = []
|
|
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)
|
|
availableViews = rows.rows.compactMap { row in
|
|
row.count > 1 && row[1].uppercased() == "VIEW" ? row[0] : nil
|
|
}
|
|
selectedViews = Set(availableViews)
|
|
} catch {
|
|
status = "Cannot list tables: \(error.localizedDescription)"
|
|
availableTables = []
|
|
selectedTables = []
|
|
availableViews = []
|
|
selectedViews = []
|
|
}
|
|
}
|
|
|
|
// 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 exportStructure || exportData else { return }
|
|
let views = exportStructure && includeViews
|
|
? availableViews.filter { selectedViews.contains($0) }
|
|
: []
|
|
guard !selectedTables.isEmpty || !views.isEmpty || (exportStructure && (includeTriggers || includeRoutines)) else { return }
|
|
guard let activeProfile = selectedProfile else {
|
|
status = "Export failed: No connection selected"
|
|
return
|
|
}
|
|
// Clone the connection profile with a fresh identity; the session pool
|
|
// then creates a separate socket, independent of the editor session.
|
|
var dedicatedProfile = activeProfile
|
|
dedicatedProfile.id = UUID()
|
|
exportPassword = PasswordStore.read(activeProfile.id)
|
|
exportSession = dedicatedProfile
|
|
isWorking = true
|
|
progress = 0
|
|
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)
|
|
NativeMySQLRunner.disconnect(profileID: dedicatedProfile.id)
|
|
exportSession = nil
|
|
exportPassword = ""
|
|
isWorking = false
|
|
operationTask = nil
|
|
}
|
|
do {
|
|
// A consistent InnoDB snapshot requires every SELECT to execute on
|
|
// this SAME connection. MyISAM/MEMORY cannot participate.
|
|
for table in tables where exportData {
|
|
let engine = try await execute("""
|
|
SELECT ENGINE FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
|
AND TABLE_NAME = \(sqlLiteral(table))
|
|
AND TABLE_TYPE = 'BASE TABLE'
|
|
""")
|
|
guard let name = engine.rows.first?.first,
|
|
name.uppercased() == "INNODB" else {
|
|
throw WorkbenchTransferError.message(
|
|
"Consistent data export requires InnoDB. Table \(table) uses \(engine.rows.first?.first ?? "an unknown engine"). Export its structure only."
|
|
)
|
|
}
|
|
}
|
|
_ = try await execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
|
_ = try await execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY")
|
|
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
|
throw WorkbenchTransferError.message("Cannot create temporary export file")
|
|
}
|
|
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()
|
|
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")
|
|
}
|
|
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: ", ")
|
|
// A single streamed SELECT keeps every row in the same
|
|
// InnoDB read view and works without a primary/unique key.
|
|
// No OFFSET, no 100,000-row cap, no full result buffering.
|
|
let indexes = try await execute("""
|
|
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
|
FROM information_schema.STATISTICS
|
|
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
|
AND TABLE_NAME = \(sqlLiteral(table))
|
|
AND NON_UNIQUE = 0
|
|
ORDER BY CASE WHEN INDEX_NAME = 'PRIMARY' THEN 0 ELSE 1 END,
|
|
INDEX_NAME, SEQ_IN_INDEX
|
|
""")
|
|
let nullable = try await execute("""
|
|
SELECT COLUMN_NAME
|
|
FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
|
AND TABLE_NAME = \(sqlLiteral(table))
|
|
AND IS_NULLABLE = 'YES'
|
|
""")
|
|
let nullableColumns = Set(nullable.rows.compactMap { $0.first })
|
|
var grouped: [String: [String]] = [:]
|
|
var indexOrder: [String] = []
|
|
for indexRow in indexes.rows where indexRow.count >= 2 {
|
|
let name = indexRow[0]
|
|
if grouped[name] == nil {
|
|
grouped[name] = []
|
|
indexOrder.append(name)
|
|
}
|
|
grouped[name, default: []].append(indexRow[1])
|
|
}
|
|
let usableIndex = indexOrder.first { name in
|
|
guard let columns = grouped[name], !columns.isEmpty else { return false }
|
|
return columns.allSatisfy { !nullableColumns.contains($0) }
|
|
}
|
|
let orderBy = usableIndex.flatMap { grouped[$0] }?
|
|
.map(quoteIdentifier).joined(separator: ", ")
|
|
let countResult = try await execute("SELECT COUNT(*) FROM \(qualified)")
|
|
guard let countText = countResult.rows.first?.first,
|
|
let expectedCount = Int64(countText) else {
|
|
throw WorkbenchTransferError.message("Cannot count rows in \(table)")
|
|
}
|
|
try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n")
|
|
let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" +
|
|
(orderBy.map { " ORDER BY " + $0 } ?? "")
|
|
let outputHandle = handle!
|
|
let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8)
|
|
let suffix = Data(";\n".utf8)
|
|
let written = try await NativeMySQLRunner.stream(
|
|
profile: dedicatedProfile,
|
|
database: database,
|
|
password: exportPassword,
|
|
sql: select
|
|
) { literal in
|
|
try outputHandle.write(contentsOf: prefix)
|
|
try outputHandle.write(contentsOf: Data(literal.utf8))
|
|
try outputHandle.write(contentsOf: suffix)
|
|
}
|
|
guard written == expectedCount else {
|
|
throw WorkbenchTransferError.message(
|
|
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
|
|
)
|
|
}
|
|
status = "Exported \(table): \(written) rows"
|
|
}
|
|
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
|
|
}
|
|
// Views are exported after base tables so dependencies exist on restore.
|
|
// The server's SHOW CREATE VIEW response may contain a DEFINER clause;
|
|
// importing under a different MySQL account can require extra privileges.
|
|
for (index, view) in views.enumerated() {
|
|
try Task.checkCancellation()
|
|
let qualified = "\(db).\(quoteIdentifier(view))"
|
|
status = "Exporting view \(view)"
|
|
let definition = try await execute("SHOW CREATE VIEW \(qualified)")
|
|
guard let row = definition.rows.first, row.count > 1 else {
|
|
throw WorkbenchTransferError.message("Missing CREATE VIEW for \(view)")
|
|
}
|
|
try write("\n-- View \(qualified)\n")
|
|
if includeDrop { try write("DROP VIEW IF EXISTS \(qualified);\n") }
|
|
try write(row[1] + ";\n")
|
|
progress = Double(tables.count + index + 1) / Double(max(tables.count + views.count, 1))
|
|
}
|
|
// Routines and triggers use client-side delimiters. The importer
|
|
// strips these directives and sends each definition as one query.
|
|
if exportStructure && includeRoutines {
|
|
let routines = try await execute("""
|
|
SELECT ROUTINE_NAME, ROUTINE_TYPE
|
|
FROM information_schema.ROUTINES
|
|
WHERE ROUTINE_SCHEMA = \(sqlLiteral(database))
|
|
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
|
""")
|
|
for routine in routines.rows {
|
|
try Task.checkCancellation()
|
|
guard routine.count >= 2 else { continue }
|
|
let name = routine[0]
|
|
let kind = routine[1].uppercased()
|
|
guard kind == "PROCEDURE" || kind == "FUNCTION" else { continue }
|
|
status = "Exporting \(kind.lowercased()) \(name)"
|
|
let qualified = "\(db).\(quoteIdentifier(name))"
|
|
let definition = try await execute("SHOW CREATE \(kind) \(qualified)")
|
|
// SHOW CREATE PROCEDURE/FUNCTION: Name, sql_mode, Create ..., ...
|
|
guard let row = definition.rows.first, row.count > 2,
|
|
!row[2].isEmpty else {
|
|
throw WorkbenchTransferError.message("Missing CREATE \(kind) for \(name)")
|
|
}
|
|
try write("\n-- \(kind) \(qualified)\n")
|
|
try write("DELIMITER $$\n")
|
|
if includeDrop { try write("DROP \(kind) IF EXISTS \(qualified)$$\n") }
|
|
try write(row[2] + "$$\nDELIMITER ;\n")
|
|
}
|
|
}
|
|
if exportStructure && includeTriggers {
|
|
let triggers = try await execute("""
|
|
SELECT TRIGGER_NAME
|
|
FROM information_schema.TRIGGERS
|
|
WHERE TRIGGER_SCHEMA = \(sqlLiteral(database))
|
|
ORDER BY TRIGGER_NAME
|
|
""")
|
|
for trigger in triggers.rows {
|
|
try Task.checkCancellation()
|
|
guard let name = trigger.first else { continue }
|
|
status = "Exporting trigger \(name)"
|
|
let qualified = "\(db).\(quoteIdentifier(name))"
|
|
let definition = try await execute("SHOW CREATE TRIGGER \(qualified)")
|
|
// SHOW CREATE TRIGGER: Trigger, sql_mode, SQL Original Statement, ...
|
|
guard let row = definition.rows.first, row.count > 2,
|
|
!row[2].isEmpty else {
|
|
throw WorkbenchTransferError.message("Missing CREATE TRIGGER for \(name)")
|
|
}
|
|
try write("\n-- Trigger \(qualified)\n")
|
|
try write("DELIMITER $$\n")
|
|
if includeDrop { try write("DROP TRIGGER IF EXISTS \(qualified)$$\n") }
|
|
try write(row[2] + "$$\nDELIMITER ;\n")
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
// Release the read view before finalizing the export file.
|
|
_ = try await execute("COMMIT")
|
|
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)
|
|
}
|
|
// The SQL file is complete before creating its checksum sidecar.
|
|
// Sidecar failure is reported; it does not delete a completed export.
|
|
do {
|
|
try WorkbenchBackupIntegrity.createSidecar(for: url)
|
|
progress = 1
|
|
status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views"
|
|
} catch {
|
|
progress = 1
|
|
status = "SQL export complete, but checksum creation failed: \(error.localizedDescription)"
|
|
}
|
|
} catch is CancellationError {
|
|
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
|
|
progress = 0
|
|
status = "Reading SQL file…"
|
|
|
|
defer {
|
|
isWorking = false
|
|
operationTask = nil
|
|
}
|
|
|
|
do {
|
|
// Refuse a corrupted backup when a checksum sidecar is present.
|
|
// Third-party SQL files without a sidecar remain importable.
|
|
if WorkbenchBackupIntegrity.hasSidecar(for: url) {
|
|
status = "Verifying SQL backup checksum…"
|
|
try await Task.detached(priority: .utility) {
|
|
try WorkbenchBackupIntegrity.verify(url)
|
|
}.value
|
|
}
|
|
try Task.checkCancellation()
|
|
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
|
|
// Supports MySQL DELIMITER directives, quoted text, executable /*! */
|
|
// comments, and routine bodies. Parsing does not execute SQL.
|
|
|
|
enum WorkbenchSQLImportParser {
|
|
static func parse(_ sql: String) throws -> [String] {
|
|
let chars = Array(sql)
|
|
var statements: [String] = []
|
|
var current = ""
|
|
var delimiter = ";"
|
|
var quote: Character? = nil
|
|
var lineComment = false
|
|
var blockComment = false
|
|
var escaped = false
|
|
var lineStart = true
|
|
var i = 0
|
|
|
|
func matches(_ value: String, at index: Int) -> Bool {
|
|
let pattern = Array(value)
|
|
guard index + pattern.count <= chars.count else { return false }
|
|
return Array(chars[index..<(index + pattern.count)]) == pattern
|
|
}
|
|
|
|
func emit() {
|
|
let value = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { statements.append(value) }
|
|
current = ""
|
|
}
|
|
|
|
while i < chars.count {
|
|
let ch = chars[i]
|
|
let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil
|
|
|
|
// DELIMITER is a client-side directive, never sent to MySQL.
|
|
if lineStart && quote == nil && !lineComment && !blockComment {
|
|
var j = i
|
|
while j < chars.count && (chars[j] == " " || chars[j] == "\t") { j += 1 }
|
|
if j + 9 <= chars.count && String(chars[j..<min(j + 9, chars.count)]).uppercased() == "DELIMITER" &&
|
|
(j + 9 == chars.count || chars[j + 9].isWhitespace) {
|
|
let pending = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !pending.isEmpty {
|
|
throw WorkbenchTransferError.message("DELIMITER directive encountered inside an unfinished statement")
|
|
}
|
|
var k = j + 9
|
|
while k < chars.count && chars[k] != "\n" && chars[k] != "\r" { k += 1 }
|
|
let token = String(chars[(j + 9)..<k]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty && !token.contains(where: { $0.isWhitespace }) else {
|
|
throw WorkbenchTransferError.message("Invalid DELIMITER directive")
|
|
}
|
|
delimiter = token
|
|
i = k
|
|
lineStart = true
|
|
continue
|
|
}
|
|
}
|
|
|
|
if lineComment {
|
|
if ch == "\n" || ch == "\r" {
|
|
lineComment = false
|
|
current.append("\n")
|
|
lineStart = true
|
|
}
|
|
i += 1
|
|
continue
|
|
}
|
|
if blockComment {
|
|
// Preserve MySQL executable and optimizer-hint comments in SQL.
|
|
if ch == "*" && next == "/" {
|
|
if current.hasSuffix("*") { /* no-op */ }
|
|
current.append("*/")
|
|
blockComment = false
|
|
i += 2
|
|
} else {
|
|
current.append(ch)
|
|
i += 1
|
|
}
|
|
continue
|
|
}
|
|
if let active = quote {
|
|
current.append(ch)
|
|
if escaped { escaped = false }
|
|
else if ch == "\\" && active != "`" { escaped = true }
|
|
else if ch == active {
|
|
if next == active {
|
|
current.append(next!)
|
|
i += 1
|
|
} else { quote = nil }
|
|
}
|
|
i += 1
|
|
continue
|
|
}
|
|
if ch == "'" || ch == "\"" || ch == "`" {
|
|
quote = ch
|
|
current.append(ch)
|
|
lineStart = false
|
|
i += 1
|
|
continue
|
|
}
|
|
if ch == "#" || (ch == "-" && next == "-" &&
|
|
(i + 2 == chars.count || chars[i + 2].isWhitespace)) {
|
|
lineComment = true
|
|
i += ch == "#" ? 1 : 2
|
|
continue
|
|
}
|
|
if ch == "/" && next == "*" {
|
|
// Keep executable comments; ordinary comments may also contain
|
|
// SQL hints, so preserve all block comments verbatim.
|
|
current.append("/*")
|
|
blockComment = true
|
|
i += 2
|
|
continue
|
|
}
|
|
if matches(delimiter, at: i) {
|
|
emit()
|
|
i += delimiter.count
|
|
lineStart = false
|
|
continue
|
|
}
|
|
current.append(ch)
|
|
if ch == "\n" || ch == "\r" { lineStart = true }
|
|
else if !ch.isWhitespace { lineStart = false }
|
|
i += 1
|
|
}
|
|
guard quote == nil && !blockComment else {
|
|
throw WorkbenchTransferError.message("SQL file ends inside a quoted string or block comment")
|
|
}
|
|
guard delimiter == ";" else {
|
|
throw WorkbenchTransferError.message("SQL file ends without resetting DELIMITER to ;")
|
|
}
|
|
emit()
|
|
return statements
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Backup integrity (SHA-256)
|
|
// A checksum detects accidental changes to a completed SQL dump. It does NOT
|
|
// establish transaction consistency, completeness or authenticity.
|
|
private enum WorkbenchBackupIntegrity {
|
|
static func sidecar(for sqlURL: URL) -> URL {
|
|
URL(fileURLWithPath: sqlURL.path + ".sha256")
|
|
}
|
|
|
|
static func hasSidecar(for sqlURL: URL) -> Bool {
|
|
FileManager.default.fileExists(atPath: sidecar(for: sqlURL).path)
|
|
}
|
|
|
|
static func digest(_ url: URL) throws -> String {
|
|
let file = try FileHandle(forReadingFrom: url)
|
|
defer { try? file.close() }
|
|
var hasher = SHA256()
|
|
while true {
|
|
let chunk = try file.read(upToCount: 1024 * 1024) ?? Data()
|
|
if chunk.isEmpty { break }
|
|
hasher.update(data: chunk)
|
|
}
|
|
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
static func createSidecar(for sqlURL: URL) throws {
|
|
let value = try digest(sqlURL)
|
|
// Standard sha256sum-compatible format, with filename rather than path.
|
|
let line = value + " " + sqlURL.lastPathComponent + "\n"
|
|
try line.write(to: sidecar(for: sqlURL), atomically: true, encoding: .utf8)
|
|
}
|
|
|
|
static func verify(_ sqlURL: URL) throws {
|
|
let checksumURL = sidecar(for: sqlURL)
|
|
guard FileManager.default.fileExists(atPath: checksumURL.path) else {
|
|
throw WorkbenchTransferError.message("No checksum file found: \(checksumURL.lastPathComponent)")
|
|
}
|
|
let contents = try String(contentsOf: checksumURL, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expected = String(contents.prefix(64)).lowercased()
|
|
guard expected.count == 64,
|
|
expected.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else {
|
|
throw WorkbenchTransferError.message("Invalid SHA-256 checksum file")
|
|
}
|
|
let actual = try digest(sqlURL)
|
|
guard actual == expected else {
|
|
throw WorkbenchTransferError.message("SHA-256 mismatch: backup contents have changed")
|
|
}
|
|
}
|
|
}
|