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 } } // Phase 68: updating a saved query keeps its stable identity and list position. func updateSavedQuery(_ id: UUID, name: String, sql updatedSQL: String) { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty, !updatedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let index = savedQueries.firstIndex(where: { $0.id == id }) else { return } savedQueries[index].name = trimmedName savedQueries[index].sql = updatedSQL persistSavedQueries() } func replaceSavedQueryWithEditor(_ id: UUID) { guard let index = savedQueries.firstIndex(where: { $0.id == id }), !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } savedQueries[index].sql = sql persistSavedQueries() status = "Updated saved query: \(savedQueries[index].name)" } 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) } } // Phase 66: compare bounded table samples without modifying either database. struct DataComparisonReport { let sourceRows: Int let targetRows: Int let truncated: Bool let text: String } func compareTableData(source: String, target: String, table: String) async throws -> DataComparisonReport { guard let profile = selected else { throw NSError(domain: "DBSyn.DataCompare", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."]) } guard let sourceMetadata = introspection(for: source), let targetMetadata = introspection(for: target), sourceMetadata.tables.contains(table), targetMetadata.tables.contains(table) else { throw NSError(domain: "DBSyn.DataCompare", code: 2, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases and select a shared base table."]) } let sourceColumns = (sourceMetadata.columnsByTable[table] ?? []).map(\.name) let targetColumns = (targetMetadata.columnsByTable[table] ?? []).map(\.name) guard !sourceColumns.isEmpty, sourceColumns == targetColumns else { throw NSError(domain: "DBSyn.DataCompare", code: 3, userInfo: [NSLocalizedDescriptionKey: "The table columns must match in name and order before comparing data."]) } func quote(_ identifier: String) -> String { "`" + identifier.replacingOccurrences(of: "`", with: "``") + "`" } let password = PasswordStore.read(profile.id) let sql = "SELECT * FROM \(quote(table)) LIMIT 501" let left = try await NativeMySQLRunner.run(profile: profile, database: source, password: password, sql: sql) let right = try await NativeMySQLRunner.run(profile: profile, database: target, password: password, sql: sql) guard left.columns == right.columns else { throw NSError(domain: "DBSyn.DataCompare", code: 4, userInfo: [NSLocalizedDescriptionKey: "Returned column layouts differ; comparison aborted."]) } let limit = 500 let truncated = left.rows.count > limit || right.rows.count > limit let a = Array(left.rows.prefix(limit)) let b = Array(right.rows.prefix(limit)) // JSON encoding gives unambiguous row keys, including embedded delimiters. func counts(_ rows: [[String]]) -> [String: Int] { var output: [String: Int] = [:] for row in rows { guard let bytes = try? JSONEncoder().encode(row), let key = String(data: bytes, encoding: .utf8) else { continue } output[key, default: 0] += 1 } return output } let leftCounts = counts(a) let rightCounts = counts(b) let keys = Set(leftCounts.keys).union(rightCounts.keys).sorted() var lines = ["DBSyn Phase 66 — Read-only Table Data Comparison", "Source: \(source).\(table) — \(a.count) sampled rows", "Target: \(target).\(table) — \(b.count) sampled rows", "Columns: \(left.columns.joined(separator: ", "))", ""] if truncated { lines.append("WARNING: Truncated to 500 rows per side; without ORDER BY, MySQL does not guarantee which rows are returned. These results are not a complete or deterministic comparison.") lines.append("") } var differences = 0 for key in keys { let leftCount = leftCounts[key, default: 0] let rightCount = rightCounts[key, default: 0] if leftCount != rightCount { differences += 1 lines.append("Source count \(leftCount), target count \(rightCount): \(key)") } } if differences == 0 { lines.append("No differences detected in retrieved rows.") } else { lines.insert("Distinct differing row values: \(differences)", at: 4) } return DataComparisonReport(sourceRows: a.count, targetRows: b.count, truncated: truncated, text: lines.joined(separator: "\n")) } // Phase 67: safe, explicit same-server INSERT preview for missing primary keys. // No SQL is executed by this method; the user reviews the statement first. struct DataSyncPreview { let sql: String let keyColumns: [String] let explanation: String } func previewMissingRowsSync(source: String, target: String, table: String) throws -> DataSyncPreview { guard selected != nil else { throw NSError(domain: "DBSyn.DataSync", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."]) } guard source != target, !source.isEmpty, !target.isEmpty else { throw NSError(domain: "DBSyn.DataSync", code: 2, userInfo: [NSLocalizedDescriptionKey: "Choose different source and target databases."]) } guard let left = introspection(for: source), let right = introspection(for: target), left.tables.contains(table), right.tables.contains(table) else { throw NSError(domain: "DBSyn.DataSync", code: 3, userInfo: [NSLocalizedDescriptionKey: "Introspect both databases; the table must exist in both."]) } let leftColumns = left.columnsByTable[table] ?? [] let rightColumns = right.columnsByTable[table] ?? [] guard !leftColumns.isEmpty, leftColumns.map(\.name) == rightColumns.map(\.name), zip(leftColumns, rightColumns).allSatisfy({ $0.dataType == $1.dataType && $0.nullable == $1.nullable }) else { throw NSError(domain: "DBSyn.DataSync", code: 4, userInfo: [NSLocalizedDescriptionKey: "Column names, order, types and nullability must match before generating SQL."]) } let keys = leftColumns.filter { $0.key == "PRI" }.map(\.name) let rightKeys = rightColumns.filter { $0.key == "PRI" }.map(\.name) guard !keys.isEmpty, keys == rightKeys else { throw NSError(domain: "DBSyn.DataSync", code: 5, userInfo: [NSLocalizedDescriptionKey: "A matching primary key is required. No statement was generated."]) } func q(_ value: String) -> String { "`" + value.replacingOccurrences(of: "`", with: "``") + "`" } let sourceTable = "\(q(source)).\(q(table))" let targetTable = "\(q(target)).\(q(table))" let columns = leftColumns.map { q($0.name) }.joined(separator: ", ") let sourceValues = leftColumns.map { "s.\(q($0.name))" }.joined(separator: ", ") let predicates = keys.map { "t.\(q($0)) = s.\(q($0))" }.joined(separator: " AND ") let sql = """ -- DBSyn Phase 67: REVIEW BEFORE EXECUTING. -- Inserts only rows whose primary key is missing in the target. -- Existing target rows are NEVER updated or deleted. -- Take a backup and verify privileges, triggers and foreign keys first. INSERT INTO \(targetTable) (\(columns)) SELECT \(sourceValues) FROM \(sourceTable) AS s WHERE NOT EXISTS ( SELECT 1 FROM \(targetTable) AS t WHERE \(predicates) ); """ return DataSyncPreview(sql: sql, keyColumns: keys, explanation: "Preview only. Uses matching primary key (\(keys.joined(separator: ", "))). Inserts missing keys; does not update changed rows. Review generated columns, triggers, constraints and backups before execution.") } @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]] = [:] // Connection-scoped metadata snapshots persist between launches. struct IntrospectedColumn: Identifiable, Codable { var id: String { "\(table).\(name)" } let table: String let name: String let dataType: String let nullable: Bool let key: String let defaultValue: String? let extra: String } struct SchemaIntrospection: Codable { let connectionID: UUID let database: String let loadedAt: Date let tables: [String] let views: [String] let columnsByTable: [String: [IntrospectedColumn]] } // Resolve USE statements from the editor buffer even before Run is pressed. // The completion provider must not depend on navigator selection alone. var completionDatabase: String { let pattern = #"(?im)^\s*USE\s+`?([A-Za-z_][A-Za-z0-9_]*)`?\s*;?"# if let regex = try? NSRegularExpression(pattern: pattern) { let value = sql as NSString let range = NSRange(location: 0, length: value.length) if let match = regex.matches(in: sql, range: range).last, match.numberOfRanges > 1 { return value.substring(with: match.range(at: 1)) } } return selectedDatabase.isEmpty ? (selected?.database ?? "") : selectedDatabase } // Phase 62: compare successive successful schema introspections. struct SchemaChange: Identifiable { let id = UUID() let category: String let object: String let detail: String } struct SchemaChangeReport { let database: String let checkedAt: Date let changes: [SchemaChange] } @Published private(set) var schemaChangeReports: [String: SchemaChangeReport] = [:] func schemaChangeReport(for database: String) -> SchemaChangeReport? { guard let profile = selected else { return nil } return schemaChangeReports[introspectionKey(database, profile.id)] } private func compareSchemas(previous: SchemaIntrospection, current: SchemaIntrospection) -> [SchemaChange] { var changes: [SchemaChange] = [] func compareObjects(_ kind: String, _ old: [String], _ new: [String]) { let oldSet = Set(old), newSet = Set(new) for name in newSet.subtracting(oldSet).sorted() { changes.append(SchemaChange(category: "Added \(kind)", object: name, detail: "New \(kind.lowercased())")) } for name in oldSet.subtracting(newSet).sorted() { changes.append(SchemaChange(category: "Removed \(kind)", object: name, detail: "No longer exists")) } } compareObjects("Table", previous.tables, current.tables) compareObjects("View", previous.views, current.views) let allObjects = Set(previous.columnsByTable.keys).union(current.columnsByTable.keys) for object in allObjects.sorted() { let oldColumns = Dictionary(uniqueKeysWithValues: (previous.columnsByTable[object] ?? []).map { ($0.name, $0) }) let newColumns = Dictionary(uniqueKeysWithValues: (current.columnsByTable[object] ?? []).map { ($0.name, $0) }) for name in Set(newColumns.keys).subtracting(oldColumns.keys).sorted() { changes.append(SchemaChange(category: "Added Column", object: "\(object).\(name)", detail: newColumns[name]?.dataType ?? "")) } for name in Set(oldColumns.keys).subtracting(newColumns.keys).sorted() { changes.append(SchemaChange(category: "Removed Column", object: "\(object).\(name)", detail: oldColumns[name]?.dataType ?? "")) } for name in Set(oldColumns.keys).intersection(newColumns.keys).sorted() { guard let before = oldColumns[name], let after = newColumns[name] else { continue } if before.dataType != after.dataType || before.nullable != after.nullable || before.key != after.key || before.defaultValue != after.defaultValue || before.extra != after.extra { changes.append(SchemaChange(category: "Changed Column", object: "\(object).\(name)", detail: "\(before.dataType) → \(after.dataType)")) } } } return changes } @Published private(set) var introspections: [String: SchemaIntrospection] = [:] private let introspectionStorageKey = "DBSyn.introspection.snapshots.v2" private func persistIntrospections() { if let data = try? JSONEncoder().encode(introspections) { UserDefaults.standard.set(data, forKey: introspectionStorageKey) } } private func applyIntrospection(_ snapshot: SchemaIntrospection) { schemaTables[snapshot.database] = snapshot.tables schemaViews[snapshot.database] = snapshot.views schemaColumns[snapshot.database] = Array(Set(snapshot.columnsByTable.values.flatMap { $0 }.flatMap { [$0.name, "\($0.table).\($0.name)"] })).sorted() } func refreshIntrospection(database: String) { introspect(database: database) } @Published private(set) var introspectionLoading: Set = [] private func introspectionKey(_ database: String, _ connectionID: UUID) -> String { "\(connectionID.uuidString):\(database)" } func introspection(for database: String) -> SchemaIntrospection? { guard let profile = selected else { return nil } return introspections[introspectionKey(database, profile.id)] } func introspect(database: String) { guard let profile = selected else { status = "Select a connection"; return } let key = introspectionKey(database, profile.id) guard !introspectionLoading.contains(key) else { return } introspectionLoading.insert(key) status = "Introspecting \(database)…" let password = PasswordStore.read(profile.id) let escaped = database.replacingOccurrences(of: "'", with: "''") Task { defer { introspectionLoading.remove(key) } do { let tableResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME" ) let columnResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME, ORDINAL_POSITION" ) let tables = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "BASE TABLE" ? $0[0] : nil } let views = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "VIEW" ? $0[0] : nil } var columns: [String: [IntrospectedColumn]] = [:] for row in columnResult.rows where row.count >= 7 { let column = IntrospectedColumn( table: row[0], name: row[1], dataType: row[2], nullable: row[3] == "YES", key: row[4], defaultValue: row[5] == "NULL" ? nil : row[5], extra: row[6] ) columns[row[0], default: []].append(column) } // Ignore results from a connection that has been removed while loading. guard profiles.contains(where: { $0.id == profile.id }), selected?.id == profile.id else { return } let previous = introspections[key] let current = SchemaIntrospection( connectionID: profile.id, database: database, loadedAt: Date(), tables: tables, views: views, columnsByTable: columns ) if let previous { schemaChangeReports[key] = SchemaChangeReport( database: database, checkedAt: current.loadedAt, changes: compareSchemas(previous: previous, current: current) ) } introspections[key] = current applyIntrospection(current) persistIntrospections() status = "Introspection refreshed: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database)" } catch { status = "Introspection failed for \(database): \(error.localizedDescription)" } } } @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()] } if let data = UserDefaults.standard.data(forKey: introspectionStorageKey), let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) { introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) } } selectedID = profiles.first?.id if let id = selectedID { for snapshot in introspections.values where snapshot.connectionID == id { applyIntrospection(snapshot) } } 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 // Refresh cached structure when the server reports a changed object list. if let snapshot = introspection(for: database), (Set(snapshot.tables) != Set(tables) || Set(snapshot.views) != Set(views)) { introspect(database: database) } // 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])") } if introspection(for: database) == nil { schemaColumns[database] = names.sorted() } } catch { if introspection(for: database) == nil { schemaColumns[database] = [] } } status = "Loaded \(tables.count) tables and \(views.count) views from \(database)" // Revalidate column definitions as well as object names on reconnect/refresh. if introspection(for: database) != nil { introspect(database: 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 } let statements = listingDatabases ? [statement] : SQLStatementSplitter.split(statement) guard !statements.isEmpty else { return } isRunning = true if !listingDatabases { queryDurationText = "Running…" } let queryStarted = Date() status = "Executing…" let password = PasswordStore.read(profile.id) Task { var activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase) do { var lastResult = QueryResult() for item in statements { // Each query is sent separately: COM_QUERY accepts one SQL // statement, not an entire semicolon-separated editor buffer. let parsed = try await NativeMySQLRunner.run( profile: profile, database: activeDB, password: password, sql: item ) lastResult = parsed if let usedSchema = SQLStatementSplitter.usedDatabase(item) { activeDB = usedSchema selectedDatabase = usedSchema // Metadata is cached in memory for native SQL completion. loadTables(database: usedSchema) introspect(database: usedSchema) } } if listingDatabases { databases = lastResult.rows.compactMap { $0.first } status = "Connected to \(profile.name)" } else { result = lastResult if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) { queryTabs[index].sql = sql queryTabs[index].result = lastResult } status = "Executed \(statements.count) statement(s) (\(lastResult.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 } } } // Splits editor scripts without splitting semicolons inside quoted values or comments. // Internal visibility allows the DBSynTests target to exercise the production parser via @testable import. enum SQLStatementSplitter { static func split(_ source: String) -> [String] { let chars = Array(source) var output: [String] = [] var current = "" var state = 0 // 0 normal, 1 single quote, 2 double quote, 3 identifier, 4 line comment, 5 block comment var i = 0 while i < chars.count { let c = chars[i] let next = i + 1 < chars.count ? chars[i + 1] : "\0" if state == 4 { if c == "\n" || c == "\r" { state = 0; current.append(" ") } i += 1; continue } if state == 5 { if c == "*" && next == "/" { state = 0; i += 2; current.append(" ") } else { i += 1 } continue } if state == 0 { if c == "#" || (c == "-" && next == "-" && (i + 2 == chars.count || chars[i + 2].isWhitespace)) { state = 4; i += c == "#" ? 1 : 2; continue } if c == "/" && next == "*" { state = 5; i += 2; continue } if c == "'" { state = 1 } else if c == "\"" { state = 2 } else if c == "`" { state = 3 } else if c == ";" { let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { output.append(trimmed) } current = ""; i += 1; continue } } else { if c == "\\" && state != 3 && i + 1 < chars.count { current.append(c); current.append(next); i += 2; continue } if (state == 1 && c == "'") || (state == 2 && c == "\"") || (state == 3 && c == "`") { if next == c { current.append(c); current.append(next); i += 2; continue } state = 0 } } current.append(c); i += 1 } let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { output.append(trimmed) } return output } static func usedDatabase(_ statement: String) -> String? { let pattern = #"(?is)^\s*USE\s+(?:`((?:``|[^`])+)`|([A-Za-z0-9_$]+))\s*$"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: statement, range: NSRange(statement.startIndex..., in: statement)) else { return nil } for index in [1, 2] where match.range(at: index).location != NSNotFound { if let range = Range(match.range(at: index), in: statement) { return String(statement[range]).replacingOccurrences(of: "``", with: "`") } } return nil } } // 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..