import Foundation import AppKit import Security struct ConnectionProfile: Identifiable, Codable, Hashable { var id = UUID() var name = "Local MySQL" var host = "127.0.0.1" var port = 3306 var username = "root" var database = "" } struct QueryResult { var columns: [String] = [] var rows: [[String]] = [] var message = "" } enum PasswordStore { static func read(_ id: UUID) -> String { let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "WorkbenchReplacement.MySQL", kSecAttrAccount as String: id.uuidString, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne] var value: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess, let data = value as? Data else { return "" } return String(data: data, encoding: .utf8) ?? "" } static func save(_ password: String, id: UUID) { let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "WorkbenchReplacement.MySQL", kSecAttrAccount as String: id.uuidString] SecItemDelete(query as CFDictionary) guard !password.isEmpty else { return } var attributes = query attributes[kSecValueData as String] = Data(password.utf8) SecItemAdd(attributes as CFDictionary, nil) } } struct WorkbenchQueryTab: Identifiable { let id: UUID var title: String var sql: String var result: QueryResult var fileURL: URL? init( id: UUID = UUID(), title: String, sql: String = "", result: QueryResult = QueryResult(), fileURL: URL? = nil ) { self.id = id self.title = title self.sql = sql self.result = result self.fileURL = fileURL } } struct SQLHistoryEntry: Identifiable, Codable { var id = UUID() var executedAt = Date() var connectionName: String var database: String var sql: String var succeeded: Bool var detail: String } struct SavedSQLQuery: Identifiable, Codable { var id = UUID() var name: String var sql: String } @MainActor final class DatabaseModel: ObservableObject { @Published var profiles: [ConnectionProfile] = [] @Published private(set) var queryHistory: [SQLHistoryEntry] = [] @Published private(set) var savedQueries: [SavedSQLQuery] = [] private let historyKey = "WorkbenchReplacement.queryHistory.v1" private let savedQueriesKey = "WorkbenchReplacement.savedQueries.v1" private let maxHistoryEntries = 200 private let recentScriptsKey = "WorkbenchReplacement.recentSQLScripts.v1" @Published private(set) var recentSQLScripts: [String] = [] private func recordRecentSQLScript(_ url: URL) { let path = url.standardizedFileURL.path recentSQLScripts.removeAll { $0 == path } recentSQLScripts.insert(path, at: 0) recentSQLScripts = Array(recentSQLScripts.prefix(12)) UserDefaults.standard.set(recentSQLScripts, forKey: recentScriptsKey) } func clearRecentSQLScripts() { recentSQLScripts = [] UserDefaults.standard.removeObject(forKey: recentScriptsKey) } func openRecentSQLScript(at path: String) { let url = URL(fileURLWithPath: path) do { let contents = try String(contentsOf: url, encoding: .utf8) saveActiveQuery() let tab = WorkbenchQueryTab(title: url.lastPathComponent, sql: contents, fileURL: url) queryTabs.append(tab) activateQuery(tab.id) recordRecentSQLScript(url) status = "Opened \(url.lastPathComponent)" NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil) } catch { status = "Could not open \(url.lastPathComponent): \(error.localizedDescription)" } } func saveCurrentQuery(named name: String) { let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !clean.isEmpty, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } savedQueries.insert(SavedSQLQuery(name: clean, sql: sql), at: 0) persistSavedQueries() } func openSavedQuery(_ entry: SavedSQLQuery) { newQueryTab() sql = entry.sql if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) { queryTabs[index].title = entry.name queryTabs[index].sql = entry.sql } } func deleteSavedQuery(_ id: UUID) { savedQueries.removeAll { $0.id == id } persistSavedQueries() } func clearQueryHistory() { queryHistory.removeAll() UserDefaults.standard.removeObject(forKey: historyKey) } private func persistSavedQueries() { if let data = try? JSONEncoder().encode(savedQueries) { UserDefaults.standard.set(data, forKey: savedQueriesKey) } } private func recordQuery(_ statement: String, profile: ConnectionProfile, database: String, succeeded: Bool, detail: String) { queryHistory.insert(SQLHistoryEntry(connectionName: profile.name, database: database, sql: statement, succeeded: succeeded, detail: detail), at: 0) if queryHistory.count > maxHistoryEntries { queryHistory.removeLast(queryHistory.count - maxHistoryEntries) } if let data = try? JSONEncoder().encode(queryHistory) { UserDefaults.standard.set(data, forKey: historyKey) } } @Published var selectedID: UUID? @Published var queryTabs: [WorkbenchQueryTab] = [ WorkbenchQueryTab( title: "Query 1", sql: "SELECT VERSION() AS server_version;" ) ] @Published var activeQueryID: UUID? @Published var sql = "SELECT VERSION() AS server_version;" @Published var result = QueryResult() @Published var databases: [String] = [] @Published var schemaTables: [String: [String]] = [:] @Published var schemaViews: [String: [String]] = [:] @Published var schemaProcedures: [String: [String]] = [:] @Published var schemaFunctions: [String: [String]] = [:] @Published var schemaColumns: [String: [String]] = [:] @Published var selectedDatabase = "" @Published var queryDurationText = "" @Published var isRunning = false @Published var status = "Not connected" private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1" init() { recentSQLScripts = UserDefaults.standard.stringArray(forKey: recentScriptsKey) ?? [] if let data = UserDefaults.standard.data(forKey: historyKey), let entries = try? JSONDecoder().decode([SQLHistoryEntry].self, from: data) { queryHistory = Array(entries.prefix(maxHistoryEntries)) } if let data = UserDefaults.standard.data(forKey: savedQueriesKey), let entries = try? JSONDecoder().decode([SavedSQLQuery].self, from: data) { savedQueries = entries } if let data = UserDefaults.standard.data(forKey: storageKey), let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded } if profiles.isEmpty { profiles = [ConnectionProfile()] } selectedID = profiles.first?.id activeQueryID = queryTabs.first?.id } func newQueryTab() { saveActiveQuery() let tab = WorkbenchQueryTab( title: "Query \(queryTabs.count + 1)" ) queryTabs.append(tab) activeQueryID = tab.id sql = tab.sql result = tab.result } func saveActiveQuery() { guard let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) else { return } queryTabs[index].sql = sql queryTabs[index].result = result } func activateQuery(_ id: UUID) { saveActiveQuery() guard let tab = queryTabs.first(where: { $0.id == id }) else { return } activeQueryID = tab.id sql = tab.sql result = tab.result } func closeQuery(_ id: UUID) { saveActiveQuery() queryTabs.removeAll { $0.id == id } if queryTabs.isEmpty { let tab = WorkbenchQueryTab(title: "Query 1") queryTabs.append(tab) } if activeQueryID == id { activeQueryID = nil activateQuery(queryTabs[0].id) } } func openSQLScript() { let panel = NSOpenPanel() panel.allowedContentTypes = [.plainText] panel.allowsOtherFileTypes = true panel.canChooseDirectories = false guard panel.runModal() == .OK, let url = panel.url, let contents = try? String(contentsOf: url, encoding: .utf8) else { return } saveActiveQuery() let tab = WorkbenchQueryTab( title: url.lastPathComponent, sql: contents, fileURL: url ) queryTabs.append(tab) activateQuery(tab.id) recordRecentSQLScript(url) NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil) status = "Opened \\(url.lastPathComponent)" } func saveSQLScript(asNewFile: Bool = false) { saveActiveQuery() guard let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) else { return } var destination = asNewFile ? nil : queryTabs[index].fileURL if destination == nil { let panel = NSSavePanel() panel.nameFieldStringValue = "query.sql" panel.canCreateDirectories = true guard panel.runModal() == .OK else { return } destination = panel.url } guard let url = destination else { return } do { try sql.write(to: url, atomically: true, encoding: .utf8) queryTabs[index].fileURL = url queryTabs[index].title = url.lastPathComponent recordRecentSQLScript(url) status = "Saved \\(url.lastPathComponent)" } catch { status = "Save failed: \\(error.localizedDescription)" } } var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } } func save(_ profile: ConnectionProfile, password: String) { if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile } else { profiles.append(profile) } PasswordStore.save(password, id: profile.id) selectedID = profile.id persist() } private func persist() { if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) } } func formatSQL() { sql = SQLFormatter.uppercaseKeywords(sql) } func loadTables(database: String) { guard let profile = selected else { status = "Select a MySQL connection" return } let password = PasswordStore.read(profile.id) let escaped = database.replacingOccurrences( of: "`", with: "``" ) status = "Loading tables from \(database)…" Task { do { let parsed = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SHOW FULL TABLES FROM `\(escaped)`" ) var tables: [String] = [] var views: [String] = [] for row in parsed.rows { guard let name = row.first else { continue } if row.count > 1 && row[1] == "VIEW" { views.append(name) } else { tables.append(name) } } schemaTables[database] = tables schemaViews[database] = views // Routine metadata is separate from SHOW FULL TABLES. // Failure (e.g. insufficient INFORMATION_SCHEMA privileges) // must not prevent browsing tables and views. do { let quotedSchema = database.replacingOccurrences(of: "'", with: "''") let routineResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '\(quotedSchema)' ORDER BY ROUTINE_NAME" ) schemaProcedures[database] = routineResult.rows.compactMap { row in row.count >= 2 && row[1].uppercased() == "PROCEDURE" ? row[0] : nil } schemaFunctions[database] = routineResult.rows.compactMap { row in row.count >= 2 && row[1].uppercased() == "FUNCTION" ? row[0] : nil } } catch { schemaProcedures[database] = [] schemaFunctions[database] = [] } // Cache column names for SQL completion. The lookup is read-only, // scoped to the selected schema and uses the current connection. // Failures (e.g. metadata permissions) do not prevent table browsing. do { let quotedSchema = database.replacingOccurrences(of: "'", with: "''") let columnResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(quotedSchema)' ORDER BY TABLE_NAME, ORDINAL_POSITION" ) var names = Set() for row in columnResult.rows where row.count >= 2 { names.insert(row[1]) names.insert("\(row[0]).\(row[1])") } schemaColumns[database] = names.sorted() } catch { schemaColumns[database] = [] } status = "Loaded \(tables.count) tables and \(views.count) views from \(database)" } catch { status = "Unable to load tables: \(error.localizedDescription)" result = QueryResult(message: error.localizedDescription) } } } func selectTable(database: String, table: String) { let db = database.replacingOccurrences( of: "`", with: "``" ) let tbl = table.replacingOccurrences( of: "`", with: "``" ) selectedDatabase = database let configuredLimit = UserDefaults.standard.integer( forKey: "preferences.defaultRowLimit" ) let rowLimit = configuredLimit > 0 ? min(configuredLimit, 100000) : 500 sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT \(rowLimit);" runSQL() } func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) } func runSQL() { if UserDefaults.standard.object( forKey: "preferences.uppercaseKeywords" ) as? Bool ?? true { formatSQL() } execute(sql, listingDatabases: false) } func runSQL(statement: String) { guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } execute(statement, listingDatabases: false) } private func execute(_ statement: String, listingDatabases: Bool) { guard let profile = selected else { status = "Select a connection"; return } guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } isRunning = true if !listingDatabases { queryDurationText = "Running…" } let queryStarted = Date() status = "Connecting…" let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase) let password = PasswordStore.read(profile.id) Task { do { let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement) if listingDatabases { databases = parsed.rows.compactMap { $0.first } status = "Connected to \(profile.name)" } else { result = parsed if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) { queryTabs[index].sql = sql queryTabs[index].result = parsed } status = "Query completed (\(parsed.rows.count) rows)" recordQuery(statement, profile: profile, database: activeDB, succeeded: true, detail: status) } } catch { status = "Error" result = QueryResult(message: error.localizedDescription) if !listingDatabases { recordQuery(statement, profile: profile, database: activeDB, succeeded: false, detail: error.localizedDescription) } } if !listingDatabases { queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted)) } isRunning = false } } } // MARK: - SQL Keyword Formatter enum SQLFormatter { private static let keywords: Set = [ "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", "DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN", "BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX", "UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS", "CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN", "THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT", "SUM", "AVG", "MIN", "MAX", "HAVING", "WITH", "RECURSIVE", "TRUNCATE", "REPLACE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "GRANT", "REVOKE", "DISTINCT", "CURRENT_TIMESTAMP" ] static func uppercaseKeywords(_ sql: String) -> String { let chars = Array(sql) var output = "" var index = 0 enum State { case normal case singleQuote case doubleQuote case backtick case lineComment case blockComment } var state = State.normal while index < chars.count { let ch = chars[index] let next: Character? = index + 1 < chars.count ? chars[index + 1] : nil switch state { case .normal: if ch == "'" { state = .singleQuote output.append(ch) index += 1 } else if ch == "\"" { state = .doubleQuote output.append(ch) index += 1 } else if ch == "`" { state = .backtick output.append(ch) index += 1 } else if ch == "-" && next == "-" && (index + 2 == chars.count || chars[index + 2].isWhitespace) { state = .lineComment output += "--" index += 2 } else if ch == "#" { state = .lineComment output.append(ch) index += 1 } else if ch == "/" && next == "*" { state = .blockComment output += "/*" index += 2 } else if ch.isLetter || ch == "_" { let start = index while index < chars.count && (chars[index].isLetter || chars[index].isNumber || chars[index] == "_" || chars[index] == "$") { index += 1 } let word = String(chars[start..