diff --git a/DBSyn.xcodeproj/project.pbxproj b/DBSyn.xcodeproj/project.pbxproj index 6df8c05..9078f41 100644 --- a/DBSyn.xcodeproj/project.pbxproj +++ b/DBSyn.xcodeproj/project.pbxproj @@ -6,6 +6,14 @@ objectVersion = 56; objects = { +/* Begin PBXContainerItemProxy section */ + F0737000000000000000000D /* DBSyn target proxy */ = { isa = PBXContainerItemProxy; containerPortal = 000000000000000000000001; proxyType = 1; remoteGlobalIDString = 000000000000000000000005; remoteInfo = DBSyn; }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + F0737000000000000000000E /* DBSyn dependency */ = { isa = PBXTargetDependency; target = 000000000000000000000005; targetProxy = F0737000000000000000000D; }; +/* End PBXTargetDependency section */ + /* 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 */; }; @@ -16,6 +24,8 @@ A10000000000000000000003 /* DBSyn.icns in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* DBSyn.icns */; }; A10000000000000000000004 /* DBSynBrand.png in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* DBSynBrand.png */; }; + F07370000000000000000002 /* DiagramLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07370000000000000000001 /* DiagramLogic.swift */; }; + F07370000000000000000006 /* DBSynTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07370000000000000000005 /* DBSynTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +39,9 @@ A10000000000000000000001 /* DBSyn.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = DBSyn.icns; sourceTree = ""; }; A10000000000000000000002 /* DBSynBrand.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = DBSynBrand.png; sourceTree = ""; }; + F07370000000000000000001 /* DiagramLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramLogic.swift; sourceTree = ""; }; + F07370000000000000000005 /* DBSynTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBSynTests.swift; sourceTree = ""; }; + F07370000000000000000007 /* DBSynTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DBSynTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,6 +52,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F07370000000000000000009 /* Tests Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -46,6 +60,7 @@ isa = PBXGroup; children = ( 000000000000000000000003 /* DBSyn */, + F07370000000000000000003 /* DBSynTests */, 000000000000000000000004 /* Products */, ); sourceTree = ""; @@ -56,6 +71,7 @@ 000000000000000000000010 /* App.swift */, 000000000000000000000011 /* MainView.swift */, 000000000000000000000012 /* DatabaseModel.swift */, + F07370000000000000000001 /* DiagramLogic.swift */, B73000000000000000000002 /* SchemaDiagramView.swift */, B73200000000000000000002 /* SchemaDiagramPrintView.swift */, 000000000000000000000016 /* MySQLProtocol.swift */, @@ -68,6 +84,7 @@ isa = PBXGroup; children = ( 00000000000000000000000F /* DBSyn.app */, + F07370000000000000000007 /* DBSynTests.xctest */, ); name = Products; sourceTree = ""; @@ -82,6 +99,7 @@ path = Resources; sourceTree = ""; }; + F07370000000000000000003 /* DBSynTests */ = { isa = PBXGroup; children = (F07370000000000000000005 /* DBSynTests.swift */,); path = DBSynTests; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -102,6 +120,7 @@ productReference = 00000000000000000000000F /* DBSyn.app */; productType = "com.apple.product-type.application"; }; + F07370000000000000000004 /* DBSynTests */ = { isa = PBXNativeTarget; buildConfigurationList = F0737000000000000000000A /* Test Configurations */; buildPhases = (F07370000000000000000008 /* Test Sources */, F07370000000000000000009 /* Tests Frameworks */,); buildRules = (); dependencies = (F0737000000000000000000E /* DBSyn dependency */,); name = DBSynTests; productName = DBSynTests; productReference = F07370000000000000000007 /* DBSynTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -113,6 +132,7 @@ 000000000000000000000005 = { CreatedOnToolsVersion = 16.0; }; + F07370000000000000000004 = { CreatedOnToolsVersion = 16.0; TestTargetID = 000000000000000000000005; }; }; }; buildConfigurationList = 000000000000000000000009 /* Build configuration list for PBXProject "DBSyn" */; @@ -129,6 +149,7 @@ projectRoot = ""; targets = ( 000000000000000000000005 /* DBSyn */, + F07370000000000000000004 /* DBSynTests */, ); }; /* End PBXProject section */ @@ -153,12 +174,14 @@ 000000000000000000000013 /* App.swift in Sources */, 000000000000000000000014 /* MainView.swift in Sources */, 000000000000000000000015 /* DatabaseModel.swift in Sources */, + F07370000000000000000002 /* DiagramLogic.swift in Sources */, B73000000000000000000001 /* SchemaDiagramView.swift in Sources */, B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */, 000000000000000000000017 /* MySQLProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + F07370000000000000000008 /* Test Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F07370000000000000000006 /* DBSynTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -226,6 +249,8 @@ }; name = Release; }; + F0737000000000000000000B /* Tests Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = NO; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSynTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DBSyn.app/Contents/MacOS/DBSyn"; }; name = Debug; }; + F0737000000000000000000C /* Tests Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = NO; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSynTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DBSyn.app/Contents/MacOS/DBSyn"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -247,6 +272,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F0737000000000000000000A /* Test Configurations */ = { isa = XCConfigurationList; buildConfigurations = (F0737000000000000000000B /* Tests Debug */, F0737000000000000000000C /* Tests Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ }; rootObject = 000000000000000000000001 /* Project object */; diff --git a/DBSyn.xcodeproj/xcshareddata/xcschemes/DBSyn.xcscheme b/DBSyn.xcodeproj/xcshareddata/xcschemes/DBSyn.xcscheme new file mode 100644 index 0000000..7dbe3ed --- /dev/null +++ b/DBSyn.xcodeproj/xcshareddata/xcschemes/DBSyn.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 1e68197..6df8c05 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -1,1052 +1,253 @@ -import Foundation -import AppKit -import Security +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { -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.., available: [String]) -> Set { + requested.intersection(Set(available)) + } + + static func arrangedPositions(_ names: [String]) -> [String: CGPoint] { + let countPerRow = max(1, Int(ceil(sqrt(Double(max(1, names.count)))))) + var positions: [String: CGPoint] = [:] + for (index, name) in names.enumerated() { + positions[name] = CGPoint(x: CGFloat(index % countPerRow) * 290 + 30, + y: CGFloat(index / countPerRow) * 300 + 30) + } + return positions + } + + static func translatedPosition(_ origin: CGPoint, translation: CGSize, zoom: CGFloat) -> CGPoint { + let scale = max(zoom, 0.1) + return CGPoint(x: origin.x + translation.width / scale, + y: origin.y + translation.height / scale) + } +} diff --git a/DBSyn/SchemaDiagramView.swift b/DBSyn/SchemaDiagramView.swift index 936d63c..60d9347 100644 --- a/DBSyn/SchemaDiagramView.swift +++ b/DBSyn/SchemaDiagramView.swift @@ -355,7 +355,7 @@ struct DBSynSchemaDiagramView: View { // previous implementation only saved the checkboxes, leaving the diagram // scrolled to stale positions after None / All / individual selections. private func updateIncludedObjects(_ updated: Set) { - let valid = updated.intersection(Set(allObjects)) + let valid = DiagramLogic.selectedObjects(updated, available: allObjects) guard valid != includedObjects else { return } includedObjects = valid if let selectedObject, !valid.contains(selectedObject) { @@ -371,11 +371,7 @@ struct DBSynSchemaDiagramView: View { private func arrange() { let names = allObjects.filter { includedObjects.contains($0) } - let countPerRow = max(1, Int(ceil(sqrt(Double(max(1, names.count)))))) - for (index, name) in names.enumerated() { - positions[name] = CGPoint(x: CGFloat(index % countPerRow) * 290 + 30, - y: CGFloat(index / countPerRow) * 300 + 30) - } + positions.merge(DiagramLogic.arrangedPositions(names)) { _, latest in latest } } private func loadCatalog() { let stored = UserDefaults.standard.stringArray(forKey: catalogKey) ?? ["Main"] diff --git a/DBSynTests/DBSynTests.swift b/DBSynTests/DBSynTests.swift new file mode 100644 index 0000000..177c9f5 --- /dev/null +++ b/DBSynTests/DBSynTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import DBSyn + +final class DBSynTests: XCTestCase { + func testSQLSplitsMultipleStatements() { + XCTAssertEqual(SQLStatementSplitter.split("USE AAMEDIUK; SELECT * FROM ADCUSERS;"), + ["USE AAMEDIUK", "SELECT * FROM ADCUSERS"]) + } + + func testSQLSplitterPreservesQuotedSemicolons() { + XCTAssertEqual(SQLStatementSplitter.split("SELECT 'a;b'; SELECT `a;b` FROM t;"), + ["SELECT 'a;b'", "SELECT `a;b` FROM t"]) + } + + func testSQLSplitterIgnoresComments() { + XCTAssertEqual(SQLStatementSplitter.split("# comment;\nUSE db; /* comment; */ SELECT 1;"), + ["USE db", "SELECT 1"]) + } + + func testSQLSplitterIgnoresEmptyStatements() { + XCTAssertEqual(SQLStatementSplitter.split(";; SELECT 1;;;"), ["SELECT 1"]) + } + + func testUSEDatabaseUnquoted() { + XCTAssertEqual(SQLStatementSplitter.usedDatabase("USE AAMEDIUK"), "AAMEDIUK") + } + + func testUSEDatabaseQuoted() { + XCTAssertEqual(SQLStatementSplitter.usedDatabase("USE `my``db`"), "my`db") + } + + func testUSEDatabaseRejectsOtherStatements() { + XCTAssertNil(SQLStatementSplitter.usedDatabase("SELECT * FROM ADCUSERS")) + } + + func testSQLFormatterUppercasesKeywords() { + XCTAssertEqual(SQLFormatter.uppercaseKeywords("select * from ADCUSERS where id = 1"), + "SELECT * FROM ADCUSERS WHERE id = 1") + } + + func testSQLFormatterPreservesStringLiteralsAndComments() { + let input = "select 'select from' # select stays lower\nfrom users" + XCTAssertEqual(SQLFormatter.uppercaseKeywords(input), + "SELECT 'select from' # select stays lower\nFROM users") + } + + func testConnectionProfileCodableRoundTrip() throws { + var profile = ConnectionProfile() + profile.name = "Development" + profile.database = "AAMEDIUK" + let decoded = try JSONDecoder().decode(ConnectionProfile.self, from: JSONEncoder().encode(profile)) + XCTAssertEqual(decoded, profile) + } + + func testIntrospectionCacheCodableRoundTrip() throws { + let connectionID = UUID() + let column = DatabaseModel.IntrospectedColumn(table: "ADCUSERS", name: "id", dataType: "bigint", nullable: false, key: "PRI", defaultValue: nil, extra: "auto_increment") + let snapshot = DatabaseModel.SchemaIntrospection(connectionID: connectionID, database: "AAMEDIUK", loadedAt: Date(timeIntervalSince1970: 1000), tables: ["ADCUSERS"], views: ["ACTIVE_USERS"], columnsByTable: ["ADCUSERS": [column]]) + let decoded = try JSONDecoder().decode(DatabaseModel.SchemaIntrospection.self, from: JSONEncoder().encode(snapshot)) + XCTAssertEqual(decoded.connectionID, connectionID) + XCTAssertEqual(decoded.database, "AAMEDIUK") + XCTAssertEqual(decoded.tables, ["ADCUSERS"]) + XCTAssertEqual(decoded.views, ["ACTIVE_USERS"]) + XCTAssertEqual(decoded.columnsByTable["ADCUSERS"]?.first?.name, "id") + } + + func testDiagramSelectNone() { + XCTAssertEqual(DiagramLogic.selectedObjects([], available: ["A", "B"]), []) + } + + func testDiagramSelectSubsetAndIgnoreRemovedObjects() { + XCTAssertEqual(DiagramLogic.selectedObjects(["A", "MISSING"], available: ["A", "B"]), ["A"]) + } + + func testDiagramSelectAll() { + let available = ["A", "B", "VIEW_C"] + XCTAssertEqual(DiagramLogic.selectedObjects(Set(available), available: available), Set(available)) + } + + func testDiagramLayoutContainsEverySelectedTable() { + let names = (0..<25).map { "table_\($0)" } + let positions = DiagramLogic.arrangedPositions(names) + XCTAssertEqual(positions.count, names.count) + XCTAssertEqual(positions["table_0"], CGPoint(x: 30, y: 30)) + XCTAssertEqual(Set(positions.values.map { "\($0.x),\($0.y)" }).count, names.count) + } + + func testDiagramDragRespectsZoom() { + let origin = CGPoint(x: 100, y: 200) + XCTAssertEqual(DiagramLogic.translatedPosition(origin, translation: CGSize(width: 60, height: -40), zoom: 2), + CGPoint(x: 130, y: 180)) + } + + func testDiagramDragHandlesZeroZoom() { + let point = DiagramLogic.translatedPosition(.zero, translation: CGSize(width: 10, height: 5), zoom: 0) + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..8b716d4 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,11 @@ +# DBSyn automated tests + +Run from the project root: `./run-tests.sh`. + +The script runs `xcodebuild test` on the macOS arm64 destination, saves a full log and `.xcresult` bundle under `test-results/`, and returns a nonzero exit status if tests fail. + +The XCTest target covers SQL statement splitting, quoting, comment handling, `USE` parsing, keyword formatting, profile and introspection persistence encoding, diagram selection and layout, and drag/zoom arithmetic. + +**Coverage limits:** These are deterministic unit tests, not a guarantee of complete application coverage. Database connection, authentication, real MySQL execution, AppKit editor interaction, PDF/PNG/SVG export, and printing require separate integration/UI tests on macOS and, for MySQL tests, a dedicated disposable database. No tests should run destructive SQL against production databases. + +This project has not been built or run with Xcode in the packaging environment. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..0a95bbe --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +if ! command -v xcodebuild >/dev/null 2>&1; then + echo "ERROR: Xcode and xcodebuild are required on macOS." >&2 + exit 1 +fi +mkdir -p test-results +STAMP="$(date +%Y%m%d-%H%M%S)" +RESULT="$ROOT/test-results/DBSyn-$STAMP.xcresult" +LOG="$ROOT/test-results/DBSyn-$STAMP.log" +echo "Running DBSyn XCTest suite. Log: $LOG" +set +e +xcodebuild test \ + -project DBSyn.xcodeproj \ + -scheme DBSyn \ + -configuration Debug \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$ROOT/build" \ + -resultBundlePath "$RESULT" \ + CODE_SIGNING_ALLOWED=NO 2>&1 | tee "$LOG" +STATUS=${PIPESTATUS[0]} +set -e +printf '\nTest log: %s\nTest results: %s\n' "$LOG" "$RESULT" +exit "$STATUS"