diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 6df8c05..1e68197 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -1,253 +1,1052 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { +import Foundation +import AppKit +import Security -/* Begin PBXBuildFile section */ - B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73200000000000000000002 /* SchemaDiagramPrintView.swift */; }; - B73000000000000000000001 /* SchemaDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73000000000000000000002 /* SchemaDiagramView.swift */; }; - 000000000000000000000013 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000010 /* App.swift */; }; - 000000000000000000000014 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000011 /* MainView.swift */; }; - 000000000000000000000015 /* DatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000012 /* DatabaseModel.swift */; }; - 000000000000000000000017 /* MySQLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000016 /* MySQLProtocol.swift */; }; - - A10000000000000000000003 /* DBSyn.icns in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* DBSyn.icns */; }; - A10000000000000000000004 /* DBSynBrand.png in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* DBSynBrand.png */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - B73200000000000000000002 /* SchemaDiagramPrintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramPrintView.swift; sourceTree = ""; }; - B73000000000000000000002 /* SchemaDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramView.swift; sourceTree = ""; }; - 00000000000000000000000F /* DBSyn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DBSyn.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 000000000000000000000010 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; - 000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; - 000000000000000000000012 /* DatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseModel.swift; sourceTree = ""; }; - 000000000000000000000016 /* MySQLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySQLProtocol.swift; sourceTree = ""; }; - - A10000000000000000000001 /* DBSyn.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = DBSyn.icns; sourceTree = ""; }; - A10000000000000000000002 /* DBSynBrand.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = DBSynBrand.png; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 000000000000000000000008 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 000000000000000000000002 = { - isa = PBXGroup; - children = ( - 000000000000000000000003 /* DBSyn */, - 000000000000000000000004 /* Products */, - ); - sourceTree = ""; - }; - 000000000000000000000003 /* DBSyn */ = { - isa = PBXGroup; - children = ( - 000000000000000000000010 /* App.swift */, - 000000000000000000000011 /* MainView.swift */, - 000000000000000000000012 /* DatabaseModel.swift */, - B73000000000000000000002 /* SchemaDiagramView.swift */, - B73200000000000000000002 /* SchemaDiagramPrintView.swift */, - 000000000000000000000016 /* MySQLProtocol.swift */, - - A10000000000000000000005 /* Resources */,); - path = DBSyn; - sourceTree = ""; - }; - 000000000000000000000004 /* Products */ = { - isa = PBXGroup; - children = ( - 00000000000000000000000F /* DBSyn.app */, - ); - name = Products; - sourceTree = ""; - }; - - A10000000000000000000005 /* Resources */ = { - isa = PBXGroup; - children = ( - A10000000000000000000001 /* DBSyn.icns */, - A10000000000000000000002 /* DBSynBrand.png */, - ); - path = Resources; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 000000000000000000000005 /* DBSyn */ = { - isa = PBXNativeTarget; - buildConfigurationList = 00000000000000000000000A /* Build configuration list for PBXNativeTarget "DBSyn" */; - buildPhases = ( - 000000000000000000000006 /* Sources */, - 000000000000000000000008 /* Frameworks */, - 000000000000000000000007 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = DBSyn; - productName = DBSyn; - productReference = 00000000000000000000000F /* DBSyn.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 000000000000000000000001 /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1600; - TargetAttributes = { - 000000000000000000000005 = { - CreatedOnToolsVersion = 16.0; - }; - }; - }; - buildConfigurationList = 000000000000000000000009 /* Build configuration list for PBXProject "DBSyn" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 000000000000000000000002; - productRefGroup = 000000000000000000000004 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 000000000000000000000005 /* DBSyn */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 000000000000000000000007 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - - A10000000000000000000003 /* DBSyn.icns in Resources */, - A10000000000000000000004 /* DBSynBrand.png in Resources */,); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 000000000000000000000006 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 000000000000000000000013 /* App.swift in Sources */, - 000000000000000000000014 /* MainView.swift in Sources */, - 000000000000000000000015 /* DatabaseModel.swift in Sources */, - B73000000000000000000001 /* SchemaDiagramView.swift in Sources */, - B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */, - 000000000000000000000017 /* MySQLProtocol.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 00000000000000000000000B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 00000000000000000000000C /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 00000000000000000000000D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 39R6U3437D; - ENABLE_APP_SANDBOX = NO; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; - MARKETING_VERSION = 1.0.0; - CURRENT_PROJECT_VERSION = 72; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; - INFOPLIST_KEY_CFBundleIconFile = "DBSyn.icns"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSyn; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 00000000000000000000000E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 39R6U3437D; - ENABLE_APP_SANDBOX = NO; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; - MARKETING_VERSION = 1.0.0; - CURRENT_PROJECT_VERSION = 72; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; - INFOPLIST_KEY_CFBundleIconFile = "DBSyn.icns"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSyn; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 000000000000000000000009 /* Build configuration list for PBXProject "DBSyn" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 00000000000000000000000B /* Debug */, - 00000000000000000000000C /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 00000000000000000000000A /* Build configuration list for PBXNativeTarget "DBSyn" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 00000000000000000000000D /* Debug */, - 00000000000000000000000E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 000000000000000000000001 /* Project object */; +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. +private 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..&1 | tee "$LOG" -STATUS=${PIPESTATUS[0]} -set -e + CODE_SIGNING_ALLOWED=NO 2>&1 | tee "$LOG"; then + STATUS=0 +else + STATUS=$? +fi printf '\nTest log: %s\nTest results: %s\n' "$LOG" "$RESULT" exit "$STATUS"