From 20bfb6e978e9f1a5d91fb6af79751f71a7ba5365 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 16:20:13 +0700 Subject: [PATCH 1/3] feat(compare): compare and sync structure or data between two connections (#721) --- CHANGELOG.md | 1 + Plugins/SQLExportPlugin/SQLExportPlugin.swift | 40 +- .../ForeignKeyTopologicalSort.swift | 41 ++ .../PluginCapabilities.swift | 2 + .../Core/Compare/CompareSyncEndpoint.swift | 78 ++++ .../Compare/CompareSyncEngineFamily.swift | 44 +++ .../Core/Compare/CompareSyncExecutor.swift | 213 +++++++++++ .../Core/Compare/CompareSyncLauncher.swift | 32 ++ .../Compare/CompareSyncProfileStorage.swift | 74 ++++ .../Compare/CompareSyncSession+Data.swift | 220 +++++++++++ .../Core/Compare/CompareSyncSession+Run.swift | 214 +++++++++++ .../Core/Compare/CompareSyncSession.swift | 226 +++++++++++ .../Core/Compare/DataCompareOptions.swift | 151 ++++++++ TablePro/Core/Compare/DataDiffEngine.swift | 270 +++++++++++++ .../Core/Compare/DataSyncScriptBuilder.swift | 128 +++++++ .../Core/Compare/SchemaSyncOperation.swift | 76 ++++ .../Compare/SchemaSyncScriptBuilder.swift | 181 +++++++++ .../Core/Compare/StreamingRowProvider.swift | 80 ++++ .../Compare/StructureCompareOptions.swift | 72 ++++ .../Compare/StructureDiffEngine+Members.swift | 167 ++++++++ .../Core/Compare/StructureDiffEngine.swift | 124 ++++++ TablePro/Core/Compare/SyncHazard.swift | 65 ++++ .../Core/Compare/SyncSafetyClassifier.swift | 133 +++++++ TablePro/Core/Compare/TableDiffResult.swift | 86 +++++ .../Core/Compare/TableStructureSnapshot.swift | 129 +++++++ .../Infrastructure/SceneIdentifiers.swift | 1 + .../Infrastructure/WindowOpener.swift | 7 + .../Models/Compare/CompareSyncPayload.swift | 18 + TablePro/Models/Settings/ProFeature.swift | 9 +- TablePro/TableProApp.swift | 21 + .../Views/Compare/CompareStatusBanner.swift | 114 ++++++ .../Views/Compare/CompareSyncApplyView.swift | 121 ++++++ .../Views/Compare/CompareSyncScriptView.swift | 214 +++++++++++ .../Views/Compare/CompareSyncSetupView.swift | 201 ++++++++++ .../Views/Compare/CompareSyncWindowView.swift | 112 ++++++ .../Views/Compare/DataCompareReviewView.swift | 259 +++++++++++++ .../Compare/StructureCompareReviewView.swift | 292 ++++++++++++++ .../Connection/WelcomeContextMenus.swift | 6 + .../Infrastructure/WindowOpenerBridge.swift | 3 + .../Compare/CompareSyncExecutorTests.swift | 318 ++++++++++++++++ .../Core/Compare/DataDiffEngineTests.swift | 360 ++++++++++++++++++ .../SchemaSyncScriptBuilderTests.swift | 336 ++++++++++++++++ .../Compare/StructureDiffEngineTests.swift | 356 +++++++++++++++++ docs/docs.json | 1 + docs/features/compare-sync.mdx | 125 ++++++ 45 files changed, 5682 insertions(+), 39 deletions(-) create mode 100644 Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift create mode 100644 TablePro/Core/Compare/CompareSyncEndpoint.swift create mode 100644 TablePro/Core/Compare/CompareSyncEngineFamily.swift create mode 100644 TablePro/Core/Compare/CompareSyncExecutor.swift create mode 100644 TablePro/Core/Compare/CompareSyncLauncher.swift create mode 100644 TablePro/Core/Compare/CompareSyncProfileStorage.swift create mode 100644 TablePro/Core/Compare/CompareSyncSession+Data.swift create mode 100644 TablePro/Core/Compare/CompareSyncSession+Run.swift create mode 100644 TablePro/Core/Compare/CompareSyncSession.swift create mode 100644 TablePro/Core/Compare/DataCompareOptions.swift create mode 100644 TablePro/Core/Compare/DataDiffEngine.swift create mode 100644 TablePro/Core/Compare/DataSyncScriptBuilder.swift create mode 100644 TablePro/Core/Compare/SchemaSyncOperation.swift create mode 100644 TablePro/Core/Compare/SchemaSyncScriptBuilder.swift create mode 100644 TablePro/Core/Compare/StreamingRowProvider.swift create mode 100644 TablePro/Core/Compare/StructureCompareOptions.swift create mode 100644 TablePro/Core/Compare/StructureDiffEngine+Members.swift create mode 100644 TablePro/Core/Compare/StructureDiffEngine.swift create mode 100644 TablePro/Core/Compare/SyncHazard.swift create mode 100644 TablePro/Core/Compare/SyncSafetyClassifier.swift create mode 100644 TablePro/Core/Compare/TableDiffResult.swift create mode 100644 TablePro/Core/Compare/TableStructureSnapshot.swift create mode 100644 TablePro/Models/Compare/CompareSyncPayload.swift create mode 100644 TablePro/Views/Compare/CompareStatusBanner.swift create mode 100644 TablePro/Views/Compare/CompareSyncApplyView.swift create mode 100644 TablePro/Views/Compare/CompareSyncScriptView.swift create mode 100644 TablePro/Views/Compare/CompareSyncSetupView.swift create mode 100644 TablePro/Views/Compare/CompareSyncWindowView.swift create mode 100644 TablePro/Views/Compare/DataCompareReviewView.swift create mode 100644 TablePro/Views/Compare/StructureCompareReviewView.swift create mode 100644 TableProTests/Core/Compare/CompareSyncExecutorTests.swift create mode 100644 TableProTests/Core/Compare/DataDiffEngineTests.swift create mode 100644 TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift create mode 100644 TableProTests/Core/Compare/StructureDiffEngineTests.swift create mode 100644 docs/features/compare-sync.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e86f113..2485e5739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) +- Compare structure or data between two connections and generate the SQL script that brings the target in line with the source. Differences show in a tree you can group by object type or by operation, with the source and target definitions side by side. The script is editable before it runs, statements are ordered so parent tables come before the tables that reference them, and anything that would drop data is listed but held back until you allow it for that run. A read-only connection cannot be chosen as the target. Requires a Starter license. (#721) ### Changed diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index eaa26b45c..47a3dffa5 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -184,44 +184,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { _ tables: [PluginExportTable], fkMap: [String: [PluginForeignKeyInfo]] ) -> [PluginExportTable] { - let nameSet = Set(tables.map { $0.name }) - var indegree: [String: Int] = [:] - var children: [String: Set] = [:] - for table in tables { indegree[table.name] = 0 } - - for table in tables { - let fks = fkMap[table.name] ?? [] - var seenParents: Set = [] - for fk in fks where fk.referencedTable != table.name { - guard nameSet.contains(fk.referencedTable), - !seenParents.contains(fk.referencedTable) else { continue } - seenParents.insert(fk.referencedTable) - children[fk.referencedTable, default: []].insert(table.name) - indegree[table.name, default: 0] += 1 - } - } - - let byName = Dictionary(uniqueKeysWithValues: tables.map { ($0.name, $0) }) - var queue = tables.map { $0.name }.filter { (indegree[$0] ?? 0) == 0 }.sorted() - var ordered: [String] = [] - while !queue.isEmpty { - let head = queue.removeFirst() - ordered.append(head) - for child in (children[head] ?? []).sorted() { - indegree[child] = (indegree[child] ?? 0) - 1 - if indegree[child] == 0 { - queue.append(child) - } - } - } - - if ordered.count < tables.count { - let remaining = tables.map { $0.name } - .filter { name in !ordered.contains(name) } - .sorted() - ordered.append(contentsOf: remaining) - } - + let byName = Dictionary(tables.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + let ordered = ForeignKeyTopologicalSort.orderedNames(tables.map { $0.name }, foreignKeysByTable: fkMap) return ordered.compactMap { byName[$0] } } diff --git a/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift b/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift new file mode 100644 index 000000000..3c2bd6513 --- /dev/null +++ b/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum ForeignKeyTopologicalSort { + public static func orderedNames( + _ names: [String], + foreignKeysByTable: [String: [PluginForeignKeyInfo]] + ) -> [String] { + let nameSet = Set(names) + var indegree: [String: Int] = [:] + var children: [String: Set] = [:] + for name in names { indegree[name] = 0 } + + for name in names { + var seenParents: Set = [] + for fk in foreignKeysByTable[name] ?? [] where fk.referencedTable != name { + guard nameSet.contains(fk.referencedTable), + !seenParents.contains(fk.referencedTable) else { continue } + seenParents.insert(fk.referencedTable) + children[fk.referencedTable, default: []].insert(name) + indegree[name, default: 0] += 1 + } + } + + var queue = names.filter { (indegree[$0] ?? 0) == 0 }.sorted() + var ordered: [String] = [] + while !queue.isEmpty { + let head = queue.removeFirst() + ordered.append(head) + for child in (children[head] ?? []).sorted() { + indegree[child] = (indegree[child] ?? 0) - 1 + if indegree[child] == 0 { + queue.append(child) + } + } + } + + guard ordered.count < names.count else { return ordered } + let placed = Set(ordered) + return ordered + names.filter { !placed.contains($0) }.sorted() + } +} diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift index 99ce00baa..8283d295f 100644 --- a/Plugins/TableProPluginKit/PluginCapabilities.swift +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -22,4 +22,6 @@ public struct PluginCapabilities: OptionSet, Sendable { public static let batchExecute = PluginCapabilities(rawValue: 1 << 10) public static let transactions = PluginCapabilities(rawValue: 1 << 11) public static let userManagement = PluginCapabilities(rawValue: 1 << 12) + public static let schemaCompare = PluginCapabilities(rawValue: 1 << 13) + public static let dataCompare = PluginCapabilities(rawValue: 1 << 14) } diff --git a/TablePro/Core/Compare/CompareSyncEndpoint.swift b/TablePro/Core/Compare/CompareSyncEndpoint.swift new file mode 100644 index 000000000..75dfb3c2a --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncEndpoint.swift @@ -0,0 +1,78 @@ +// +// CompareSyncEndpoint.swift +// TablePro +// +// One side of a comparison. The source never changes; the target is written to. +// + +import Foundation +import TableProPluginKit + +internal struct CompareSyncEndpoint: Hashable, Identifiable { + internal let connectionId: UUID + internal let displayName: String + internal let databaseType: DatabaseType + internal let database: String? + internal let schema: String? + internal let safeModeLevel: SafeModeLevel + internal let color: ConnectionColor + + internal var id: String { + "\(connectionId.uuidString)-\(database ?? "")-\(schema ?? "")" + } + + internal var canBeWrittenTo: Bool { + safeModeLevel != .readOnly + } + + internal var ineligibleAsTargetReason: String? { + guard !canBeWrittenTo else { return nil } + return String(localized: "Read-Only. Choose a different connection to write changes to.") + } + + internal var qualifiedDescription: String { + var parts = [displayName] + if let database, !database.isEmpty { parts.append(database) } + if let schema, !schema.isEmpty { parts.append(schema) } + return parts.joined(separator: " / ") + } +} + +internal extension CompareSyncEndpoint { + static func candidates(from connections: [DatabaseConnection]) -> [CompareSyncEndpoint] { + connections.map { connection in + CompareSyncEndpoint( + connectionId: connection.id, + displayName: connection.name, + databaseType: connection.type, + database: connection.database, + schema: nil, + safeModeLevel: connection.safeModeLevel, + color: connection.color + ) + } + } +} + +internal enum CompareSyncEligibility { + static func refusalReason( + for driver: any PluginDatabaseDriver, + mode: CompareSyncMode, + endpointName: String + ) -> String? { + let required: PluginCapabilities = mode == .structure ? .schemaCompare : .dataCompare + guard !driver.capabilities.contains(required) else { return nil } + switch mode { + case .structure: + return String( + format: String(localized: "%@ does not report structure metadata that can be compared."), + endpointName + ) + case .data: + return String( + format: String(localized: "%@ does not support reading rows in key order, which data compare needs."), + endpointName + ) + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncEngineFamily.swift b/TablePro/Core/Compare/CompareSyncEngineFamily.swift new file mode 100644 index 000000000..7ca98f894 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncEngineFamily.swift @@ -0,0 +1,44 @@ +// +// CompareSyncEngineFamily.swift +// TablePro +// +// Which pairs of database types may generate a structure sync script. +// Column data types are driver-native strings, so generating DDL for one engine +// from another engine's metadata is unsound. Comparison stays available across +// engines as an informational read; only script generation is gated. +// + +import Foundation + +internal enum CompareSyncEngineFamily { + internal static func canGenerateStructureScript(from source: DatabaseType, to target: DatabaseType) -> Bool { + guard source != target else { return true } + return sameFamily(source, target) + } + + internal static func sameFamily(_ lhs: DatabaseType, _ rhs: DatabaseType) -> Bool { + guard lhs != rhs else { return true } + let key = [lhs.rawValue, rhs.rawValue].sorted().joined(separator: "\u{1F}") + return compatiblePairKeys.contains(key) + } + + private static let compatiblePairKeys: Set = { + let pairs: [[DatabaseType]] = [[.mysql, .mariadb]] + return Set(pairs.map { $0.map { $0.rawValue }.sorted().joined(separator: "\u{1F}") }) + }() + + internal static func structureScriptRefusal(from source: DatabaseType, to target: DatabaseType) -> String { + String( + format: String(localized: "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated."), + source.rawValue, target.rawValue + ) + } + + internal static func crossEngineDataWarning(from source: DatabaseType, to target: DatabaseType) -> String? { + guard source != target else { return nil } + return String( + format: String(localized: "Syncing data from %@ to %@. Value formatting can differ between engines; review the script before applying."), + source.rawValue, target.rawValue + ) + } +} diff --git a/TablePro/Core/Compare/CompareSyncExecutor.swift b/TablePro/Core/Compare/CompareSyncExecutor.swift new file mode 100644 index 000000000..431df3a9c --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -0,0 +1,213 @@ +// +// CompareSyncExecutor.swift +// TablePro +// +// Applies a generated sync script to the target connection. +// Authorization happens once for the whole run through ExecutionGate, and the +// statement loop stays inside that call so the task-local receipt remains bound +// for every statement. Cancellation is cooperative between statements: a driver +// already blocked in a C call cannot be interrupted. +// + +import Foundation +import os +import TableProPluginKit + +internal enum CompareSyncMode: String, Codable, Hashable, Sendable, CaseIterable { + case structure + case data + + internal var displayName: String { + switch self { + case .structure: return String(localized: "Structure") + case .data: return String(localized: "Data") + } + } +} + +internal struct CompareSyncExecutionSettings { + internal var errorHandling: ImportErrorHandling = .stopAndRollback + internal var wrapInTransaction = true + internal var allowedHazardStatementIds: Set = [] + + internal func canRun(_ statement: SyncStatement) -> Bool { + guard statement.isRefusedByDefault else { return true } + return allowedHazardStatementIds.contains(statement.id) + } + + internal func usesTransaction(supportsTransactions: Bool) -> Bool { + wrapInTransaction && supportsTransactions && errorHandling != .skipAndContinue + } +} + +internal struct SyncStatementOutcome: Identifiable { + internal let id: UUID + internal let statement: SyncStatement + internal let error: String? + internal let wasSkipped: Bool + + internal var succeeded: Bool { + error == nil && !wasSkipped + } +} + +internal struct CompareSyncRunResult { + internal let outcomes: [SyncStatementOutcome] + internal let rolledBack: Bool + internal let cancelled: Bool + + internal var executedCount: Int { + outcomes.filter { $0.succeeded }.count + } + + internal var failedCount: Int { + outcomes.filter { $0.error != nil }.count + } + + internal var heldBackCount: Int { + outcomes.filter { $0.wasSkipped }.count + } +} + +internal actor CompareSyncExecutor { + private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncExecutor") + + private let gate: ExecutionGate + + internal init(gate: ExecutionGate = ExecutionGateProvider.shared) { + self.gate = gate + } + + internal func apply( + statements: [SyncStatement], + mode: CompareSyncMode, + settings: CompareSyncExecutionSettings, + target: CompareSyncEndpoint, + driver: any PluginDatabaseDriver, + progress: Progress + ) async throws -> CompareSyncRunResult { + let runnable = statements.filter { settings.canRun($0) } + let heldBack = statements.filter { !settings.canRun($0) } + + guard !runnable.isEmpty else { + return CompareSyncRunResult( + outcomes: heldBack.map { SyncStatementOutcome(id: $0.id, statement: $0, error: nil, wasSkipped: true) }, + rolledBack: false, + cancelled: false + ) + } + + let request = OperationRequest( + connectionId: target.connectionId, + databaseType: target.databaseType, + sql: Self.digest(of: runnable), + kind: Self.kind(for: mode, statements: runnable, databaseType: target.databaseType), + caller: .userInterface, + capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], + operationDescription: String( + format: String(localized: "Apply %@ sync to %@"), + mode.displayName, target.displayName + ) + ) + + progress.totalUnitCount = Int64(runnable.count) + progress.completedUnitCount = 0 + progress.isCancellable = true + + let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .idleSystemSleepDisabled, .suddenTerminationDisabled], + reason: "Applying database sync" + ) + defer { ProcessInfo.processInfo.endActivity(activity) } + + return try await gate.authorizing(request) { + try await self.run( + runnable: runnable, + heldBack: heldBack, + settings: settings, + driver: driver, + progress: progress + ) + } + } + + private func run( + runnable: [SyncStatement], + heldBack: [SyncStatement], + settings: CompareSyncExecutionSettings, + driver: any PluginDatabaseDriver, + progress: Progress + ) async throws -> CompareSyncRunResult { + let usesTransaction = settings.usesTransaction(supportsTransactions: driver.supportsTransactions) + if usesTransaction { + try await driver.beginTransaction() + } + + var outcomes = heldBack.map { + SyncStatementOutcome(id: $0.id, statement: $0, error: nil, wasSkipped: true) + } + var completed: Int64 = 0 + var stopped = false + var cancelled = false + + for statement in runnable { + if progress.isCancelled || Task.isCancelled { + cancelled = true + break + } + do { + _ = try await driver.execute(query: statement.sql) + outcomes.append(SyncStatementOutcome( + id: statement.id, statement: statement, error: nil, wasSkipped: false + )) + } catch { + Self.logger.error("Sync statement failed: \(error.localizedDescription, privacy: .public)") + outcomes.append(SyncStatementOutcome( + id: statement.id, statement: statement, + error: error.localizedDescription, wasSkipped: false + )) + if settings.errorHandling != .skipAndContinue { + stopped = true + break + } + } + completed += 1 + if completed % Self.progressBatchSize == 0 || completed == Int64(runnable.count) { + progress.completedUnitCount = completed + } + } + progress.completedUnitCount = completed + + let shouldRollback = usesTransaction + && (cancelled || (stopped && settings.errorHandling == .stopAndRollback)) + if usesTransaction { + if shouldRollback { + try? await driver.rollbackTransaction() + } else { + try await driver.commitTransaction() + } + } + + return CompareSyncRunResult(outcomes: outcomes, rolledBack: shouldRollback, cancelled: cancelled) + } + + private static let progressBatchSize: Int64 = 25 + + private static func kind( + for mode: CompareSyncMode, + statements: [SyncStatement], + databaseType: DatabaseType + ) -> OperationKind { + guard mode == .data else { return .schemaMutation } + return OperationKind.worst(of: statements.map { $0.sql }, databaseType: databaseType) + } + + private static func digest(of statements: [SyncStatement]) -> String { + var digest = "" + for statement in statements { + guard digest.count < 10_000 else { break } + digest += statement.sql + "\n" + } + return digest + } +} diff --git a/TablePro/Core/Compare/CompareSyncLauncher.swift b/TablePro/Core/Compare/CompareSyncLauncher.swift new file mode 100644 index 000000000..0292191c8 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncLauncher.swift @@ -0,0 +1,32 @@ +// +// CompareSyncLauncher.swift +// TablePro +// +// Single entry point for opening Compare & Sync, so the license gate is +// enforced in one place no matter which menu or context menu was used. +// + +import AppKit +import Foundation + +@MainActor +internal enum CompareSyncLauncher { + internal static func open(prefillSource connectionId: UUID? = nil) { + guard LicenseManager.shared.isFeatureAvailable(.compareSync) else { + presentUpgradeAlert() + return + } + WindowOpener.shared.openCompareSync(prefillSource: connectionId) + } + + private static func presentUpgradeAlert() { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String(localized: "Compare & Sync requires a license") + alert.informativeText = ProFeature.compareSync.featureDescription + alert.addButton(withTitle: String(localized: "View Account")) + alert.addButton(withTitle: String(localized: "Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + WindowOpener.shared.openSettings(tab: .account) + } +} diff --git a/TablePro/Core/Compare/CompareSyncProfileStorage.swift b/TablePro/Core/Compare/CompareSyncProfileStorage.swift new file mode 100644 index 000000000..8a108910f --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncProfileStorage.swift @@ -0,0 +1,74 @@ +// +// CompareSyncProfileStorage.swift +// TablePro +// +// Named comparison setups, keyed by source, target, and mode. +// + +import Foundation +import os + +internal struct CompareSyncProfile: Codable, Hashable, Identifiable { + internal var id = UUID() + internal var name: String + internal var sourceConnectionId: UUID + internal var targetConnectionId: UUID + internal var mode: CompareSyncMode + internal var structureOptions: StructureCompareOptions + internal var dataOptions: DataCompareOptions + internal var selectedTables: [String] + + internal var storageKey: String { + "\(sourceConnectionId.uuidString)|\(targetConnectionId.uuidString)|\(mode.rawValue)" + } +} + +internal final class CompareSyncProfileStorage { + internal static let shared = CompareSyncProfileStorage() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncProfileStorage") + private static let defaultsKey = "compareSyncProfiles" + + private let defaults: UserDefaults + + internal init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + internal func allProfiles() -> [CompareSyncProfile] { + guard let data = defaults.data(forKey: Self.defaultsKey) else { return [] } + do { + return try JSONDecoder().decode([CompareSyncProfile].self, from: data) + } catch { + Self.logger.error("Failed to decode profiles: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + internal func profiles(source: UUID, target: UUID, mode: CompareSyncMode) -> [CompareSyncProfile] { + let key = "\(source.uuidString)|\(target.uuidString)|\(mode.rawValue)" + return allProfiles().filter { $0.storageKey == key } + } + + internal func save(_ profile: CompareSyncProfile) { + var profiles = allProfiles() + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + persist(profiles) + } + + internal func delete(_ profile: CompareSyncProfile) { + persist(allProfiles().filter { $0.id != profile.id }) + } + + private func persist(_ profiles: [CompareSyncProfile]) { + do { + defaults.set(try JSONEncoder().encode(profiles), forKey: Self.defaultsKey) + } catch { + Self.logger.error("Failed to encode profiles: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession+Data.swift b/TablePro/Core/Compare/CompareSyncSession+Data.swift new file mode 100644 index 000000000..76498ccd3 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession+Data.swift @@ -0,0 +1,220 @@ +// +// CompareSyncSession+Data.swift +// TablePro +// +// Data comparison. Rows are read in key order from both sides and walked in +// lockstep. A table with no usable key is listed as not comparable rather than +// matched on a guess. +// + +import Foundation +import TableProPluginKit + +internal struct DataComparePlan: Identifiable, Hashable { + internal let table: String + internal let schema: String? + internal var columns: [String] + internal var keyColumns: [String] + internal var isEnabled: Bool + internal var unavailableReason: String? + internal var summary: DataDiffSummary? + + internal var id: String { + guard let schema, !schema.isEmpty else { return table } + return "\(schema).\(table)" + } + + internal var isComparable: Bool { + unavailableReason == nil + } + + internal static func == (lhs: DataComparePlan, rhs: DataComparePlan) -> Bool { + lhs.id == rhs.id && lhs.keyColumns == rhs.keyColumns && lhs.isEnabled == rhs.isEnabled + } + + internal func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +internal extension CompareSyncSession { + func compareData() { + guard canCompare, let source, let target else { return } + cancelRunningWork() + errorMessage = nil + informationalMessage = nil + + runTask = Task { [weak self] in + guard let self else { return } + self.activity = .connecting + defer { self.activity = .idle } + + do { + let sourceDriver = try Self.dataDriver(for: source) + let targetDriver = try Self.dataDriver(for: target) + + if let refusal = CompareSyncEligibility.refusalReason( + for: sourceDriver, mode: .data, endpointName: source.displayName + ) ?? CompareSyncEligibility.refusalReason( + for: targetDriver, mode: .data, endpointName: target.displayName + ) { + self.errorMessage = refusal + return + } + + self.activity = .comparing + var plans = try await Self.buildPlans( + sourceDriver: sourceDriver, + targetDriver: targetDriver, + sourceSchema: source.schema, + targetSchema: target.schema, + existing: self.dataPlans + ) + + for index in plans.indices where plans[index].isEnabled && plans[index].isComparable { + try Task.checkCancellation() + plans[index].summary = try await self.runDataDiff( + plan: plans[index], + sourceDriver: sourceDriver, + targetDriver: targetDriver, + sourceSchema: source.schema, + targetSchema: target.schema + ) + } + + self.dataPlans = plans + let differences = plans.compactMap { $0.summary?.differenceCount }.reduce(0, +) + self.lastAction = .compared(Date(), differences: differences) + self.informationalMessage = self.crossEngineNotice + self.step = .review + } catch is CancellationError { + self.informationalMessage = String(localized: "Comparison cancelled.") + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + func generateDataScript() { + guard let target else { return } + errorMessage = nil + + runTask = Task { [weak self] in + guard let self else { return } + do { + let targetDriver = try Self.dataDriver(for: target) + var built: [SyncStatement] = [] + for plan in self.dataPlans where plan.isEnabled && plan.isComparable { + guard let summary = plan.summary else { continue } + var options = self.dataOptions + options.keyColumns = plan.keyColumns + let builder = DataSyncScriptBuilder(targetDriver: targetDriver, options: options) + built.append(contentsOf: builder.build( + table: plan.table, + schema: plan.schema, + writeColumns: plan.columns, + entries: summary.entries + )) + } + guard !built.isEmpty else { + self.errorMessage = String(localized: "Nothing is selected to apply.") + return + } + self.statements = built + self.editedScript = built.map { $0.sql }.joined(separator: "\n") + self.step = .script + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + var dataDifferenceTotal: Int { + dataPlans.compactMap { $0.summary?.differenceCount }.reduce(0, +) + } + + var truncatedPlanNames: [String] { + dataPlans.filter { $0.summary?.truncatedEntries == true }.map { $0.id } + } + + // MARK: - Helpers + + private func runDataDiff( + plan: DataComparePlan, + sourceDriver: any PluginDatabaseDriver, + targetDriver: any PluginDatabaseDriver, + sourceSchema: String?, + targetSchema: String? + ) async throws -> DataDiffSummary { + var options = dataOptions + options.keyColumns = plan.keyColumns + + let sourceQuery = KeyOrderedQuery.build( + table: plan.table, schema: sourceSchema, columns: plan.columns, + keyColumns: plan.keyColumns, driver: sourceDriver + ) + let targetQuery = KeyOrderedQuery.build( + table: plan.table, schema: targetSchema, columns: plan.columns, + keyColumns: plan.keyColumns, driver: targetDriver + ) + + let engine = DataDiffEngine(options: options, columns: plan.columns) + return try await engine.compare( + source: StreamingRowProvider(stream: sourceDriver.streamRows(query: sourceQuery), columns: plan.columns), + target: StreamingRowProvider(stream: targetDriver.streamRows(query: targetQuery), columns: plan.columns) + ) + } + + private static func dataDriver(for endpoint: CompareSyncEndpoint) throws -> any PluginDatabaseDriver { + guard let adapter = DatabaseManager.shared.driver(for: endpoint.connectionId) as? PluginDriverAdapter else { + throw CompareSyncError.unsupportedOperation(String( + format: String(localized: "Connect to %@ before comparing."), + endpoint.displayName + )) + } + return adapter.schemaPluginDriver + } + + private static func buildPlans( + sourceDriver: any PluginDatabaseDriver, + targetDriver: any PluginDatabaseDriver, + sourceSchema: String?, + targetSchema: String?, + existing: [DataComparePlan] + ) async throws -> [DataComparePlan] { + let sourceTables = try await sourceDriver.fetchTables(schema: sourceSchema) + let targetNames = Set(try await targetDriver.fetchTables(schema: targetSchema).map { $0.name.lowercased() }) + let previous = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + + var plans: [DataComparePlan] = [] + for table in sourceTables where targetNames.contains(table.name.lowercased()) { + try Task.checkCancellation() + let sourceColumns = try await sourceDriver.fetchColumns(table: table.name, schema: table.schema ?? sourceSchema) + let targetColumns = try await targetDriver.fetchColumns(table: table.name, schema: table.schema ?? targetSchema) + let targetNameSet = Set(targetColumns.map { $0.name.lowercased() }) + let shared = sourceColumns.filter { targetNameSet.contains($0.name.lowercased()) }.map { $0.name } + + let primaryKey = sourceColumns.filter { $0.isPrimaryKey }.map { $0.name } + let identifier = table.schema.map { "\($0).\(table.name)" } ?? table.name + let carried = previous[identifier] + + var plan = DataComparePlan( + table: table.name, + schema: table.schema ?? sourceSchema, + columns: shared, + keyColumns: carried?.keyColumns.isEmpty == false ? carried?.keyColumns ?? primaryKey : primaryKey, + isEnabled: carried?.isEnabled ?? true, + unavailableReason: nil, + summary: nil + ) + + if shared.isEmpty { + plan.unavailableReason = String(localized: "No columns in common.") + } else if plan.keyColumns.isEmpty { + plan.unavailableReason = String(localized: "No primary key. Choose key columns to compare this table.") + } + plans.append(plan) + } + return plans.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession+Run.swift b/TablePro/Core/Compare/CompareSyncSession+Run.swift new file mode 100644 index 000000000..bd8d270b8 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession+Run.swift @@ -0,0 +1,214 @@ +// +// CompareSyncSession+Run.swift +// TablePro +// +// Connecting, comparing, script generation, and applying. +// + +import Foundation +import TableProPluginKit + +internal extension CompareSyncSession { + func compare() { + guard canCompare, let source, let target else { return } + cancelRunningWork() + errorMessage = nil + informationalMessage = nil + + runTask = Task { [weak self] in + guard let self else { return } + self.activity = .connecting + defer { self.activity = .idle } + + do { + let sourceDriver = try Self.driver(for: source) + let targetDriver = try Self.driver(for: target) + + if let refusal = CompareSyncEligibility.refusalReason( + for: sourceDriver, mode: self.mode, endpointName: source.displayName + ) ?? CompareSyncEligibility.refusalReason( + for: targetDriver, mode: self.mode, endpointName: target.displayName + ) { + self.errorMessage = refusal + return + } + + self.activity = .comparing + let sourceSnapshots = try await Self.snapshots(from: sourceDriver, schema: source.schema) + let targetSnapshots = try await Self.snapshots(from: targetDriver, schema: target.schema) + try Task.checkCancellation() + + let engine = StructureDiffEngine(options: self.structureOptions) + let report = engine.compare(source: sourceSnapshots, target: targetSnapshots) + + self.structureReport = report + self.sourceSnapshotCache = Dictionary( + sourceSnapshots.map { ($0.qualifiedName, $0) }, + uniquingKeysWith: { first, _ in first } + ) + self.tableActions = [:] + self.statements = [] + self.editedScript = nil + self.lastAction = .compared(Date(), differences: report.comparable.filter { $0.status != .identical }.count) + self.informationalMessage = self.crossEngineNotice + self.step = .review + } catch is CancellationError { + self.informationalMessage = String(localized: "Comparison cancelled.") + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + func generateScript() { + guard let target, let report = structureReport else { return } + guard canGenerateStructureScript else { + errorMessage = crossEngineNotice + return + } + errorMessage = nil + + runTask = Task { [weak self] in + guard let self else { return } + do { + let targetDriver = try Self.driver(for: target) + let operations = self.operations(from: report) + guard !operations.isEmpty else { + self.errorMessage = String(localized: "Nothing is selected to apply.") + return + } + let foreignKeys = Self.foreignKeyMap(from: self.sourceSnapshotCache) + let builder = SchemaSyncScriptBuilder(targetDriver: targetDriver) + self.statements = try builder.build(operations: operations, foreignKeysByTable: foreignKeys) + self.editedScript = self.statements.map { $0.sql }.joined(separator: "\n") + self.step = .script + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + func apply() { + guard let target, !statements.isEmpty else { return } + cancelRunningWork() + errorMessage = nil + + let runProgress = Progress(totalUnitCount: Int64(statements.count)) + progress = runProgress + + runTask = Task { [weak self] in + guard let self else { return } + self.activity = .applying + self.hasWrittenToTarget = true + defer { + self.activity = .idle + self.progress = nil + } + + do { + let targetDriver = try Self.driver(for: target) + let result = try await self.executorRun( + statements: self.statements, + target: target, + driver: targetDriver, + progress: runProgress + ) + self.runResult = result + self.lastAction = .applied( + Date(), target: target.qualifiedDescription, statements: result.executedCount + ) + self.step = .apply + } catch { + self.errorMessage = error.localizedDescription + self.step = .apply + } + } + } + + // MARK: - Comparison + + private func operations(from report: StructureDiffReport) -> [SchemaSyncOperation] { + report.comparable.compactMap { result in + switch action(for: result) { + case .skip: + return nil + case .create: + guard let snapshot = sourceSnapshotCache[result.id] else { return nil } + return .createTable(snapshot) + case .drop: + return .dropTable(name: result.tableName, schema: result.schema) + case .alter: + guard !result.changes.isEmpty else { return nil } + return .alterTable(name: result.tableName, schema: result.schema, changes: result.changes) + } + } + } + + private func executorRun( + statements: [SyncStatement], + target: CompareSyncEndpoint, + driver: any PluginDatabaseDriver, + progress: Progress + ) async throws -> CompareSyncRunResult { + try await CompareSyncExecutor().apply( + statements: statements, + mode: mode, + settings: executionSettings, + target: target, + driver: driver, + progress: progress + ) + } + + // MARK: - Helpers + + private static func driver(for endpoint: CompareSyncEndpoint) throws -> any PluginDatabaseDriver { + guard let adapter = DatabaseManager.shared.driver(for: endpoint.connectionId) as? PluginDriverAdapter else { + throw CompareSyncError.unsupportedOperation(String( + format: String(localized: "Connect to %@ before comparing."), + endpoint.displayName + )) + } + return adapter.schemaPluginDriver + } + + private static func snapshots( + from driver: any PluginDatabaseDriver, + schema: String? + ) async throws -> [TableStructureSnapshot] { + let tables = try await driver.fetchTables(schema: schema) + var result: [TableStructureSnapshot] = [] + for table in tables { + try Task.checkCancellation() + let columns = try await driver.fetchColumns(table: table.name, schema: table.schema ?? schema) + let indexes = try await driver.fetchIndexes(table: table.name, schema: table.schema ?? schema) + let foreignKeys = try await driver.fetchForeignKeys(table: table.name, schema: table.schema ?? schema) + result.append(TableStructureSnapshot.from( + table: table, columns: columns, indexes: indexes, foreignKeys: foreignKeys + )) + } + return result + } + + private static func foreignKeyMap( + from snapshots: [String: TableStructureSnapshot] + ) -> [String: [PluginForeignKeyInfo]] { + var map: [String: [PluginForeignKeyInfo]] = [:] + for snapshot in snapshots.values { + let dependencies = snapshot.foreignKeys.compactMap { foreignKey -> PluginForeignKeyInfo? in + guard let column = foreignKey.columns.first, + let referencedColumn = foreignKey.referencedColumns.first else { return nil } + return PluginForeignKeyInfo( + name: foreignKey.name, + column: column, + referencedTable: foreignKey.referencedTable, + referencedColumn: referencedColumn, + referencedSchema: foreignKey.referencedSchema + ) + } + guard !dependencies.isEmpty else { continue } + map[snapshot.name] = dependencies + } + return map + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession.swift b/TablePro/Core/Compare/CompareSyncSession.swift new file mode 100644 index 000000000..cd0c3394b --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession.swift @@ -0,0 +1,226 @@ +// +// CompareSyncSession.swift +// TablePro +// +// Drives the compare and sync flow. Owns no cache: every comparison is a fresh +// read that replaces the previous result only once it has finished, so a re-run +// never blanks what is on screen before it has something to show. +// + +import Foundation +import Observation +import os +import SwiftUI +import TableProPluginKit + +internal enum CompareSyncStep: Int, CaseIterable, Comparable { + case setup + case review + case script + case apply + + internal static func < (lhs: CompareSyncStep, rhs: CompareSyncStep) -> Bool { + lhs.rawValue < rhs.rawValue + } + + internal var title: String { + switch self { + case .setup: return String(localized: "Setup") + case .review: return String(localized: "Review") + case .script: return String(localized: "Script") + case .apply: return String(localized: "Apply") + } + } +} + +internal enum CompareSyncActivity: Equatable { + case idle + case connecting + case comparing + case applying +} + +internal enum CompareSyncLastAction: Equatable { + case none + case compared(Date, differences: Int) + case applied(Date, target: String, statements: Int) +} + +@MainActor +@Observable +internal final class CompareSyncSession { + private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncSession") + + internal var step: CompareSyncStep = .setup + internal var mode: CompareSyncMode = .structure + internal var source: CompareSyncEndpoint? + internal var target: CompareSyncEndpoint? + internal var structureOptions = StructureCompareOptions.default + internal var dataOptions = DataCompareOptions.default + internal var executionSettings = CompareSyncExecutionSettings() + + internal var activity: CompareSyncActivity = .idle + internal var errorMessage: String? + internal var informationalMessage: String? + internal var lastAction: CompareSyncLastAction = .none + + internal var structureReport: StructureDiffReport? + internal var dataPlans: [DataComparePlan] = [] + internal var tableActions: [String: TableSyncAction] = [:] + internal var statements: [SyncStatement] = [] + internal var editedScript: String? + internal var runResult: CompareSyncRunResult? + internal var progress: Progress? + + internal var hasWrittenToTarget = false + internal var sourceSnapshotCache: [String: TableStructureSnapshot] = [:] + + internal var runTask: Task? + + internal init() {} + + // MARK: - Direction + + internal var directionSentence: String? { + guard let source, let target else { return nil } + return String( + format: String(localized: "Compare %@ and write changes to %@."), + source.qualifiedDescription, target.qualifiedDescription + ) + } + + internal var canSwap: Bool { + source != nil || target != nil + } + + internal func swapEndpoints() { + let previousSource = source + source = target + target = previousSource + resetComparison() + } + + internal var canCompare: Bool { + guard let source, let target else { return false } + guard target.canBeWrittenTo else { return false } + return source.id != target.id && activity == .idle + } + + internal var canGenerateStructureScript: Bool { + guard mode == .structure, let source, let target else { return false } + return CompareSyncEngineFamily.canGenerateStructureScript(from: source.databaseType, to: target.databaseType) + } + + internal var crossEngineNotice: String? { + guard let source, let target else { return nil } + if mode == .structure, !canGenerateStructureScript { + return CompareSyncEngineFamily.structureScriptRefusal(from: source.databaseType, to: target.databaseType) + } + return CompareSyncEngineFamily.crossEngineDataWarning(from: source.databaseType, to: target.databaseType) + } + + internal var bannerText: String { + switch activity { + case .applying: + return String( + format: String(localized: "Applying to %@..."), + target?.qualifiedDescription ?? "" + ) + case .comparing, .connecting: + return String(localized: "Comparing only. Nothing has been written.") + case .idle: + return idleBannerText + } + } + + private var idleBannerText: String { + switch lastAction { + case .none: + return String(localized: "Comparing only. Nothing has been written.") + case .compared(let date, let differences): + return String( + format: String(localized: "Compared %@. %d differences. Nothing has been written."), + Self.timeFormatter.string(from: date), differences + ) + case .applied(let date, let name, let statements): + return String( + format: String(localized: "Applied to %@ at %@. %d statements."), + name, Self.timeFormatter.string(from: date), statements + ) + } + } + + // MARK: - Selection + + internal func action(for result: TableDiffResult) -> TableSyncAction { + tableActions[result.id] ?? .skip + } + + internal func setAction(_ action: TableSyncAction, for result: TableDiffResult) { + tableActions[result.id] = action + statements = [] + editedScript = nil + } + + internal var selectedTableCount: Int { + switch mode { + case .structure: + return tableActions.values.filter { $0 != .skip }.count + case .data: + return dataPlans.filter { $0.isEnabled && $0.isComparable && ($0.summary?.differenceCount ?? 0) > 0 }.count + } + } + + internal func runComparison() { + switch mode { + case .structure: compare() + case .data: compareData() + } + } + + internal func buildScript() { + switch mode { + case .structure: generateScript() + case .data: generateDataScript() + } + } + + internal var canBuildScript: Bool { + switch mode { + case .structure: + return selectedTableCount > 0 && canGenerateStructureScript + case .data: + return selectedTableCount > 0 + } + } + + internal func resetComparison() { + structureReport = nil + sourceSnapshotCache = [:] + dataPlans = [] + tableActions = [:] + statements = [] + editedScript = nil + runResult = nil + errorMessage = nil + step = .setup + } + + // MARK: - Lifecycle + + internal func cancelRunningWork() { + progress?.cancel() + runTask?.cancel() + } + + internal var isBusy: Bool { + activity != .idle + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter + }() +} diff --git a/TablePro/Core/Compare/DataCompareOptions.swift b/TablePro/Core/Compare/DataCompareOptions.swift new file mode 100644 index 000000000..bf56bba52 --- /dev/null +++ b/TablePro/Core/Compare/DataCompareOptions.swift @@ -0,0 +1,151 @@ +// +// DataCompareOptions.swift +// TablePro +// +// How two rows are matched and how two values are judged equal. +// The set of columns used to compare is deliberately separate from the set +// written: an audit column can be excluded from matching while still being +// carried into the generated statement. +// + +import Foundation +import TableProPluginKit + +internal struct DataCompareOptions: Codable, Hashable, Sendable { + internal var keyColumns: [String] = [] + internal var excludedFromComparison: Set = [] + internal var insertMissingRows = true + internal var updateDifferingRows = true + internal var deleteExtraRows = false + internal var floatTolerance: Double = 0 + internal var timestampFractionalDigits = 6 + internal var maxRetainedEntries = 5_000 + + internal init() {} + + internal static let `default` = DataCompareOptions() + + internal var hasKey: Bool { + !keyColumns.isEmpty + } + + internal func comparisonColumns(from columns: [String]) -> [String] { + let keys = Set(keyColumns.map { $0.lowercased() }) + return columns.filter { column in + let lowered = column.lowercased() + return !keys.contains(lowered) && !excludedFromComparison.contains(where: { $0.lowercased() == lowered }) + } + } +} + +internal enum ComparisonRule: String, Codable, Hashable, Sendable { + case exactValue + case nullEquality + case floatTolerance + case timestampPrecision + case binaryContent + case typeMismatch + + internal var displayName: String { + switch self { + case .exactValue: + return String(localized: "Exact value") + case .nullEquality: + return String(localized: "NULL only equals NULL") + case .floatTolerance: + return String(localized: "Numeric tolerance") + case .timestampPrecision: + return String(localized: "Timestamp precision") + case .binaryContent: + return String(localized: "Binary content") + case .typeMismatch: + return String(localized: "Value kind differs") + } + } +} + +internal struct ValueComparison { + internal let isEqual: Bool + internal let rule: ComparisonRule +} + +internal struct CellValueComparator { + private let options: DataCompareOptions + + internal init(options: DataCompareOptions) { + self.options = options + } + + internal func compare(_ lhs: PluginCellValue, _ rhs: PluginCellValue) -> ValueComparison { + switch (lhs, rhs) { + case (.null, .null): + return ValueComparison(isEqual: true, rule: .nullEquality) + case (.null, _), (_, .null): + return ValueComparison(isEqual: false, rule: .nullEquality) + case (.bytes(let left), .bytes(let right)): + return ValueComparison(isEqual: left == right, rule: .binaryContent) + case (.text(let left), .text(let right)): + return compareText(left, right) + default: + return ValueComparison(isEqual: false, rule: .typeMismatch) + } + } + + private func compareText(_ lhs: String, _ rhs: String) -> ValueComparison { + if lhs == rhs { + return ValueComparison(isEqual: true, rule: .exactValue) + } + if options.floatTolerance > 0, + let left = Double(lhs.trimmingCharacters(in: .whitespaces)), + let right = Double(rhs.trimmingCharacters(in: .whitespaces)) { + let equal = (left - right).magnitude <= options.floatTolerance + return ValueComparison(isEqual: equal, rule: .floatTolerance) + } + if let left = TimestampValue.parse(lhs), let right = TimestampValue.parse(rhs) { + let equal = left.equals(right, fractionalDigits: options.timestampFractionalDigits) + return ValueComparison(isEqual: equal, rule: .timestampPrecision) + } + return ValueComparison(isEqual: false, rule: .exactValue) + } +} + +internal struct TimestampValue: Hashable { + internal let secondsSinceEpoch: Double + + internal func equals(_ other: TimestampValue, fractionalDigits: Int) -> Bool { + let scale = pow(10.0, Double(max(0, min(9, fractionalDigits)))) + return (secondsSinceEpoch * scale).rounded() == (other.secondsSinceEpoch * scale).rounded() + } + + internal static func parse(_ raw: String) -> TimestampValue? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 10 else { return nil } + for formatter in Self.formatters { + if let date = formatter.date(from: trimmed) { + return TimestampValue(secondsSinceEpoch: date.timeIntervalSince1970) + } + } + return nil + } + + private static let formatters: [DateFormatter] = { + let patterns = [ + "yyyy-MM-dd HH:mm:ss.SSSSSSXXXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", + "yyyy-MM-dd HH:mm:ssXXXXX", + "yyyy-MM-dd'T'HH:mm:ssXXXXX", + "yyyy-MM-dd HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd" + ] + return patterns.map { pattern in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = pattern + return formatter + } + }() +} diff --git a/TablePro/Core/Compare/DataDiffEngine.swift b/TablePro/Core/Compare/DataDiffEngine.swift new file mode 100644 index 000000000..b0db8c818 --- /dev/null +++ b/TablePro/Core/Compare/DataDiffEngine.swift @@ -0,0 +1,270 @@ +// +// DataDiffEngine.swift +// TablePro +// +// Key-ordered merge join over two row providers. Both sides are read in key +// order and walked in lockstep, so neither side is ever materialized and no +// server-side hash function has to agree between two engines. +// + +import Foundation +import TableProPluginKit + +internal struct DataRow: Hashable { + internal let values: [String: PluginCellValue] + + internal func value(for column: String) -> PluginCellValue { + if let exact = values[column] { return exact } + let lowered = column.lowercased() + guard let match = values.first(where: { $0.key.lowercased() == lowered }) else { return .null } + return match.value + } +} + +internal protocol DataRowProviding: AnyObject { + func nextRow() async throws -> DataRow? +} + +internal enum RowDiffKind: String, Codable, Hashable, Sendable { + case insert + case update + case delete + case identical +} + +internal struct CellDifference: Hashable { + internal let column: String + internal let rule: ComparisonRule + internal let sourceValue: PluginCellValue + internal let targetValue: PluginCellValue +} + +internal struct RowDiffEntry: Identifiable, Hashable { + internal let id: UUID + internal let kind: RowDiffKind + internal let keyDescription: String + internal let sourceRow: DataRow? + internal let targetRow: DataRow? + internal let cellDifferences: [CellDifference] + + internal init( + id: UUID = UUID(), + kind: RowDiffKind, + keyDescription: String, + sourceRow: DataRow?, + targetRow: DataRow?, + cellDifferences: [CellDifference] = [] + ) { + self.id = id + self.kind = kind + self.keyDescription = keyDescription + self.sourceRow = sourceRow + self.targetRow = targetRow + self.cellDifferences = cellDifferences + } +} + +internal struct DataDiffSummary { + internal let insertCount: Int + internal let updateCount: Int + internal let deleteCount: Int + internal let identicalCount: Int + internal let entries: [RowDiffEntry] + internal let truncatedEntries: Bool + + internal var differenceCount: Int { + insertCount + updateCount + deleteCount + } + + internal var totalCount: Int { + differenceCount + identicalCount + } +} + +internal struct DataDiffEngine { + private let options: DataCompareOptions + private let comparator: CellValueComparator + private let comparisonColumns: [String] + + internal init(options: DataCompareOptions, columns: [String]) { + self.options = options + self.comparator = CellValueComparator(options: options) + self.comparisonColumns = options.comparisonColumns(from: columns) + } + + internal func compare( + source: DataRowProviding, + target: DataRowProviding + ) async throws -> DataDiffSummary { + guard options.hasKey else { + throw CompareSyncError.noComparisonKey(String(localized: "Choose a key column before comparing data.")) + } + + var accumulator = Accumulator(limit: options.maxRetainedEntries) + var sourceRow = try await source.nextRow() + var targetRow = try await target.nextRow() + + while sourceRow != nil || targetRow != nil { + try Task.checkCancellation() + + guard let left = sourceRow else { + accumulator.add(deleteEntry(for: targetRow)) + targetRow = try await target.nextRow() + continue + } + guard let right = targetRow else { + accumulator.add(insertEntry(for: left)) + sourceRow = try await source.nextRow() + continue + } + + let leftKey = keyComponents(of: left) + let rightKey = keyComponents(of: right) + switch KeyOrdering.compare(leftKey, rightKey) { + case .orderedSame: + accumulator.add(matchedEntry(source: left, target: right)) + sourceRow = try await source.nextRow() + targetRow = try await target.nextRow() + case .orderedAscending: + accumulator.add(insertEntry(for: left)) + sourceRow = try await source.nextRow() + case .orderedDescending: + accumulator.add(deleteEntry(for: right)) + targetRow = try await target.nextRow() + } + } + + return accumulator.summary() + } + + private func insertEntry(for row: DataRow) -> RowDiffEntry { + RowDiffEntry( + kind: .insert, + keyDescription: keyDescription(of: row), + sourceRow: row, + targetRow: nil + ) + } + + private func deleteEntry(for row: DataRow?) -> RowDiffEntry { + RowDiffEntry( + kind: .delete, + keyDescription: row.map { keyDescription(of: $0) } ?? "", + sourceRow: nil, + targetRow: row + ) + } + + private func matchedEntry(source: DataRow, target: DataRow) -> RowDiffEntry { + var differences: [CellDifference] = [] + for column in comparisonColumns { + let sourceValue = source.value(for: column) + let targetValue = target.value(for: column) + let outcome = comparator.compare(sourceValue, targetValue) + guard !outcome.isEqual else { continue } + differences.append(CellDifference( + column: column, + rule: outcome.rule, + sourceValue: sourceValue, + targetValue: targetValue + )) + } + return RowDiffEntry( + kind: differences.isEmpty ? .identical : .update, + keyDescription: keyDescription(of: source), + sourceRow: source, + targetRow: target, + cellDifferences: differences + ) + } + + private func keyComponents(of row: DataRow) -> [PluginCellValue] { + options.keyColumns.map { row.value(for: $0) } + } + + private func keyDescription(of row: DataRow) -> String { + options.keyColumns + .map { column in KeyOrdering.sortKey(row.value(for: column)) } + .joined(separator: ", ") + } +} + +private extension DataDiffEngine { + struct Accumulator { + private let limit: Int + private var insertCount = 0 + private var updateCount = 0 + private var deleteCount = 0 + private var identicalCount = 0 + private var entries: [RowDiffEntry] = [] + private var truncated = false + + init(limit: Int) { + self.limit = limit + } + + mutating func add(_ entry: RowDiffEntry) { + switch entry.kind { + case .insert: insertCount += 1 + case .update: updateCount += 1 + case .delete: deleteCount += 1 + case .identical: identicalCount += 1 + } + guard entries.count < limit else { + truncated = true + return + } + entries.append(entry) + } + + func summary() -> DataDiffSummary { + DataDiffSummary( + insertCount: insertCount, + updateCount: updateCount, + deleteCount: deleteCount, + identicalCount: identicalCount, + entries: entries, + truncatedEntries: truncated + ) + } + } +} + +internal enum KeyOrdering { + internal static func compare(_ lhs: [PluginCellValue], _ rhs: [PluginCellValue]) -> ComparisonResult { + for (left, right) in zip(lhs, rhs) { + let leftKey = sortKey(left) + let rightKey = sortKey(right) + if leftKey == rightKey { continue } + if let leftNumber = Double(leftKey), let rightNumber = Double(rightKey) { + return leftNumber < rightNumber ? .orderedAscending : .orderedDescending + } + return leftKey < rightKey ? .orderedAscending : .orderedDescending + } + if lhs.count == rhs.count { return .orderedSame } + return lhs.count < rhs.count ? .orderedAscending : .orderedDescending + } + + internal static func sortKey(_ value: PluginCellValue) -> String { + switch value { + case .null: return "" + case .text(let text): return text + case .bytes(let data): return data.base64EncodedString() + } + } +} + +internal final class ArrayRowProvider: DataRowProviding { + private var rows: [DataRow] + private var index = 0 + + internal init(rows: [DataRow]) { + self.rows = rows + } + + internal func nextRow() async throws -> DataRow? { + guard index < rows.count else { return nil } + defer { index += 1 } + return rows[index] + } +} diff --git a/TablePro/Core/Compare/DataSyncScriptBuilder.swift b/TablePro/Core/Compare/DataSyncScriptBuilder.swift new file mode 100644 index 000000000..8d2158321 --- /dev/null +++ b/TablePro/Core/Compare/DataSyncScriptBuilder.swift @@ -0,0 +1,128 @@ +// +// DataSyncScriptBuilder.swift +// TablePro +// +// Turns row differences into DML for the target. Inserts run before updates +// before deletes, and parent tables before the tables that reference them. +// + +import Foundation +import TableProPluginKit + +internal struct DataSyncScriptBuilder { + private let targetDriver: any PluginDatabaseDriver + private let options: DataCompareOptions + + internal init(targetDriver: any PluginDatabaseDriver, options: DataCompareOptions) { + self.targetDriver = targetDriver + self.options = options + } + + internal func build( + table: String, + schema: String?, + writeColumns: [String], + entries: [RowDiffEntry] + ) -> [SyncStatement] { + var inserts: [SyncStatement] = [] + var updates: [SyncStatement] = [] + var deletes: [SyncStatement] = [] + + for entry in entries { + switch entry.kind { + case .insert: + guard options.insertMissingRows, let row = entry.sourceRow else { continue } + inserts.append(insertStatement(table: table, schema: schema, columns: writeColumns, row: row, entry: entry)) + case .update: + guard options.updateDifferingRows, let row = entry.sourceRow else { continue } + guard let statement = updateStatement( + table: table, schema: schema, columns: writeColumns, row: row, entry: entry + ) else { continue } + updates.append(statement) + case .delete: + guard options.deleteExtraRows, let row = entry.targetRow else { continue } + guard let statement = deleteStatement(table: table, schema: schema, row: row, entry: entry) else { continue } + deletes.append(statement) + case .identical: + continue + } + } + return inserts + updates + deletes + } + + private func qualified(_ table: String, _ schema: String?) -> String { + guard let schema, !schema.isEmpty else { return targetDriver.quoteIdentifier(table) } + return "\(targetDriver.quoteIdentifier(schema)).\(targetDriver.quoteIdentifier(table))" + } + + private func insertStatement( + table: String, + schema: String?, + columns: [String], + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement { + let columnList = columns.map { targetDriver.quoteIdentifier($0) }.joined(separator: ", ") + let valueList = columns.map { targetDriver.sqlLiteral(for: row.value(for: $0)) }.joined(separator: ", ") + return SyncStatement( + sql: "INSERT INTO \(qualified(table, schema)) (\(columnList)) VALUES (\(valueList));", + objectName: table, + summary: String(format: String(localized: "Insert row %@ into %@"), entry.keyDescription, table) + ) + } + + private func updateStatement( + table: String, + schema: String?, + columns: [String], + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement? { + let keySet = Set(options.keyColumns.map { $0.lowercased() }) + let assignable = columns.filter { !keySet.contains($0.lowercased()) } + guard !assignable.isEmpty else { return nil } + let assignments = assignable + .map { "\(targetDriver.quoteIdentifier($0)) = \(targetDriver.sqlLiteral(for: row.value(for: $0)))" } + .joined(separator: ", ") + guard let predicate = keyPredicate(row: row) else { return nil } + return SyncStatement( + sql: "UPDATE \(qualified(table, schema)) SET \(assignments) WHERE \(predicate);", + objectName: table, + summary: String(format: String(localized: "Update row %@ in %@"), entry.keyDescription, table) + ) + } + + private func deleteStatement( + table: String, + schema: String?, + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement? { + guard let predicate = keyPredicate(row: row) else { return nil } + return SyncStatement( + sql: "DELETE FROM \(qualified(table, schema)) WHERE \(predicate);", + objectName: table, + summary: String(format: String(localized: "Delete row %@ from %@"), entry.keyDescription, table), + hazards: [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Deleting row %@ from %@ permanently removes it."), + entry.keyDescription, table + ) + )] + ) + } + + private func keyPredicate(row: DataRow) -> String? { + guard !options.keyColumns.isEmpty else { return nil } + return options.keyColumns + .map { column -> String in + let quoted = targetDriver.quoteIdentifier(column) + let value = row.value(for: column) + if case .null = value { return "\(quoted) IS NULL" } + return "\(quoted) = \(targetDriver.sqlLiteral(for: value))" + } + .joined(separator: " AND ") + } +} diff --git a/TablePro/Core/Compare/SchemaSyncOperation.swift b/TablePro/Core/Compare/SchemaSyncOperation.swift new file mode 100644 index 000000000..f67d6f812 --- /dev/null +++ b/TablePro/Core/Compare/SchemaSyncOperation.swift @@ -0,0 +1,76 @@ +// +// SchemaSyncOperation.swift +// TablePro +// +// Table-level sync operations. SchemaChange covers everything inside one +// table; this covers the table's own lifecycle, which SchemaStatementGenerator +// deliberately does not model because it is scoped to a single fixed table. +// + +import Foundation +import TableProPluginKit + +internal enum SchemaSyncOperation: Identifiable { + case createTable(TableStructureSnapshot) + case dropTable(name: String, schema: String?) + case alterTable(name: String, schema: String?, changes: [SchemaChange]) + + internal var id: String { + switch self { + case .createTable(let snapshot): return "create-\(snapshot.qualifiedName)" + case .dropTable(let name, let schema): return "drop-\(Self.qualify(name, schema))" + case .alterTable(let name, let schema, _): return "alter-\(Self.qualify(name, schema))" + } + } + + internal var tableName: String { + switch self { + case .createTable(let snapshot): return snapshot.name + case .dropTable(let name, _): return name + case .alterTable(let name, _, _): return name + } + } + + internal var schema: String? { + switch self { + case .createTable(let snapshot): return snapshot.schema + case .dropTable(_, let schema): return schema + case .alterTable(_, let schema, _): return schema + } + } + + private static func qualify(_ name: String, _ schema: String?) -> String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } +} + +internal struct SyncStatement: Identifiable, Hashable { + internal let id: UUID + internal let sql: String + internal let objectName: String + internal let summary: String + internal let hazards: [SyncHazard] + + internal init( + id: UUID = UUID(), + sql: String, + objectName: String, + summary: String, + hazards: [SyncHazard] = [] + ) { + self.id = id + self.sql = sql + self.objectName = objectName + self.summary = summary + self.hazards = hazards + } + + internal var isRefusedByDefault: Bool { + hazards.contains { $0.severity == .refusedByDefault } + } + + internal var highestSeverity: SyncHazardSeverity? { + hazards.map { $0.severity }.max() + } +} diff --git a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift new file mode 100644 index 000000000..647ca7c5d --- /dev/null +++ b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -0,0 +1,181 @@ +// +// SchemaSyncScriptBuilder.swift +// TablePro +// +// Turns table-level sync operations into dialect-correct statements for the +// target driver. Ordering across tables follows foreign key dependencies; +// ordering inside one table is delegated to SchemaStatementGenerator. +// + +import Foundation +import TableProPluginKit + +internal struct SchemaSyncScriptBuilder { + private let targetDriver: any PluginDatabaseDriver + private let classifier: SyncSafetyClassifier + + internal init(targetDriver: any PluginDatabaseDriver, classifier: SyncSafetyClassifier = SyncSafetyClassifier()) { + self.targetDriver = targetDriver + self.classifier = classifier + } + + internal func build( + operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]] + ) throws -> [SyncStatement] { + let ordered = Self.order(operations: operations, foreignKeysByTable: foreignKeysByTable) + var statements: [SyncStatement] = [] + for operation in ordered { + statements.append(contentsOf: try build(operation: operation)) + } + return statements + } + + internal static func order( + operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]] + ) -> [SchemaSyncOperation] { + var drops: [SchemaSyncOperation] = [] + var creates: [SchemaSyncOperation] = [] + var alters: [SchemaSyncOperation] = [] + for operation in operations { + switch operation { + case .dropTable: drops.append(operation) + case .createTable: creates.append(operation) + case .alterTable: alters.append(operation) + } + } + return sorted(drops, foreignKeysByTable: foreignKeysByTable, childrenFirst: true) + + sorted(creates, foreignKeysByTable: foreignKeysByTable, childrenFirst: false) + + sorted(alters, foreignKeysByTable: foreignKeysByTable, childrenFirst: false) + } + + private static func sorted( + _ operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]], + childrenFirst: Bool + ) -> [SchemaSyncOperation] { + guard operations.count > 1 else { return operations } + let byName = Dictionary( + operations.map { ($0.tableName, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let ordered = ForeignKeyTopologicalSort.orderedNames( + operations.map { $0.tableName }, + foreignKeysByTable: foreignKeysByTable + ) + let resolved = ordered.compactMap { byName[$0] } + return childrenFirst ? resolved.reversed() : resolved + } + + private func build(operation: SchemaSyncOperation) throws -> [SyncStatement] { + switch operation { + case .createTable(let snapshot): + return try createStatements(for: snapshot) + case .dropTable(let name, let schema): + return dropStatements(name: name, schema: schema) + case .alterTable(let name, let schema, let changes): + return try alterStatements(name: name, schema: schema, changes: changes) + } + } + + private func createStatements(for snapshot: TableStructureSnapshot) throws -> [SyncStatement] { + let definition = PluginCreateTableDefinition( + tableName: snapshot.name, + columns: snapshot.columns.map { $0.toPlugin() }, + indexes: snapshot.indexes.filter { !$0.isPrimary }.map { $0.toPlugin() }, + foreignKeys: snapshot.foreignKeys.map { $0.toPlugin() }, + primaryKeyColumns: snapshot.primaryKeyColumns, + engine: snapshot.engine, + charset: snapshot.charset, + collation: snapshot.collation + ) + guard let sql = targetDriver.generateCreateTableSQL(definition: definition) else { + throw CompareSyncError.unsupportedOperation(String( + format: String(localized: "The target does not support creating table %@."), + snapshot.name + )) + } + return [SyncStatement( + sql: Self.terminated(sql), + objectName: snapshot.qualifiedName, + summary: String(format: String(localized: "Create table %@"), snapshot.name) + )] + } + + private func dropStatements(name: String, schema: String?) -> [SyncStatement] { + guard let sql = targetDriver.dropObjectStatement( + name: name, objectType: "TABLE", schema: schema, cascade: false + ) else { return [] } + return [SyncStatement( + sql: Self.terminated(sql), + objectName: name, + summary: String(format: String(localized: "Drop table %@"), name), + hazards: classifier.hazards(forDropping: name) + )] + } + + private func alterStatements( + name: String, + schema: String?, + changes: [SchemaChange] + ) throws -> [SyncStatement] { + let generator = SchemaStatementGenerator(tableName: name, pluginDriver: targetDriver) + var statements: [SyncStatement] = [] + for change in SchemaChangeOrdering.sorted(changes) { + let hazards = classifier.hazards(for: change) + let generated = try generator.generate(changes: [change]) + for statement in generated { + statements.append(SyncStatement( + sql: Self.terminated(statement.sql), + objectName: name, + summary: statement.description, + hazards: hazards + )) + } + } + return statements + } + + private static func terminated(_ sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasSuffix(";") ? trimmed : trimmed + ";" + } +} + +internal enum SchemaChangeOrdering { + internal static func sorted(_ changes: [SchemaChange]) -> [SchemaChange] { + var buckets: [[SchemaChange]] = Array(repeating: [], count: 8) + for change in changes { + buckets[bucket(for: change)].append(change) + } + return buckets.flatMap { $0 } + } + + private static func bucket(for change: SchemaChange) -> Int { + switch change { + case .deleteForeignKey, .modifyForeignKey: return 0 + case .deleteIndex, .modifyIndex: return 1 + case .deleteColumn: return 2 + case .modifyColumn: return 3 + case .addColumn: return 4 + case .modifyPrimaryKey: return 5 + case .addIndex: return 6 + case .addForeignKey: return 7 + } + } +} + +internal enum CompareSyncError: LocalizedError { + case unsupportedOperation(String) + case incompatibleEngines(String) + case noComparisonKey(String) + + internal var errorDescription: String? { + switch self { + case .unsupportedOperation(let message): return message + case .incompatibleEngines(let message): return message + case .noComparisonKey(let message): return message + } + } +} diff --git a/TablePro/Core/Compare/StreamingRowProvider.swift b/TablePro/Core/Compare/StreamingRowProvider.swift new file mode 100644 index 000000000..8f01f4483 --- /dev/null +++ b/TablePro/Core/Compare/StreamingRowProvider.swift @@ -0,0 +1,80 @@ +// +// StreamingRowProvider.swift +// TablePro +// +// Feeds the merge join from a driver's row stream. Only the current batch is +// held, so neither side of a comparison is ever materialized in full. +// + +import Foundation +import TableProPluginKit + +internal final class StreamingRowProvider: DataRowProviding { + private var iterator: AsyncThrowingStream.AsyncIterator + private var columns: [String] + private var buffer: [DataRow] = [] + private var bufferIndex = 0 + private var isFinished = false + + internal init(stream: AsyncThrowingStream, columns: [String] = []) { + self.iterator = stream.makeAsyncIterator() + self.columns = columns + } + + internal func nextRow() async throws -> DataRow? { + while bufferIndex >= buffer.count { + guard !isFinished else { return nil } + try await fillBuffer() + } + defer { bufferIndex += 1 } + return buffer[bufferIndex] + } + + private func fillBuffer() async throws { + buffer = [] + bufferIndex = 0 + while buffer.isEmpty { + guard let element = try await iterator.next() else { + isFinished = true + return + } + switch element { + case .header(let header): + if columns.isEmpty { columns = header.columns } + case .rows(let rows): + buffer = rows.map { row in + var values: [String: PluginCellValue] = [:] + for (index, column) in columns.enumerated() where index < row.count { + values[column] = row[index] + } + return DataRow(values: values) + } + } + } + } +} + +internal enum KeyOrderedQuery { + internal static func build( + table: String, + schema: String?, + columns: [String], + keyColumns: [String], + driver: any PluginDatabaseDriver + ) -> String { + let columnList = columns.isEmpty + ? "*" + : columns.map { driver.quoteIdentifier($0) }.joined(separator: ", ") + let orderBy = keyColumns.map { driver.quoteIdentifier($0) }.joined(separator: ", ") + var sql = "SELECT \(columnList) FROM \(qualified(table, schema, driver))" + if !orderBy.isEmpty { + sql += " ORDER BY \(orderBy)" + } + return sql + } + + private static func qualified(_ table: String, _ schema: String?, _ driver: any PluginDatabaseDriver) -> String { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" + } +} diff --git a/TablePro/Core/Compare/StructureCompareOptions.swift b/TablePro/Core/Compare/StructureCompareOptions.swift new file mode 100644 index 000000000..4d3a44f11 --- /dev/null +++ b/TablePro/Core/Compare/StructureCompareOptions.swift @@ -0,0 +1,72 @@ +// +// StructureCompareOptions.swift +// TablePro +// +// Normalization rules applied before two structures are considered different. +// Every option defaults to ignoring the difference, because the metadata these +// cover drifts between environments by design. +// + +import Foundation + +internal struct StructureCompareOptions: Codable, Hashable, Sendable { + internal var ignoreIdentifierCase = true + internal var ignoreColumnOrder = true + internal var ignoreWhitespaceInText = true + internal var ignoreAutoIncrementSeed = true + internal var ignoreCollationAndCharset = true + internal var ignoreCommentsAndOwners = true + + internal static let `default` = StructureCompareOptions() +} + +internal extension StructureCompareOptions { + func matchKey(_ identifier: String) -> String { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + return ignoreIdentifierCase ? trimmed.lowercased() : trimmed + } + + func normalizedText(_ value: String?) -> String? { + guard let value else { return nil } + var result = value + if ignoreAutoIncrementSeed { + result = Self.autoIncrementSeed.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "AUTO_INCREMENT" + ) + } + if ignoreWhitespaceInText { + result = Self.whitespaceRun.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: " " + ) + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + } + return result.isEmpty ? nil : result + } + + func normalizedType(_ dataType: String) -> String { + let collapsed = Self.whitespaceRun.stringByReplacingMatches( + in: dataType, + range: NSRange(dataType.startIndex..., in: dataType), + withTemplate: " " + ) + return collapsed.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + func columnListKey(_ columns: [String]) -> String { + columns.map { matchKey($0) }.joined(separator: "\u{1F}") + } + + private static let whitespaceRun = makeRegex("\\s+") + private static let autoIncrementSeed = makeRegex("(?i)AUTO_INCREMENT\\s*=\\s*\\d+") + + private static func makeRegex(_ pattern: String) -> NSRegularExpression { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + preconditionFailure("StructureCompareOptions pattern failed to compile: \(pattern)") + } + return regex + } +} diff --git a/TablePro/Core/Compare/StructureDiffEngine+Members.swift b/TablePro/Core/Compare/StructureDiffEngine+Members.swift new file mode 100644 index 000000000..8f24739d8 --- /dev/null +++ b/TablePro/Core/Compare/StructureDiffEngine+Members.swift @@ -0,0 +1,167 @@ +// +// StructureDiffEngine+Members.swift +// TablePro +// +// Column, primary key, index and foreign key comparison. +// Indexes and foreign keys are matched on structure rather than on name, so a +// system-generated name difference never reports an object as changed. +// + +import Foundation + +internal extension StructureDiffEngine { + struct MemberOutcome { + internal let changes: [SchemaChange] + internal let notes: [String] + } + + func columnChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [SchemaChange] { + let targetByKey = Dictionary( + target.columns.map { (options.matchKey($0.name), $0) }, + uniquingKeysWith: { first, _ in first } + ) + let sourceKeys = Set(source.columns.map { options.matchKey($0.name) }) + + var changes: [SchemaChange] = [] + for column in source.columns { + guard let existing = targetByKey[options.matchKey(column.name)] else { + changes.append(.addColumn(column)) + continue + } + guard columnSignature(column) != columnSignature(existing) else { continue } + changes.append(.modifyColumn(old: existing, new: column)) + } + for column in target.columns where !sourceKeys.contains(options.matchKey(column.name)) { + changes.append(.deleteColumn(column)) + } + return changes + } + + func primaryKeyChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [SchemaChange] { + let sourceKey = source.primaryKeyColumns + let targetKey = target.primaryKeyColumns + guard options.columnListKey(sourceKey) != options.columnListKey(targetKey) else { return [] } + return [.modifyPrimaryKey(old: targetKey, new: sourceKey)] + } + + func indexChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> MemberOutcome { + let sourceIndexes = source.indexes.filter { !$0.isPrimary } + let targetIndexes = target.indexes.filter { !$0.isPrimary } + + var remaining = targetIndexes + var changes: [SchemaChange] = [] + var notes: [String] = [] + + for index in sourceIndexes { + let signature = indexSignature(index) + guard let position = remaining.firstIndex(where: { indexSignature($0) == signature }) else { + changes.append(.addIndex(index)) + continue + } + let matched = remaining.remove(at: position) + guard options.matchKey(matched.name) != options.matchKey(index.name) else { continue } + notes.append(String( + format: String(localized: "Index %@ matches %@ on the target but the names differ."), + index.name, matched.name + )) + } + for index in remaining { + changes.append(.deleteIndex(index)) + } + return MemberOutcome(changes: changes, notes: notes) + } + + func foreignKeyChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> MemberOutcome { + var remaining = target.foreignKeys + var changes: [SchemaChange] = [] + var notes: [String] = [] + + for foreignKey in source.foreignKeys { + let signature = foreignKeySignature(foreignKey) + guard let position = remaining.firstIndex(where: { foreignKeySignature($0) == signature }) else { + changes.append(.addForeignKey(foreignKey)) + continue + } + let matched = remaining.remove(at: position) + guard options.matchKey(matched.name) != options.matchKey(foreignKey.name) else { continue } + notes.append(String( + format: String(localized: "Foreign key %@ matches %@ on the target but the names differ."), + foreignKey.name, matched.name + )) + } + for foreignKey in remaining { + changes.append(.deleteForeignKey(foreignKey)) + } + return MemberOutcome(changes: changes, notes: notes) + } +} + +private extension StructureDiffEngine { + func columnSignature(_ column: EditableColumnDefinition) -> String { + var parts: [String] = [ + options.matchKey(column.name), + options.normalizedType(column.dataType), + String(column.isNullable), + String(column.autoIncrement), + String(column.unsigned), + options.normalizedText(column.defaultValue) ?? "", + options.normalizedText(column.onUpdate) ?? "", + options.normalizedText(strippedExtra(column.extra)) ?? "" + ] + if !options.ignoreCollationAndCharset { + parts.append(options.matchKey(column.charset ?? "")) + parts.append(options.matchKey(column.collation ?? "")) + } + if !options.ignoreCommentsAndOwners { + parts.append(options.normalizedText(column.comment) ?? "") + } + return parts.joined(separator: "\u{1F}") + } + + func indexSignature(_ index: EditableIndexDefinition) -> String { + var parts: [String] = [ + options.columnListKey(index.columns), + String(index.isUnique), + options.matchKey(index.type.rawValue), + options.normalizedText(index.whereClause) ?? "" + ] + let prefixes = index.columnPrefixes + .sorted { $0.key.localizedStandardCompare($1.key) == .orderedAscending } + .map { "\(options.matchKey($0.key)):\($0.value)" } + parts.append(prefixes.joined(separator: ",")) + return parts.joined(separator: "\u{1F}") + } + + func foreignKeySignature(_ foreignKey: EditableForeignKeyDefinition) -> String { + [ + options.columnListKey(foreignKey.columns), + options.matchKey(foreignKey.referencedTable), + options.columnListKey(foreignKey.referencedColumns), + options.matchKey(foreignKey.referencedSchema ?? ""), + foreignKey.onDelete.rawValue, + foreignKey.onUpdate.rawValue + ].joined(separator: "\u{1F}") + } + + func strippedExtra(_ extra: String?) -> String? { + guard let extra else { return nil } + guard options.ignoreAutoIncrementSeed else { return extra } + return extra.replacingOccurrences( + of: "auto_increment=[0-9]+", + with: "auto_increment", + options: [.regularExpression, .caseInsensitive] + ) + } +} diff --git a/TablePro/Core/Compare/StructureDiffEngine.swift b/TablePro/Core/Compare/StructureDiffEngine.swift new file mode 100644 index 000000000..e87bd0995 --- /dev/null +++ b/TablePro/Core/Compare/StructureDiffEngine.swift @@ -0,0 +1,124 @@ +// +// StructureDiffEngine.swift +// TablePro +// +// Compares parsed structure metadata between two connections and produces the +// SchemaChange list that would make the target match the source. +// Structure is never compared as DDL text: driver-rendered DDL varies by +// formatting and system-generated names, which reports identical objects as +// different. +// + +import Foundation + +internal struct StructureDiffEngine { + internal let options: StructureCompareOptions + + internal init(options: StructureCompareOptions = .default) { + self.options = options + } + + internal func compare( + source: [TableStructureSnapshot], + target: [TableStructureSnapshot] + ) -> StructureDiffReport { + let targetByKey = indexByMatchKey(target) + + var results: [TableDiffResult] = [] + var handled: Set = [] + + for snapshot in source { + let key = options.matchKey(snapshot.name) + handled.insert(key) + guard let counterpart = targetByKey[key] else { + results.append(TableDiffResult( + tableName: snapshot.name, + schema: snapshot.schema, + status: .onlyInSource + )) + continue + } + results.append(compareTable(source: snapshot, target: counterpart)) + } + + for snapshot in target where !handled.contains(options.matchKey(snapshot.name)) { + results.append(TableDiffResult( + tableName: snapshot.name, + schema: snapshot.schema, + status: .onlyInTarget + )) + } + + let sorted = results.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + return StructureDiffReport(results: sorted) + } + + internal func compareTable( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> TableDiffResult { + var changes: [SchemaChange] = [] + var notes: [String] = [] + + changes.append(contentsOf: columnChanges(source: source, target: target)) + changes.append(contentsOf: primaryKeyChanges(source: source, target: target)) + + let indexOutcome = indexChanges(source: source, target: target) + changes.append(contentsOf: indexOutcome.changes) + notes.append(contentsOf: indexOutcome.notes) + + let foreignKeyOutcome = foreignKeyChanges(source: source, target: target) + changes.append(contentsOf: foreignKeyOutcome.changes) + notes.append(contentsOf: foreignKeyOutcome.notes) + + notes.append(contentsOf: columnOrderNotes(source: source, target: target)) + notes.append(contentsOf: tableOptionNotes(source: source, target: target)) + + let status: TableDiffStatus = (changes.isEmpty && notes.isEmpty) ? .identical : .differs + return TableDiffResult( + tableName: source.name, + schema: source.schema, + status: status, + changes: changes, + notes: notes + ) + } + + private func indexByMatchKey(_ snapshots: [TableStructureSnapshot]) -> [String: TableStructureSnapshot] { + Dictionary(snapshots.map { (options.matchKey($0.name), $0) }, uniquingKeysWith: { first, _ in first }) + } + + private func columnOrderNotes( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [String] { + guard !options.ignoreColumnOrder else { return [] } + let sourceOrder = source.columns.map { options.matchKey($0.name) } + let targetOrder = target.columns.map { options.matchKey($0.name) } + guard sourceOrder != targetOrder, Set(sourceOrder) == Set(targetOrder) else { return [] } + return [String(localized: "Column order differs. No statement is generated for reordering columns.")] + } + + private func tableOptionNotes( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [String] { + var notes: [String] = [] + if let sourceEngine = source.engine, let targetEngine = target.engine, + options.matchKey(sourceEngine) != options.matchKey(targetEngine) { + notes.append(String( + format: String(localized: "Storage engine differs: %@ on source, %@ on target."), + sourceEngine, targetEngine + )) + } + guard !options.ignoreCollationAndCharset else { return notes } + if let sourceCollation = source.collation, let targetCollation = target.collation, + options.matchKey(sourceCollation) != options.matchKey(targetCollation) { + notes.append(String( + format: String(localized: "Table collation differs: %@ on source, %@ on target."), + sourceCollation, targetCollation + )) + } + return notes + } +} diff --git a/TablePro/Core/Compare/SyncHazard.swift b/TablePro/Core/Compare/SyncHazard.swift new file mode 100644 index 000000000..b05a2dd5e --- /dev/null +++ b/TablePro/Core/Compare/SyncHazard.swift @@ -0,0 +1,65 @@ +// +// SyncHazard.swift +// TablePro +// +// Typed risks attached to individual generated statements. +// A statement carrying a refused hazard is shown in the preview but is not +// executed unless it is explicitly allowed for that run. +// + +import Foundation + +internal enum SyncHazardKind: String, Codable, Hashable, Sendable { + case dataLoss + case lossyTypeChange + case tableRebuild + case collationOrCharsetChange + case primaryKeyChange + case engineOrStorageChange + case notSupportedByTarget +} + +internal enum SyncHazardSeverity: Int, Codable, Hashable, Sendable, Comparable { + case informational + case warning + case refusedByDefault + + internal static func < (lhs: SyncHazardSeverity, rhs: SyncHazardSeverity) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +internal struct SyncHazard: Identifiable, Hashable, Sendable { + internal let kind: SyncHazardKind + internal let severity: SyncHazardSeverity + internal let explanation: String + + internal var id: String { "\(kind.rawValue)-\(explanation)" } + + internal init(kind: SyncHazardKind, severity: SyncHazardSeverity, explanation: String) { + self.kind = kind + self.severity = severity + self.explanation = explanation + } +} + +internal extension SyncHazardKind { + var displayName: String { + switch self { + case .dataLoss: + return String(localized: "Data loss") + case .lossyTypeChange: + return String(localized: "Lossy type change") + case .tableRebuild: + return String(localized: "Table rebuild") + case .collationOrCharsetChange: + return String(localized: "Collation or character set change") + case .primaryKeyChange: + return String(localized: "Primary key change") + case .engineOrStorageChange: + return String(localized: "Storage engine change") + case .notSupportedByTarget: + return String(localized: "Not supported by the target") + } + } +} diff --git a/TablePro/Core/Compare/SyncSafetyClassifier.swift b/TablePro/Core/Compare/SyncSafetyClassifier.swift new file mode 100644 index 000000000..478efd4eb --- /dev/null +++ b/TablePro/Core/Compare/SyncSafetyClassifier.swift @@ -0,0 +1,133 @@ +// +// SyncSafetyClassifier.swift +// TablePro +// +// Decides which generated statements are unsafe enough to be refused unless +// the user allows them for a single run. Refusing by default keeps the preview +// honest: nothing runs that the preview did not show as allowed. +// + +import Foundation + +internal struct SyncSafetyClassifier { + internal func hazards(for change: SchemaChange) -> [SyncHazard] { + switch change { + case .deleteColumn(let column): + return [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Dropping column %@ permanently removes its data."), + column.name + ) + )] + case .modifyColumn(let old, let new): + return modifyColumnHazards(old: old, new: new) + case .modifyPrimaryKey: + return [SyncHazard( + kind: .primaryKeyChange, + severity: .refusedByDefault, + explanation: String(localized: "Changing the primary key rebuilds the table and can fail on duplicate values.") + )] + case .deleteIndex(let index): + return [SyncHazard( + kind: .tableRebuild, + severity: .warning, + explanation: String( + format: String(localized: "Dropping index %@ can slow existing queries."), + index.name + ) + )] + case .deleteForeignKey, .addForeignKey, .modifyForeignKey, .addColumn, .addIndex, .modifyIndex: + return [] + } + } + + internal func hazards(forDropping tableName: String) -> [SyncHazard] { + [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Dropping table %@ permanently removes the table and all of its rows."), + tableName + ) + )] + } + + private func modifyColumnHazards( + old: EditableColumnDefinition, + new: EditableColumnDefinition + ) -> [SyncHazard] { + var hazards: [SyncHazard] = [] + + if let narrowing = TypeWidthComparison.classify(from: old.dataType, to: new.dataType), + narrowing == .narrowing { + hazards.append(SyncHazard( + kind: .lossyTypeChange, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Changing %@ from %@ to %@ can truncate existing values."), + old.name, old.dataType, new.dataType + ) + )) + } + + if old.isNullable, !new.isNullable { + hazards.append(SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Making %@ NOT NULL fails if any existing row holds NULL."), + old.name + ) + )) + } + + if normalized(old.collation) != normalized(new.collation) + || normalized(old.charset) != normalized(new.charset) { + hazards.append(SyncHazard( + kind: .collationOrCharsetChange, + severity: .warning, + explanation: String( + format: String(localized: "Changing the collation of %@ can change sort order and uniqueness."), + old.name + ) + )) + } + + return hazards + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +internal enum TypeWidthComparison { + internal enum Outcome { + case widening + case narrowing + case equivalent + } + + internal static func classify(from oldType: String, to newType: String) -> Outcome? { + let old = parse(oldType) + let new = parse(newType) + guard old.base == new.base else { return .narrowing } + guard let oldWidth = old.width, let newWidth = new.width else { return .equivalent } + if newWidth > oldWidth { return .widening } + if newWidth < oldWidth { return .narrowing } + return .equivalent + } + + private static func parse(_ type: String) -> (base: String, width: Int?) { + let lowered = type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let open = lowered.firstIndex(of: "("), let close = lowered.firstIndex(of: ")"), open < close else { + return (lowered, nil) + } + let base = String(lowered[lowered.startIndex.. Int { + comparable.filter { $0.status == status }.count + } +} diff --git a/TablePro/Core/Compare/TableStructureSnapshot.swift b/TablePro/Core/Compare/TableStructureSnapshot.swift new file mode 100644 index 000000000..9f3da1076 --- /dev/null +++ b/TablePro/Core/Compare/TableStructureSnapshot.swift @@ -0,0 +1,129 @@ +// +// TableStructureSnapshot.swift +// TablePro +// +// One side's view of a table's structure, already converted out of plugin +// transfer types so the diff engine stays free of driver concerns. +// + +import Foundation +import TableProPluginKit + +internal struct TableStructureSnapshot: Hashable { + internal let name: String + internal let schema: String? + internal let columns: [EditableColumnDefinition] + internal let indexes: [EditableIndexDefinition] + internal let foreignKeys: [EditableForeignKeyDefinition] + internal let engine: String? + internal let charset: String? + internal let collation: String? + + internal init( + name: String, + schema: String? = nil, + columns: [EditableColumnDefinition], + indexes: [EditableIndexDefinition] = [], + foreignKeys: [EditableForeignKeyDefinition] = [], + engine: String? = nil, + charset: String? = nil, + collation: String? = nil + ) { + self.name = name + self.schema = schema + self.columns = columns + self.indexes = indexes + self.foreignKeys = foreignKeys + self.engine = engine + self.charset = charset + self.collation = collation + } + + internal var primaryKeyColumns: [String] { + if let primary = indexes.first(where: { $0.isPrimary }) { + return primary.columns + } + return columns.filter { $0.isPrimaryKey }.map { $0.name } + } + + internal var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } +} + +internal extension TableStructureSnapshot { + static func from( + table: PluginTableInfo, + columns: [PluginColumnInfo], + indexes: [PluginIndexInfo], + foreignKeys: [PluginForeignKeyInfo], + metadata: PluginTableMetadata? = nil + ) -> TableStructureSnapshot { + TableStructureSnapshot( + name: table.name, + schema: table.schema, + columns: columns.map { EditableColumnDefinition.from($0.toColumnInfo()) }, + indexes: indexes.map { EditableIndexDefinition.from($0.toIndexInfo()) }, + foreignKeys: Self.groupForeignKeys(foreignKeys), + engine: metadata?.engine, + charset: nil, + collation: metadata?.collation + ) + } + + private static func groupForeignKeys(_ foreignKeys: [PluginForeignKeyInfo]) -> [EditableForeignKeyDefinition] { + var order: [String] = [] + var grouped: [String: [PluginForeignKeyInfo]] = [:] + for foreignKey in foreignKeys { + if grouped[foreignKey.name] == nil { order.append(foreignKey.name) } + grouped[foreignKey.name, default: []].append(foreignKey) + } + return order.compactMap { name in + guard let parts = grouped[name], let first = parts.first else { return nil } + return EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: parts.map { $0.column }, + referencedTable: first.referencedTable, + referencedColumns: parts.map { $0.referencedColumn }, + referencedSchema: first.referencedSchema, + onDelete: EditableForeignKeyDefinition.ReferentialAction( + rawValue: first.onDelete.uppercased()) ?? .noAction, + onUpdate: EditableForeignKeyDefinition.ReferentialAction( + rawValue: first.onUpdate.uppercased()) ?? .noAction + ) + } + } +} + +private extension PluginColumnInfo { + func toColumnInfo() -> ColumnInfo { + ColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue, + extra: extra, + charset: charset, + collation: collation, + comment: comment, + allowedValues: allowedValues + ) + } +} + +private extension PluginIndexInfo { + func toIndexInfo() -> IndexInfo { + IndexInfo( + name: name, + columns: columns, + isUnique: isUnique, + isPrimary: isPrimary, + type: type, + columnPrefixes: columnPrefixes, + whereClause: whereClause + ) + } +} diff --git a/TablePro/Core/Services/Infrastructure/SceneIdentifiers.swift b/TablePro/Core/Services/Infrastructure/SceneIdentifiers.swift index 08250e970..4fa5670d0 100644 --- a/TablePro/Core/Services/Infrastructure/SceneIdentifiers.swift +++ b/TablePro/Core/Services/Infrastructure/SceneIdentifiers.swift @@ -9,4 +9,5 @@ internal enum SceneId { static let welcome = "welcome" static let connectionForm = "connection-form" static let integrationsActivity = "integrations-activity" + static let compareSync = "compare-sync" } diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 3c0e87045..af86d2e7b 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -17,6 +17,7 @@ internal final class WindowOpener { @ObservationIgnored private var openWelcomeAction: (() -> Void)? @ObservationIgnored private var openConnectionFormAction: ((UUID?) -> Void)? @ObservationIgnored private var openIntegrationsActivityAction: (() -> Void)? + @ObservationIgnored private var openCompareSyncAction: ((UUID?) -> Void)? @ObservationIgnored private var openSettingsAction: (() -> Void)? @ObservationIgnored private var presentTypeChooserAction: ((DatabaseType?, @escaping (DatabaseType) -> Void) -> Void)? @@ -81,16 +82,22 @@ internal final class WindowOpener { run { $0.openIntegrationsActivityAction?() } } + internal func openCompareSync(prefillSource connectionId: UUID? = nil) { + run { $0.openCompareSyncAction?(connectionId) } + } + internal func wire( openWelcome: @escaping () -> Void, openConnectionForm: @escaping (UUID?) -> Void, openIntegrationsActivity: @escaping () -> Void, + openCompareSync: @escaping (UUID?) -> Void, openSettings: @escaping () -> Void, presentTypeChooser: @escaping (DatabaseType?, @escaping (DatabaseType) -> Void) -> Void ) { openWelcomeAction = openWelcome openConnectionFormAction = openConnectionForm openIntegrationsActivityAction = openIntegrationsActivity + openCompareSyncAction = openCompareSync openSettingsAction = openSettings presentTypeChooserAction = presentTypeChooser isWired = true diff --git a/TablePro/Models/Compare/CompareSyncPayload.swift b/TablePro/Models/Compare/CompareSyncPayload.swift new file mode 100644 index 000000000..e0a35db26 --- /dev/null +++ b/TablePro/Models/Compare/CompareSyncPayload.swift @@ -0,0 +1,18 @@ +// +// CompareSyncPayload.swift +// TablePro +// +// Scene payload for the Compare & Sync window. A dedicated type rather than a +// bare UUID?, so this scene never competes with the connection form for an +// openWindow(value:) call carrying the same payload type. +// + +import Foundation + +internal struct CompareSyncPayload: Codable, Hashable { + internal let sourceConnectionId: UUID? + + internal init(sourceConnectionId: UUID? = nil) { + self.sourceConnectionId = sourceConnectionId + } +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index 8c44a0aaa..6de80cdd3 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -15,6 +15,7 @@ internal enum ProFeature: String, CaseIterable { case linkedFolders case teamCatalog case teamLibrary + case compareSync var displayName: String { switch self { @@ -30,6 +31,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Team Catalog") case .teamLibrary: return String(localized: "Team Library") + case .compareSync: + return String(localized: "Compare & Sync") } } @@ -47,6 +50,8 @@ internal enum ProFeature: String, CaseIterable { return "person.2.fill" case .teamLibrary: return "books.vertical.fill" + case .compareSync: + return "arrow.left.arrow.right.square" } } @@ -64,13 +69,15 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Publish connections to a shared folder your team reads from. Passwords are never included.") case .teamLibrary: return String(localized: "Share connections and saved queries with your team through your account. Passwords are never included.") + case .compareSync: + return String(localized: "Compare structure or data between two connections and generate the sync script.") } } /// The lowest license tier that unlocks this feature. var requiredTier: LicenseTier { switch self { - case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders: + case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .compareSync: return .starter case .teamCatalog, .teamLibrary: return .team diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index f358f3211..f93bc69ba 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -265,6 +265,10 @@ struct AppMenuCommands: Commands { } .optionalKeyboardShortcut(shortcut(for: .manageConnections)) + Button(String(localized: "Compare & Sync Databases...")) { + CompareSyncLauncher.open() + } + Button(String(localized: "Open Sample Database")) { SampleDatabaseLauncher.open() } @@ -923,6 +927,23 @@ struct TableProApp: App { .defaultSize(width: 820, height: 600) .commandsRemoved() + WindowGroup("Compare & Sync", id: SceneId.compareSync, for: CompareSyncPayload.self) { $payload in + CompareSyncWindowView(prefillSourceConnectionId: payload?.sourceConnectionId) + .background(WindowOpenerBridge()) + .background(WindowChromeConfigurator(restorable: false)) + .environment(\.appServices, .live) + } + .windowResizability(.contentMinSize) + .defaultSize(width: 1_040, height: 680) + .commands { + AppMenuCommands( + settingsManager: AppSettingsManager.shared, + updaterBridge: updaterBridge, + commandRegistry: commandRegistry, + recentlyClosedStore: RecentlyClosedTabStore.shared + ) + } + Window("Integrations Activity", id: SceneId.integrationsActivity) { IntegrationsActivityView() .background(WindowOpenerBridge()) diff --git a/TablePro/Views/Compare/CompareStatusBanner.swift b/TablePro/Views/Compare/CompareStatusBanner.swift new file mode 100644 index 000000000..0f879d52e --- /dev/null +++ b/TablePro/Views/Compare/CompareStatusBanner.swift @@ -0,0 +1,114 @@ +// +// CompareStatusBanner.swift +// TablePro +// +// States plainly whether anything has been written yet, and carries the target +// connection's colour so a production target stays visually distinct. +// + +import SwiftUI + +internal struct CompareStatusBanner: View { + internal let session: CompareSyncSession + + internal var body: some View { + HStack(spacing: 8) { + Image(systemName: symbol) + .foregroundStyle(tint) + Text(session.bannerText) + .font(.callout) + .foregroundStyle(.primary) + Spacer() + if let target = session.target { + HStack(spacing: 6) { + Circle() + .fill(target.color.color) + .frame(width: 9, height: 9) + Text(String(format: String(localized: "Target: %@"), target.qualifiedDescription)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(tint.opacity(0.08)) + } + + private var symbol: String { + switch session.activity { + case .applying: return "exclamationmark.triangle.fill" + case .comparing, .connecting: return "magnifyingglass" + case .idle: return session.hasWrittenToTarget ? "checkmark.circle" : "eye" + } + } + + private var tint: Color { + switch session.activity { + case .applying: return .orange + case .comparing, .connecting: return .accentColor + case .idle: return session.hasWrittenToTarget ? .green : .secondary + } + } +} + +internal struct CompareSyncFooter: View { + internal let session: CompareSyncSession + @Binding internal var isConfirmingApply: Bool + + internal var body: some View { + HStack(spacing: 12) { + if let message = session.errorMessage { + Label(message, systemImage: "exclamationmark.octagon.fill") + .foregroundStyle(.red) + .font(.callout) + .lineLimit(2) + } else if let message = session.informationalMessage { + Label(message, systemImage: "info.circle") + .foregroundStyle(.secondary) + .font(.callout) + .lineLimit(2) + } + + Spacer() + + if session.isBusy { + if let progress = session.progress { + ProgressView(progress) + .progressViewStyle(.linear) + .frame(width: 180) + } else { + ProgressView() + .controlSize(.small) + } + Button(String(localized: "Cancel")) { session.cancelRunningWork() } + } else { + actionButtons + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private var actionButtons: some View { + switch session.step { + case .setup: + Button(String(localized: "Compare")) { session.runComparison() } + .keyboardShortcut(.defaultAction) + .disabled(!session.canCompare) + case .review: + Button(String(localized: "Back")) { session.step = .setup } + Button(String(localized: "Generate Script")) { session.buildScript() } + .keyboardShortcut(.defaultAction) + .disabled(!session.canBuildScript) + case .script: + Button(String(localized: "Back")) { session.step = .review } + Button(String(localized: "Apply to Target")) { isConfirmingApply = true } + .disabled(session.statements.isEmpty) + case .apply: + Button(String(localized: "Done")) { session.step = .setup } + .keyboardShortcut(.defaultAction) + } + } +} diff --git a/TablePro/Views/Compare/CompareSyncApplyView.swift b/TablePro/Views/Compare/CompareSyncApplyView.swift new file mode 100644 index 000000000..083e76a7e --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncApplyView.swift @@ -0,0 +1,121 @@ +// +// CompareSyncApplyView.swift +// TablePro +// +// Per-statement outcome of a run, including what was held back and whether the +// run rolled back. +// + +import SwiftUI + +internal struct CompareSyncApplyView: View { + internal let session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + summary + Divider() + outcomeList + } + } + + @ViewBuilder + private var summary: some View { + if let result = session.runResult { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 16) { + countBadge( + String(localized: "Executed"), result.executedCount, + "checkmark.circle.fill", result.executedCount > 0 ? .green : .secondary + ) + countBadge( + String(localized: "Failed"), result.failedCount, + "xmark.circle.fill", result.failedCount > 0 ? .red : .secondary + ) + countBadge( + String(localized: "Held back"), result.heldBackCount, + "hand.raised.fill", result.heldBackCount > 0 ? .orange : .secondary + ) + Spacer() + } + + if result.rolledBack { + Label( + String(localized: "The run was rolled back. The target is unchanged."), + systemImage: "arrow.uturn.backward.circle.fill" + ) + .foregroundStyle(.orange) + } else if result.failedCount > 0 { + Label( + String(localized: "Statements that already ran stay applied. Compare again to see the current state."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + } + + if result.cancelled { + Label(String(localized: "Cancelled before the script finished."), systemImage: "stop.circle") + .foregroundStyle(.secondary) + } + } + .padding(16) + } else { + Text("Nothing has run yet.") + .foregroundStyle(.secondary) + .padding(16) + } + } + + private func countBadge(_ label: String, _ count: Int, _ symbol: String, _ tint: Color) -> some View { + HStack(spacing: 6) { + Image(systemName: symbol).foregroundStyle(tint) + Text("\(label): \(count)") + } + .font(.callout) + } + + private var outcomeList: some View { + List(session.runResult?.outcomes ?? []) { outcome in + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Image(systemName: symbol(for: outcome)) + .foregroundStyle(tint(for: outcome)) + Text(outcome.statement.summary) + .font(.callout) + Spacer() + Text(stateLabel(for: outcome)) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(outcome.statement.sql) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + if let error = outcome.error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + .padding(.vertical, 2) + } + .listStyle(.inset) + } + + private func symbol(for outcome: SyncStatementOutcome) -> String { + if outcome.wasSkipped { return "hand.raised.fill" } + return outcome.error == nil ? "checkmark.circle.fill" : "xmark.circle.fill" + } + + private func tint(for outcome: SyncStatementOutcome) -> Color { + if outcome.wasSkipped { return .orange } + return outcome.error == nil ? .green : .red + } + + private func stateLabel(for outcome: SyncStatementOutcome) -> String { + if outcome.wasSkipped { return String(localized: "Held back") } + return outcome.error == nil ? String(localized: "Ran") : String(localized: "Failed") + } +} diff --git a/TablePro/Views/Compare/CompareSyncScriptView.swift b/TablePro/Views/Compare/CompareSyncScriptView.swift new file mode 100644 index 000000000..f8b4a08a1 --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncScriptView.swift @@ -0,0 +1,214 @@ +// +// CompareSyncScriptView.swift +// TablePro +// +// The generated script and its hazards. Statements that would destroy data are +// listed but held back until they are allowed for this run. +// + +import AppKit +import SwiftUI +import TableProPluginKit +import UniformTypeIdentifiers + +internal struct CompareSyncScriptView: View { + @Bindable internal var session: CompareSyncSession + + @State private var isExporting = false + + internal var body: some View { + HSplitView { + hazardPane + .frame(minWidth: 300, idealWidth: 340) + scriptPane + .frame(minWidth: 400) + } + } + + private var heldBack: [SyncStatement] { + session.statements.filter { $0.isRefusedByDefault } + } + + private var hazardPane: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Warnings") + .font(.headline) + Spacer() + if !heldBack.isEmpty { + Text("\(allowedCount)/\(heldBack.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + if heldBack.isEmpty { + VStack(spacing: 6) { + Image(systemName: "checkmark.shield") + .font(.title2) + .foregroundStyle(.green) + Text("Nothing in this script destroys data.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section { + Text("These are held back. Allow one to include it in this run only; the choice is never saved.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(heldBack) { statement in + HeldBackStatementRow(statement: statement, session: session) + } + } + .listStyle(.inset) + } + + Divider() + executionSettings + } + } + + private var allowedCount: Int { + heldBack.filter { session.executionSettings.allowedHazardStatementIds.contains($0.id) }.count + } + + private var executionSettings: some View { + VStack(alignment: .leading, spacing: 6) { + Picker(String(localized: "On error"), selection: $session.executionSettings.errorHandling) { + Text("Stop and roll back").tag(ImportErrorHandling.stopAndRollback) + Text("Stop and keep what ran").tag(ImportErrorHandling.stopAndCommit) + Text("Skip and continue").tag(ImportErrorHandling.skipAndContinue) + } + + Toggle(String(localized: "Run in a transaction"), isOn: $session.executionSettings.wrapInTransaction) + .disabled(session.executionSettings.errorHandling == .skipAndContinue) + + if session.executionSettings.errorHandling == .skipAndContinue { + Text("A transaction cannot be used with skip and continue, because together they leave the target half-applied.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(12) + } + + private var scriptPane: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Script") + .font(.headline) + Text("\(session.statements.count) statements") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(String(localized: "Copy")) { copyScript() } + Button(String(localized: "Save...")) { isExporting = true } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + TextEditor(text: scriptBinding) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .fileExporter( + isPresented: $isExporting, + document: SyncScriptDocument(text: scriptBinding.wrappedValue), + contentType: SyncScriptDocument.sqlType, + defaultFilename: "sync" + ) { _ in } + } + + private var scriptBinding: Binding { + Binding( + get: { session.editedScript ?? session.statements.map { $0.sql }.joined(separator: "\n") }, + set: { session.editedScript = $0 } + ) + } + + private func copyScript() { + ClipboardService.shared.writeText(scriptBinding.wrappedValue) + } +} + +internal struct HeldBackStatementRow: View { + internal let statement: SyncStatement + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: allowBinding) { + Text(statement.summary) + .font(.callout) + } + ForEach(statement.hazards) { hazard in + Label { + VStack(alignment: .leading, spacing: 1) { + Text(hazard.kind.displayName) + .font(.caption.weight(.semibold)) + Text(hazard.explanation) + .font(.caption2) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + Text(statement.sql) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(3) + } + .padding(.vertical, 4) + } + + private var allowBinding: Binding { + Binding( + get: { session.executionSettings.allowedHazardStatementIds.contains(statement.id) }, + set: { allowed in + if allowed { + session.executionSettings.allowedHazardStatementIds.insert(statement.id) + } else { + session.executionSettings.allowedHazardStatementIds.remove(statement.id) + } + } + ) + } +} + +internal struct SyncScriptDocument: FileDocument { + internal static let sqlType = UTType(filenameExtension: "sql") ?? .plainText + + internal static var readableContentTypes: [UTType] { [sqlType, .plainText] } + + internal let text: String + + internal init(text: String) { + self.text = text + } + + internal init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents, + let decoded = String(bytes: data, encoding: .utf8) else { + throw CocoaError(.fileReadCorruptFile) + } + text = decoded + } + + internal func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} diff --git a/TablePro/Views/Compare/CompareSyncSetupView.swift b/TablePro/Views/Compare/CompareSyncSetupView.swift new file mode 100644 index 000000000..c5f83d121 --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncSetupView.swift @@ -0,0 +1,201 @@ +// +// CompareSyncSetupView.swift +// TablePro +// +// Picks the two endpoints. Neither picker is prefilled with a target, and a +// read-only connection is refused as the target at selection time rather than +// at apply time. +// + +import SwiftUI + +internal struct CompareSyncSetupView: View { + @Bindable internal var session: CompareSyncSession + + @State private var candidates: [CompareSyncEndpoint] = [] + + internal var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + modePicker + endpointPickers + directionSummary + optionsSection + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .task { candidates = CompareSyncEndpoint.candidates(from: ConnectionStorage.shared.loadConnections()) } + } + + private var modePicker: some View { + VStack(alignment: .leading, spacing: 6) { + Text("What to compare") + .font(.headline) + Picker("", selection: $session.mode) { + ForEach(CompareSyncMode.allCases, id: \.rawValue) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 260) + .onChange(of: session.mode) { _, _ in session.resetComparison() } + } + } + + private var endpointPickers: some View { + HStack(alignment: .top, spacing: 16) { + endpointColumn( + title: String(localized: "Source"), + caption: String(localized: "Will not change"), + selection: $session.source, + isTarget: false + ) + + Button { + session.swapEndpoints() + } label: { + Image(systemName: "arrow.left.arrow.right") + } + .help(String(localized: "Swap source and target")) + .disabled(!session.canSwap) + .padding(.top, 28) + + endpointColumn( + title: String(localized: "Target"), + caption: String(localized: "Will be changed"), + selection: $session.target, + isTarget: true + ) + } + } + + private func endpointColumn( + title: String, + caption: String, + selection: Binding, + isTarget: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(title).font(.headline) + Text(caption) + .font(.caption) + .foregroundStyle(isTarget ? Color.orange : Color.secondary) + } + Picker("", selection: selection) { + Text(String(localized: "Choose a connection")).tag(CompareSyncEndpoint?.none) + ForEach(candidates) { candidate in + endpointRow(candidate, isTarget: isTarget) + .tag(CompareSyncEndpoint?.some(candidate)) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + .onChange(of: selection.wrappedValue) { _, _ in session.resetComparison() } + + if isTarget, let target = session.target, let reason = target.ineligibleAsTargetReason { + Label(reason, systemImage: "lock.fill") + .font(.caption) + .foregroundStyle(.red) + } + } + } + + private func endpointRow(_ candidate: CompareSyncEndpoint, isTarget: Bool) -> some View { + HStack(spacing: 6) { + Circle().fill(candidate.color.color).frame(width: 8, height: 8) + Text(candidate.qualifiedDescription) + if isTarget, let reason = candidate.ineligibleAsTargetReason { + Text(String(format: String(localized: "(%@)"), reason)) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var directionSummary: some View { + if let sentence = session.directionSentence { + Label(sentence, systemImage: "arrow.right.circle.fill") + .font(.callout) + .padding(10) + .background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + } + if let notice = session.crossEngineNotice { + Label(notice, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var optionsSection: some View { + if session.mode == .structure { + StructureCompareOptionsView(session: session) + } else { + DataCompareOptionsView(session: session) + } + } +} + +internal struct StructureCompareOptionsView: View { + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("What to ignore") + .font(.headline) + Text("These drift between environments by design. Turn one off to have the difference reported.") + .font(.caption) + .foregroundStyle(.secondary) + + Toggle("Identifier case", isOn: $session.structureOptions.ignoreIdentifierCase) + Toggle("Column order", isOn: $session.structureOptions.ignoreColumnOrder) + Toggle("Whitespace in default values and comments", isOn: $session.structureOptions.ignoreWhitespaceInText) + Toggle("Auto-increment seed value", isOn: $session.structureOptions.ignoreAutoIncrementSeed) + Toggle("Collation and character set", isOn: $session.structureOptions.ignoreCollationAndCharset) + Toggle("Comments, owners, and definers", isOn: $session.structureOptions.ignoreCommentsAndOwners) + } + .onChange(of: session.structureOptions) { _, _ in session.resetComparison() } + } +} + +internal struct DataCompareOptionsView: View { + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("What to write") + .font(.headline) + + Toggle("Insert rows missing from the target", isOn: $session.dataOptions.insertMissingRows) + Toggle("Update rows that differ", isOn: $session.dataOptions.updateDifferingRows) + Toggle("Delete rows the source does not have", isOn: $session.dataOptions.deleteExtraRows) + + Text("Delete is off by default so the first run cannot remove a row you did not mean to touch.") + .font(.caption) + .foregroundStyle(.secondary) + + Divider().padding(.vertical, 4) + + Text("How values are judged equal") + .font(.headline) + HStack { + Text("Numeric tolerance") + TextField("", value: $session.dataOptions.floatTolerance, format: .number) + .frame(width: 100) + } + HStack { + Text("Timestamp fractional digits") + Stepper( + value: $session.dataOptions.timestampFractionalDigits, + in: 0...9 + ) { + Text("\(session.dataOptions.timestampFractionalDigits)") + } + .frame(width: 120) + } + } + } +} diff --git a/TablePro/Views/Compare/CompareSyncWindowView.swift b/TablePro/Views/Compare/CompareSyncWindowView.swift new file mode 100644 index 000000000..01c02a08e --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncWindowView.swift @@ -0,0 +1,112 @@ +// +// CompareSyncWindowView.swift +// TablePro +// +// Root of the Compare & Sync auxiliary window. Steps advance in one direction +// and the banner never lets the user forget which side is written to. +// + +import SwiftUI + +internal struct CompareSyncWindowView: View { + internal let prefillSourceConnectionId: UUID? + + @State private var session = CompareSyncSession() + @State private var isConfirmingApply = false + @State private var isConfirmingClose = false + + internal var body: some View { + VStack(spacing: 0) { + CompareSyncStepHeader(session: session) + Divider() + CompareStatusBanner(session: session) + Divider() + + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + CompareSyncFooter(session: session, isConfirmingApply: $isConfirmingApply) + } + .frame(minWidth: 900, minHeight: 560) + .task { applyPrefill() } + .alert(String(localized: "Apply changes?"), isPresented: $isConfirmingApply) { + Button(String(localized: "Cancel"), role: .cancel) {} + Button(String(localized: "Apply")) { session.apply() } + } message: { + Text(applyConfirmationMessage) + } + } + + @ViewBuilder + private var stepContent: some View { + switch session.step { + case .setup: + CompareSyncSetupView(session: session) + case .review: + if session.mode == .structure { + StructureCompareReviewView(session: session) + } else { + DataCompareReviewView(session: session) + } + case .script: + CompareSyncScriptView(session: session) + case .apply: + CompareSyncApplyView(session: session) + } + } + + private var applyConfirmationMessage: String { + let runnable = session.statements.filter { session.executionSettings.canRun($0) }.count + let heldBack = session.statements.count - runnable + let target = session.target?.qualifiedDescription ?? "" + guard heldBack > 0 else { + return String( + format: String(localized: "%d statements will run against %@. This cannot be undone automatically."), + runnable, target + ) + } + return String( + format: String(localized: "%d statements will run against %@. %d are held back as unsafe. This cannot be undone automatically."), + runnable, target, heldBack + ) + } + + private func applyPrefill() { + guard let prefillSourceConnectionId, session.source == nil else { return } + let connections = ConnectionStorage.shared.loadConnections() + guard let match = connections.first(where: { $0.id == prefillSourceConnectionId }) else { return } + session.source = CompareSyncEndpoint.candidates(from: [match]).first + } +} + +internal struct CompareSyncStepHeader: View { + internal let session: CompareSyncSession + + internal var body: some View { + HStack(spacing: 12) { + ForEach(CompareSyncStep.allCases, id: \.rawValue) { step in + HStack(spacing: 6) { + Image(systemName: symbol(for: step)) + .foregroundStyle(step <= session.step ? Color.accentColor : Color.secondary) + Text(step.title) + .fontWeight(step == session.step ? .semibold : .regular) + .foregroundStyle(step <= session.step ? Color.primary : Color.secondary) + } + if step != CompareSyncStep.allCases.last { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func symbol(for step: CompareSyncStep) -> String { + guard step != session.step else { return "\(step.rawValue + 1).circle.fill" } + return step < session.step ? "checkmark.circle.fill" : "\(step.rawValue + 1).circle" + } +} diff --git a/TablePro/Views/Compare/DataCompareReviewView.swift b/TablePro/Views/Compare/DataCompareReviewView.swift new file mode 100644 index 000000000..8efb6a7fb --- /dev/null +++ b/TablePro/Views/Compare/DataCompareReviewView.swift @@ -0,0 +1,259 @@ +// +// DataCompareReviewView.swift +// TablePro +// +// Per-table row counts on the left, the row differences on the right. One grid +// filtered six ways rather than six separate views. +// + +import SwiftUI +import TableProPluginKit + +internal enum RowDiffFilter: String, CaseIterable { + case all + case difference + case insert + case update + case delete + case same + + internal var title: String { + switch self { + case .all: return String(localized: "All Rows") + case .difference: return String(localized: "Difference") + case .insert: return String(localized: "Insert") + case .update: return String(localized: "Update") + case .delete: return String(localized: "Delete") + case .same: return String(localized: "Same") + } + } + + internal func matches(_ entry: RowDiffEntry) -> Bool { + switch self { + case .all: return true + case .difference: return entry.kind != .identical + case .insert: return entry.kind == .insert + case .update: return entry.kind == .update + case .delete: return entry.kind == .delete + case .same: return entry.kind == .identical + } + } +} + +internal struct DataCompareReviewView: View { + @Bindable internal var session: CompareSyncSession + + @State private var selection: String? + @State private var filter: RowDiffFilter = .difference + + internal var body: some View { + HSplitView { + planPane + .frame(minWidth: 320, idealWidth: 380) + rowPane + .frame(minWidth: 380) + } + } + + private var planPane: some View { + VStack(spacing: 0) { + List(selection: $selection) { + ForEach($session.dataPlans) { $plan in + DataComparePlanRow(plan: $plan) + .tag(plan.id) + } + } + .listStyle(.inset) + + if !session.truncatedPlanNames.isEmpty { + Divider() + Label( + String( + format: String(localized: "%d tables had more differences than are shown. Counts are still exact."), + session.truncatedPlanNames.count + ), + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(8) + } + } + } + + private var selectedPlan: DataComparePlan? { + session.dataPlans.first { $0.id == selection } + } + + @ViewBuilder + private var rowPane: some View { + if let plan = selectedPlan, let summary = plan.summary { + VStack(spacing: 0) { + HStack { + Picker("", selection: $filter) { + ForEach(RowDiffFilter.allCases, id: \.rawValue) { option in + Text(option.title).tag(option) + } + } + .labelsHidden() + .frame(width: 150) + Spacer() + Text(String(format: String(localized: "Key: %@"), plan.keyColumns.joined(separator: ", "))) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + let entries = summary.entries.filter { filter.matches($0) } + if entries.isEmpty { + Text("No rows match this filter.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(entries) { entry in + RowDiffEntryRow(entry: entry) + } + .listStyle(.inset) + } + } + } else { + VStack(spacing: 8) { + Image(systemName: "tablecells") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text("Select a table to see its row differences") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +internal struct DataComparePlanRow: View { + @Binding internal var plan: DataComparePlan + + internal var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Toggle("", isOn: $plan.isEnabled) + .labelsHidden() + .disabled(!plan.isComparable) + Text(plan.id) + .font(.callout) + Spacer() + } + + if let reason = plan.unavailableReason { + Label(reason, systemImage: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.orange) + } else if let summary = plan.summary { + HStack(spacing: 10) { + countLabel(String(localized: "Insert"), summary.insertCount, "plus.circle") + countLabel(String(localized: "Update"), summary.updateCount, "pencil.circle") + countLabel(String(localized: "Delete"), summary.deleteCount, "minus.circle") + countLabel(String(localized: "Same"), summary.identicalCount, "equal.circle") + } + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text(String(format: String(localized: "Key: %@"), plan.keyColumns.joined(separator: ", "))) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private func countLabel(_ label: String, _ count: Int, _ symbol: String) -> some View { + HStack(spacing: 3) { + Image(systemName: symbol) + Text("\(count)") + } + .help(label) + } +} + +internal struct RowDiffEntryRow: View { + internal let entry: RowDiffEntry + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Label { + Text(kindLabel) + .font(.caption2) + } icon: { + Image(systemName: kindSymbol) + } + .foregroundStyle(differentiateWithoutColor ? Color.primary : kindTint) + .frame(width: 84, alignment: .leading) + + Text(entry.keyDescription) + .font(.system(.callout, design: .monospaced)) + Spacer() + } + + ForEach(entry.cellDifferences, id: \.column) { difference in + HStack(alignment: .top, spacing: 6) { + Text(difference.column) + .font(.caption2.weight(.semibold)) + .frame(width: 100, alignment: .leading) + Text(Self.describe(difference.sourceValue)) + .font(.system(.caption2, design: .monospaced)) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(Self.describe(difference.targetValue)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text(difference.rule.displayName) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 2) + } + + private static func describe(_ value: PluginCellValue) -> String { + switch value { + case .null: return "NULL" + case .text(let text): return text + case .bytes(let data): return String(format: String(localized: "%d bytes"), data.count) + } + } + + private var kindLabel: String { + switch entry.kind { + case .insert: return String(localized: "Insert") + case .update: return String(localized: "Update") + case .delete: return String(localized: "Delete") + case .identical: return String(localized: "Same") + } + } + + private var kindSymbol: String { + switch entry.kind { + case .insert: return "plus.circle.fill" + case .update: return "circle.lefthalf.filled" + case .delete: return "minus.circle.fill" + case .identical: return "equal.circle" + } + } + + private var kindTint: Color { + switch entry.kind { + case .insert: return .green + case .update: return .orange + case .delete: return .red + case .identical: return .secondary + } + } +} diff --git a/TablePro/Views/Compare/StructureCompareReviewView.swift b/TablePro/Views/Compare/StructureCompareReviewView.swift new file mode 100644 index 000000000..aa6bc2fed --- /dev/null +++ b/TablePro/Views/Compare/StructureCompareReviewView.swift @@ -0,0 +1,292 @@ +// +// StructureCompareReviewView.swift +// TablePro +// +// Differences grouped by object type or by operation, with the source and +// target definitions side by side. Difference type never rests on colour alone. +// + +import SwiftUI + +internal enum CompareGrouping: String, CaseIterable { + case byStatus + case byName + + internal var title: String { + switch self { + case .byStatus: return String(localized: "Group by operation") + case .byName: return String(localized: "Group by name") + } + } +} + +internal struct StructureCompareReviewView: View { + @Bindable internal var session: CompareSyncSession + + @State private var grouping: CompareGrouping = .byStatus + @State private var showIdentical = false + @State private var selection: String? + + internal var body: some View { + HSplitView { + treePane + .frame(minWidth: 340, idealWidth: 420) + detailPane + .frame(minWidth: 360) + } + } + + private var report: StructureDiffReport? { + session.structureReport + } + + private var visibleResults: [TableDiffResult] { + guard let report else { return [] } + let base = showIdentical ? report.comparable : report.comparable.filter { $0.status != .identical } + switch grouping { + case .byName: + return base.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + case .byStatus: + return base.sorted { lhs, rhs in + guard lhs.status == rhs.status else { + return statusRank(lhs.status) < statusRank(rhs.status) + } + return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending + } + } + } + + private func statusRank(_ status: TableDiffStatus) -> Int { + switch status { + case .onlyInSource: return 0 + case .differs: return 1 + case .onlyInTarget: return 2 + case .identical: return 3 + } + } + + private var treePane: some View { + VStack(spacing: 0) { + HStack { + Picker("", selection: $grouping) { + ForEach(CompareGrouping.allCases, id: \.rawValue) { option in + Text(option.title).tag(option) + } + } + .labelsHidden() + .frame(width: 190) + Spacer() + Toggle(String(localized: "Show identical"), isOn: $showIdentical) + .toggleStyle(.checkbox) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + List(selection: $selection) { + ForEach(visibleResults) { result in + CompareResultRow(result: result, session: session) + .tag(result.id) + } + + if let uncomparable = report?.uncomparable, !uncomparable.isEmpty { + Section(String(localized: "Cannot be compared")) { + ForEach(uncomparable) { result in + VStack(alignment: .leading, spacing: 2) { + Text(result.id) + Text(result.comparisonError ?? "") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + .listStyle(.inset) + + Divider() + summaryBar + } + } + + private var summaryBar: some View { + HStack(spacing: 14) { + if let report { + statusCount(String(localized: "To create"), report.count(of: .onlyInSource), "plus.circle") + statusCount(String(localized: "To alter"), report.count(of: .differs), "pencil.circle") + statusCount(String(localized: "To drop"), report.count(of: .onlyInTarget), "minus.circle") + statusCount(String(localized: "Identical"), report.count(of: .identical), "equal.circle") + } + Spacer() + } + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func statusCount(_ label: String, _ count: Int, _ symbol: String) -> some View { + HStack(spacing: 4) { + Image(systemName: symbol) + Text("\(label): \(count)") + } + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var detailPane: some View { + if let result = visibleResults.first(where: { $0.id == selection }) { + CompareResultDetailView(result: result) + } else { + VStack(spacing: 8) { + Image(systemName: "sidebar.right") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text("Select a table to see what differs") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +internal struct CompareResultRow: View { + internal let result: TableDiffResult + @Bindable internal var session: CompareSyncSession + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + HStack(spacing: 8) { + Label { + Text(statusLabel) + .font(.caption2) + .fixedSize() + } icon: { + Image(systemName: statusSymbol) + } + .labelStyle(.titleAndIcon) + .foregroundStyle(differentiateWithoutColor ? Color.primary : statusTint) + .frame(width: 96, alignment: .leading) + + VStack(alignment: .leading, spacing: 1) { + Text(result.tableName) + if !result.notes.isEmpty { + Text(result.notes[0]) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + Picker("", selection: actionBinding) { + ForEach(availableActions, id: \.rawValue) { action in + Text(actionTitle(action)).tag(action) + } + } + .labelsHidden() + .frame(width: 110) + } + .padding(.vertical, 2) + } + + private var actionBinding: Binding { + Binding( + get: { session.action(for: result) }, + set: { session.setAction($0, for: result) } + ) + } + + private var availableActions: [TableSyncAction] { + switch result.status { + case .onlyInSource: return [.skip, .create] + case .onlyInTarget: return [.skip, .drop] + case .differs: return [.skip, .alter] + case .identical: return [.skip] + } + } + + private func actionTitle(_ action: TableSyncAction) -> String { + switch action { + case .create: return String(localized: "Create") + case .alter: return String(localized: "Alter") + case .drop: return String(localized: "Drop") + case .skip: return String(localized: "Skip") + } + } + + private var statusLabel: String { + switch result.status { + case .onlyInSource: return String(localized: "Source only") + case .onlyInTarget: return String(localized: "Target only") + case .differs: return String(localized: "Different") + case .identical: return String(localized: "Identical") + } + } + + private var statusSymbol: String { + switch result.status { + case .onlyInSource: return "plus.circle.fill" + case .onlyInTarget: return "minus.circle.fill" + case .differs: return "circle.lefthalf.filled" + case .identical: return "equal.circle" + } + } + + private var statusTint: Color { + switch result.status { + case .onlyInSource: return .green + case .onlyInTarget: return .red + case .differs: return .orange + case .identical: return .secondary + } + } +} + +internal struct CompareResultDetailView: View { + internal let result: TableDiffResult + + internal var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(result.id) + .font(.headline) + + if !result.notes.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Notes") + .font(.subheadline.weight(.semibold)) + ForEach(Array(result.notes.enumerated()), id: \.offset) { _, note in + Label(note, systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if result.changes.isEmpty { + Text("No structural changes.") + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Changes") + .font(.subheadline.weight(.semibold)) + ForEach(Array(result.changes.enumerated()), id: \.offset) { _, change in + HStack(alignment: .top, spacing: 6) { + Image(systemName: change.isDestructive ? "exclamationmark.triangle.fill" : "arrow.right") + .font(.caption) + .foregroundStyle(change.isDestructive ? Color.orange : Color.secondary) + Text(change.description) + .font(.callout) + } + } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 659296bfa..058d0df58 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -142,6 +142,12 @@ extension WelcomeWindowView { Divider() + Button { CompareSyncLauncher.open(prefillSource: connection.id) } label: { + Label(String(localized: "Compare/Sync with..."), systemImage: "arrow.left.arrow.right.square") + } + + Divider() + Button { vm.toggleFavorite([connection]) } label: { Label( connection.isFavorite diff --git a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift index 547ecaa66..ca63ca88a 100644 --- a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift +++ b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift @@ -21,6 +21,9 @@ internal struct WindowOpenerBridge: View { openWelcome: { openWindow(id: SceneId.welcome) }, openConnectionForm: { id in openWindow(id: SceneId.connectionForm, value: id) }, openIntegrationsActivity: { openWindow(id: SceneId.integrationsActivity) }, + openCompareSync: { id in + openWindow(id: SceneId.compareSync, value: CompareSyncPayload(sourceConnectionId: id)) + }, openSettings: { openSettings() }, presentTypeChooser: { initialType, onSelected in let payload = DatabaseTypeChooserPayload( diff --git a/TableProTests/Core/Compare/CompareSyncExecutorTests.swift b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift new file mode 100644 index 000000000..04efc9bce --- /dev/null +++ b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift @@ -0,0 +1,318 @@ +// +// CompareSyncExecutorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +private final class RecordingDriver: PluginDatabaseDriver, @unchecked Sendable { + var executed: [String] = [] + var transactionEvents: [String] = [] + var failingStatements: Set = [] + var transactionsSupported = true + var declaredCapabilities: PluginCapabilities = [] + + var capabilities: PluginCapabilities { declaredCapabilities } + var supportsTransactions: Bool { transactionsSupported } + + func connect() async throws {} + + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + executed.append(query) + if failingStatements.contains(query) { + throw CompareSyncError.unsupportedOperation("boom") + } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func beginTransaction() async throws { transactionEvents.append("begin") } + func commitTransaction() async throws { transactionEvents.append("commit") } + func rollbackTransaction() async throws { transactionEvents.append("rollback") } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +private struct AlwaysAllowGate: ExecutionGate { + func authorize(_ request: OperationRequest) async -> OperationDecision { + .authorized(OperationReceipt( + connectionId: request.connectionId, + kind: request.kind, + effectiveWrite: true, + grantedAt: Date(), + token: UUID() + )) + } +} + +private struct AlwaysDenyGate: ExecutionGate { + func authorize(_ request: OperationRequest) async -> OperationDecision { + .denied(reason: "Read-Only connection") + } +} + +final class CompareSyncExecutorTests: XCTestCase { + private func endpoint() -> CompareSyncEndpoint { + CompareSyncEndpoint( + connectionId: UUID(), + displayName: "staging", + databaseType: .mysql, + database: "app", + schema: nil, + safeModeLevel: .silent, + color: .blue + ) + } + + private func statement(_ sql: String, refused: Bool = false) -> SyncStatement { + SyncStatement( + sql: sql, + objectName: "t", + summary: sql, + hazards: refused + ? [SyncHazard(kind: .dataLoss, severity: .refusedByDefault, explanation: "drops data")] + : [] + ) + } + + private func run( + statements: [SyncStatement], + settings: CompareSyncExecutionSettings = CompareSyncExecutionSettings(), + driver: RecordingDriver, + gate: any ExecutionGate = AlwaysAllowGate() + ) async throws -> CompareSyncRunResult { + try await CompareSyncExecutor(gate: gate).apply( + statements: statements, + mode: .structure, + settings: settings, + target: endpoint(), + driver: driver, + progress: Progress() + ) + } + + // MARK: - Held back statements + + func testRefusedStatementIsNotExecutedUnlessAllowed() async throws { + let driver = RecordingDriver() + let dropStatement = statement("DROP TABLE t;", refused: true) + + let result = try await run(statements: [statement("SELECT 1;"), dropStatement], driver: driver) + + XCTAssertEqual(driver.executed, ["SELECT 1;"], "a refused statement must not reach the driver") + XCTAssertEqual(result.heldBackCount, 1) + XCTAssertEqual(result.executedCount, 1) + } + + func testAllowingARefusedStatementRunsIt() async throws { + let driver = RecordingDriver() + let dropStatement = statement("DROP TABLE t;", refused: true) + var settings = CompareSyncExecutionSettings() + settings.allowedHazardStatementIds = [dropStatement.id] + + let result = try await run(statements: [dropStatement], settings: settings, driver: driver) + + XCTAssertEqual(driver.executed, ["DROP TABLE t;"]) + XCTAssertEqual(result.heldBackCount, 0) + } + + func testAllRefusedMeansNothingRunsAndNoTransactionOpens() async throws { + let driver = RecordingDriver() + + let result = try await run(statements: [statement("DROP TABLE t;", refused: true)], driver: driver) + + XCTAssertTrue(driver.executed.isEmpty) + XCTAssertTrue(driver.transactionEvents.isEmpty) + XCTAssertEqual(result.heldBackCount, 1) + } + + // MARK: - Transactions + + func testSuccessfulRunCommits() async throws { + let driver = RecordingDriver() + + _ = try await run(statements: [statement("A;"), statement("B;")], driver: driver) + + XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) + } + + func testFailureRollsBackWithStopAndRollback() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .stopAndRollback + + let result = try await run( + statements: [statement("A;"), statement("B;"), statement("C;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.transactionEvents, ["begin", "rollback"]) + XCTAssertTrue(result.rolledBack) + XCTAssertEqual(driver.executed, ["A;", "B;"], "execution stops at the first failure") + } + + func testFailureCommitsWithStopAndCommit() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .stopAndCommit + + let result = try await run( + statements: [statement("A;"), statement("B;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) + XCTAssertFalse(result.rolledBack) + } + + func testSkipAndContinueRunsEverythingAndUsesNoTransaction() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .skipAndContinue + + let result = try await run( + statements: [statement("A;"), statement("B;"), statement("C;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.executed, ["A;", "B;", "C;"]) + XCTAssertTrue(driver.transactionEvents.isEmpty, "skip and continue must not open a transaction") + XCTAssertEqual(result.failedCount, 1) + XCTAssertEqual(result.executedCount, 2) + } + + func testNoTransactionWhenDriverDoesNotSupportOne() async throws { + let driver = RecordingDriver() + driver.transactionsSupported = false + + _ = try await run(statements: [statement("A;")], driver: driver) + + XCTAssertTrue(driver.transactionEvents.isEmpty) + } + + // MARK: - Gate + + func testDeniedAuthorizationRunsNoStatements() async { + let driver = RecordingDriver() + + do { + _ = try await run(statements: [statement("A;")], driver: driver, gate: AlwaysDenyGate()) + XCTFail("Expected the gate to deny the run") + } catch { + XCTAssertTrue(driver.executed.isEmpty, "nothing may run when the gate denies") + } + } + + // MARK: - Progress + + func testProgressReachesTotalOnCompletion() async throws { + let driver = RecordingDriver() + let progress = Progress() + + _ = try await CompareSyncExecutor(gate: AlwaysAllowGate()).apply( + statements: [statement("A;"), statement("B;"), statement("C;")], + mode: .structure, + settings: CompareSyncExecutionSettings(), + target: endpoint(), + driver: driver, + progress: progress + ) + + XCTAssertEqual(progress.totalUnitCount, 3) + XCTAssertEqual(progress.completedUnitCount, 3) + } +} + +final class CompareSyncExecutionSettingsTests: XCTestCase { + func testTransactionIsDisabledForSkipAndContinue() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .skipAndContinue + + XCTAssertFalse(settings.usesTransaction(supportsTransactions: true)) + } + + func testTransactionRequiresDriverSupport() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .stopAndRollback + + XCTAssertTrue(settings.usesTransaction(supportsTransactions: true)) + XCTAssertFalse(settings.usesTransaction(supportsTransactions: false)) + } +} + +final class CompareSyncEligibilityTests: XCTestCase { + func testMissingSchemaCompareBitIsRefused() { + let driver = RecordingDriver() + driver.declaredCapabilities = [] + + XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) + } + + func testDeclaredBitPasses() { + let driver = RecordingDriver() + driver.declaredCapabilities = [.schemaCompare, .dataCompare] + + XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) + XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + } + + func testStructureBitDoesNotGrantDataCompare() { + let driver = RecordingDriver() + driver.declaredCapabilities = [.schemaCompare] + + XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + } +} + +final class CompareSyncEndpointTests: XCTestCase { + private func endpoint(_ level: SafeModeLevel) -> CompareSyncEndpoint { + CompareSyncEndpoint( + connectionId: UUID(), + displayName: "prod", + databaseType: .postgresql, + database: nil, + schema: nil, + safeModeLevel: level, + color: .red + ) + } + + func testReadOnlyEndpointCannotBeWrittenTo() { + let target = endpoint(.readOnly) + + XCTAssertFalse(target.canBeWrittenTo) + XCTAssertNotNil(target.ineligibleAsTargetReason) + } + + func testOtherLevelsCanBeWrittenTo() { + for level in [SafeModeLevel.silent, .alert, .alertFull, .safeMode, .safeModeFull] { + XCTAssertTrue(endpoint(level).canBeWrittenTo, "\(level) should be a valid target") + XCTAssertNil(endpoint(level).ineligibleAsTargetReason) + } + } +} diff --git a/TableProTests/Core/Compare/DataDiffEngineTests.swift b/TableProTests/Core/Compare/DataDiffEngineTests.swift new file mode 100644 index 000000000..3e9a1024e --- /dev/null +++ b/TableProTests/Core/Compare/DataDiffEngineTests.swift @@ -0,0 +1,360 @@ +// +// DataDiffEngineTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class DataDiffEngineTests: XCTestCase { + private func row(_ pairs: [String: String?]) -> DataRow { + var values: [String: PluginCellValue] = [:] + for (key, value) in pairs { + values[key] = value.map { PluginCellValue.text($0) } ?? .null + } + return DataRow(values: values) + } + + private func engine( + key: [String] = ["id"], + columns: [String] = ["id", "name"], + configure: (inout DataCompareOptions) -> Void = { _ in } + ) -> DataDiffEngine { + var options = DataCompareOptions() + options.keyColumns = key + configure(&options) + return DataDiffEngine(options: options, columns: columns) + } + + private func diff( + source: [DataRow], + target: [DataRow], + engine: DataDiffEngine + ) async throws -> DataDiffSummary { + try await engine.compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: target) + ) + } + + // MARK: - Merge join classification + + func testRowMissingFromTargetIsAnInsert() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "a"]), row(["id": "2", "name": "b"])], + target: [row(["id": "1", "name": "a"])], + engine: engine() + ) + + XCTAssertEqual(summary.insertCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.updateCount, 0) + XCTAssertEqual(summary.deleteCount, 0) + } + + func testRowMissingFromSourceIsADelete() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "a"])], + target: [row(["id": "1", "name": "a"]), row(["id": "2", "name": "b"])], + engine: engine() + ) + + XCTAssertEqual(summary.deleteCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + } + + func testDifferingValueIsAnUpdateAndRecordsWhichRuleFired() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "alice"])], + target: [row(["id": "1", "name": "bob"])], + engine: engine() + ) + + XCTAssertEqual(summary.updateCount, 1) + let entry = try XCTUnwrap(summary.entries.first) + XCTAssertEqual(entry.cellDifferences.count, 1) + XCTAssertEqual(entry.cellDifferences[0].column, "name") + XCTAssertEqual(entry.cellDifferences[0].rule, .exactValue) + } + + func testInterleavedKeysAreAllClassified() async throws { + let summary = try await diff( + source: [row(["id": "1"]), row(["id": "3"]), row(["id": "5"])], + target: [row(["id": "2"]), row(["id": "3"]), row(["id": "4"])], + engine: engine(columns: ["id"]) + ) + + XCTAssertEqual(summary.insertCount, 2) + XCTAssertEqual(summary.deleteCount, 2) + XCTAssertEqual(summary.identicalCount, 1) + } + + func testEmptySourceMakesEveryTargetRowADelete() async throws { + let summary = try await diff( + source: [], + target: [row(["id": "1"]), row(["id": "2"])], + engine: engine(columns: ["id"]) + ) + + XCTAssertEqual(summary.deleteCount, 2) + } + + // MARK: - Composite keys + + func testCompositeKeyMatchesOnBothColumns() async throws { + let compareEngine = engine(key: ["tenant", "id"], columns: ["tenant", "id", "name"]) + let summary = try await diff( + source: [ + row(["tenant": "a", "id": "1", "name": "x"]), + row(["tenant": "b", "id": "1", "name": "y"]) + ], + target: [ + row(["tenant": "a", "id": "1", "name": "x"]), + row(["tenant": "b", "id": "1", "name": "z"]) + ], + engine: compareEngine + ) + + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.updateCount, 1, "composite keys must not collapse distinct rows") + } + + // MARK: - No key + + func testComparingWithoutAKeyThrowsRatherThanGuessing() async { + var options = DataCompareOptions() + options.keyColumns = [] + let keyless = DataDiffEngine(options: options, columns: ["id"]) + + do { + _ = try await keyless.compare( + source: ArrayRowProvider(rows: [row(["id": "1"])]), + target: ArrayRowProvider(rows: []) + ) + XCTFail("Expected a missing-key error") + } catch { + XCTAssertTrue(error is CompareSyncError) + } + } + + // MARK: - NULL semantics + + func testNullEqualsNullAndNeverEqualsEmptyString() async throws { + let bothNull = try await diff( + source: [row(["id": "1", "name": nil])], + target: [row(["id": "1", "name": nil])], + engine: engine() + ) + XCTAssertEqual(bothNull.identicalCount, 1) + + let nullVersusEmpty = try await diff( + source: [row(["id": "1", "name": nil])], + target: [row(["id": "1", "name": ""])], + engine: engine() + ) + XCTAssertEqual(nullVersusEmpty.updateCount, 1) + XCTAssertEqual(nullVersusEmpty.entries.first?.cellDifferences.first?.rule, .nullEquality) + } + + // MARK: - Compare set versus write set + + func testExcludedColumnNeverCausesADifference() async throws { + let compareEngine = engine(columns: ["id", "name", "updated_at"]) { options in + options.excludedFromComparison = ["updated_at"] + } + let summary = try await diff( + source: [row(["id": "1", "name": "a", "updated_at": "2026-01-01 00:00:00"])], + target: [row(["id": "1", "name": "a", "updated_at": "2020-01-01 00:00:00"])], + engine: compareEngine + ) + + XCTAssertEqual(summary.identicalCount, 1, "an excluded audit column must not create a diff") + } + + func testExcludedColumnIsStillCarriedOnTheSourceRow() async throws { + let compareEngine = engine(columns: ["id", "name", "updated_at"]) { options in + options.excludedFromComparison = ["updated_at"] + options.insertMissingRows = true + } + let summary = try await diff( + source: [row(["id": "1", "name": "a", "updated_at": "2026-01-01 00:00:00"])], + target: [], + engine: compareEngine + ) + + let entry = try XCTUnwrap(summary.entries.first) + XCTAssertEqual(entry.kind, .insert) + XCTAssertEqual(entry.sourceRow?.value(for: "updated_at"), .text("2026-01-01 00:00:00")) + } + + // MARK: - Key columns are never treated as comparison columns + + func testKeyColumnIsNotAlsoComparedAsAValue() async throws { + var options = DataCompareOptions() + options.keyColumns = ["id"] + + XCTAssertEqual(options.comparisonColumns(from: ["id", "name"]), ["name"]) + } + + // MARK: - Retention cap + + func testCountsStayExactWhenRetainedEntriesAreCapped() async throws { + let compareEngine = engine(columns: ["id"]) { options in + options.maxRetainedEntries = 2 + } + let source = (1...10).map { row(["id": String(format: "%03d", $0)]) } + + let summary = try await diff(source: source, target: [], engine: compareEngine) + + XCTAssertEqual(summary.insertCount, 10, "counts must be exact even when entries are capped") + XCTAssertEqual(summary.entries.count, 2) + XCTAssertTrue(summary.truncatedEntries) + } + + // MARK: - Cancellation + + func testCancellationStopsTheComparison() async { + let compareEngine = engine(columns: ["id"]) + let source = (1...5_000).map { row(["id": String(format: "%06d", $0)]) } + + let task = Task { + try await compareEngine.compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: []) + ) + } + task.cancel() + + do { + _ = try await task.value + } catch { + XCTAssertTrue(error is CancellationError) + return + } + XCTAssertTrue(true, "comparison finished before cancellation was observed") + } +} + +final class CellValueComparatorTests: XCTestCase { + private func comparator(tolerance: Double = 0, fractionalDigits: Int = 6) -> CellValueComparator { + var options = DataCompareOptions() + options.floatTolerance = tolerance + options.timestampFractionalDigits = fractionalDigits + return CellValueComparator(options: options) + } + + func testFloatToleranceTreatsNearlyEqualValuesAsEqual() { + let outcome = comparator(tolerance: 0.001).compare( + .text("109.05999755859375"), + .text("109.05999755859381") + ) + + XCTAssertTrue(outcome.isEqual) + XCTAssertEqual(outcome.rule, .floatTolerance) + } + + func testFloatToleranceIsSymmetric() { + let subject = comparator(tolerance: 0.5) + let pairs: [(String, String)] = [ + ("1.0", "1.2"), + ("1.0", "1.8"), + ("100.10", "100.1000"), + ("-3.0", "-3.4"), + ("0.0", "0.6") + ] + + for (left, right) in pairs { + let forward = subject.compare(.text(left), .text(right)) + let backward = subject.compare(.text(right), .text(left)) + XCTAssertEqual( + forward.isEqual, + backward.isEqual, + "comparison of \(left) and \(right) must not depend on argument order" + ) + } + } + + func testExactComparisonIsSymmetricForMismatchedKinds() { + let subject = comparator() + + XCTAssertEqual( + subject.compare(.null, .text("x")).isEqual, + subject.compare(.text("x"), .null).isEqual + ) + XCTAssertEqual( + subject.compare(.bytes(Data([0x01])), .text("x")).isEqual, + subject.compare(.text("x"), .bytes(Data([0x01]))).isEqual + ) + } + + func testZeroToleranceKeepsExactNumericComparison() { + let outcome = comparator(tolerance: 0).compare(.text("1.0"), .text("1.00")) + + XCTAssertFalse(outcome.isEqual, "without a declared tolerance nothing is smoothed over") + } + + func testEquivalentInstantsWithDifferentOffsetsCompareEqual() { + let outcome = comparator().compare( + .text("1999-01-15 08:00:00-08:00"), + .text("1999-01-15 11:00:00-05:00") + ) + + XCTAssertTrue(outcome.isEqual, "the same instant written at two offsets is not a difference") + XCTAssertEqual(outcome.rule, .timestampPrecision) + } + + func testTimestampPrecisionTruncationIsHonoured() { + let coarse = comparator(fractionalDigits: 0).compare( + .text("2026-01-01 00:00:00.100000"), + .text("2026-01-01 00:00:00.200000") + ) + XCTAssertTrue(coarse.isEqual) + + let fine = comparator(fractionalDigits: 6).compare( + .text("2026-01-01 00:00:00.100000"), + .text("2026-01-01 00:00:00.200000") + ) + XCTAssertFalse(fine.isEqual) + } + + func testBinaryContentComparedByBytes() { + let subject = comparator() + + XCTAssertTrue(subject.compare(.bytes(Data([1, 2, 3])), .bytes(Data([1, 2, 3]))).isEqual) + XCTAssertFalse(subject.compare(.bytes(Data([1, 2, 3])), .bytes(Data([1, 2, 4]))).isEqual) + } +} + +final class KeyOrderingTests: XCTestCase { + func testNumericKeysOrderNumericallyNotLexically() { + let result = KeyOrdering.compare([.text("9")], [.text("10")]) + + XCTAssertEqual(result, .orderedAscending, "9 must sort before 10") + } + + func testTextKeysOrderLexically() { + XCTAssertEqual(KeyOrdering.compare([.text("a")], [.text("b")]), .orderedAscending) + } + + func testEqualKeysReportSame() { + XCTAssertEqual(KeyOrdering.compare([.text("a"), .text("1")], [.text("a"), .text("1")]), .orderedSame) + } + + func testOrderingIsAntisymmetric() { + let pairs: [([PluginCellValue], [PluginCellValue])] = [ + ([.text("1")], [.text("2")]), + ([.text("b")], [.text("a")]), + ([.null], [.text("x")]) + ] + + for (left, right) in pairs { + let forward = KeyOrdering.compare(left, right) + let backward = KeyOrdering.compare(right, left) + XCTAssertNotEqual(forward, backward) + XCTAssertNotEqual(forward, .orderedSame) + } + } +} diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift new file mode 100644 index 000000000..58586d3d6 --- /dev/null +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -0,0 +1,336 @@ +// +// SchemaSyncScriptBuilderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +private final class StubSyncDriver: PluginDatabaseDriver, @unchecked Sendable { + func connect() async throws {} + + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + "CREATE TABLE \(definition.tableName)" + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + "DROP \(objectType) \(name)" + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(table) ADD \(column.name)" + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(table) DROP \(columnName)" + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + "ALTER TABLE \(table) MODIFY \(newColumn.name)" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + "CREATE INDEX \(index.name) ON \(table)" + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(indexName) ON \(table)" + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + "ALTER TABLE \(table) ADD CONSTRAINT \(fk.name)" + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + "ALTER TABLE \(table) DROP CONSTRAINT \(constraintName)" + } +} + +final class SchemaSyncScriptBuilderTests: XCTestCase { + private var driver: StubSyncDriver! + private var builder: SchemaSyncScriptBuilder! + + override func setUp() { + super.setUp() + driver = StubSyncDriver() + builder = SchemaSyncScriptBuilder(targetDriver: driver) + } + + override func tearDown() { + driver = nil + builder = nil + super.tearDown() + } + + private func snapshot(_ name: String) -> TableStructureSnapshot { + TableStructureSnapshot( + name: name, + columns: [ + EditableColumnDefinition( + id: UUID(), name: "id", dataType: "int", isNullable: false, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: nil, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: true + ) + ] + ) + } + + private func foreignKey(from child: String, to parent: String) -> PluginForeignKeyInfo { + PluginForeignKeyInfo( + name: "fk_\(child)_\(parent)", + column: "\(parent)_id", + referencedTable: parent, + referencedColumn: "id" + ) + } + + // MARK: - Cross-table ordering + + func testCreatesEmitParentsBeforeChildren() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = ["orders": [foreignKey(from: "orders", to: "customers")]] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["customers", "orders"]) + } + + func testDropsEmitChildrenBeforeParents() { + let operations: [SchemaSyncOperation] = [ + .dropTable(name: "customers", schema: nil), + .dropTable(name: "orders", schema: nil) + ] + let foreignKeys = ["orders": [foreignKey(from: "orders", to: "customers")]] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["orders", "customers"]) + } + + func testThreeTableChainOrdersTransitively() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("line_items")), + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = [ + "orders": [foreignKey(from: "orders", to: "customers")], + "line_items": [foreignKey(from: "line_items", to: "orders")] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["customers", "orders", "line_items"]) + } + + func testDropsRunBeforeCreatesWhichRunBeforeAlters() { + let operations: [SchemaSyncOperation] = [ + .alterTable(name: "a", schema: nil, changes: []), + .createTable(snapshot("b")), + .dropTable(name: "c", schema: nil) + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: [:]) + + XCTAssertEqual(ordered.map { $0.tableName }, ["c", "b", "a"]) + } + + func testCircularForeignKeysStillEmitEveryTable() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("a")), + .createTable(snapshot("b")) + ] + let foreignKeys = [ + "a": [foreignKey(from: "a", to: "b")], + "b": [foreignKey(from: "b", to: "a")] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(Set(ordered.map { $0.tableName }), ["a", "b"], "a cycle must not drop tables from the script") + } + + // MARK: - Intra-table ordering + + func testIntraTableOrderDropsConstraintsBeforeColumnsAndAddsThemLast() { + let column = EditableColumnDefinition.placeholder() + let index = EditableIndexDefinition.placeholder() + let foreignKeyDefinition = EditableForeignKeyDefinition.placeholder() + + let sortedChanges = SchemaChangeOrdering.sorted([ + .addForeignKey(foreignKeyDefinition), + .addColumn(column), + .deleteForeignKey(foreignKeyDefinition), + .addIndex(index), + .deleteColumn(column) + ]) + + let positions = sortedChanges.map { change -> Int in + switch change { + case .deleteForeignKey: return 0 + case .deleteColumn: return 1 + case .addColumn: return 2 + case .addIndex: return 3 + case .addForeignKey: return 4 + default: return 99 + } + } + XCTAssertEqual(positions, positions.sorted(), "changes must be emitted in dependency-safe order") + } + + // MARK: - Hazards + + func testDropTableIsRefusedByDefault() throws { + let statements = try builder.build( + operations: [.dropTable(name: "users", schema: nil)], + foreignKeysByTable: [:] + ) + + XCTAssertEqual(statements.count, 1) + XCTAssertTrue(statements[0].isRefusedByDefault) + XCTAssertEqual(statements[0].hazards.first?.kind, .dataLoss) + } + + func testCreateTableCarriesNoHazard() throws { + let statements = try builder.build( + operations: [.createTable(snapshot("users"))], + foreignKeysByTable: [:] + ) + + XCTAssertEqual(statements.count, 1) + XCTAssertFalse(statements[0].isRefusedByDefault) + XCTAssertTrue(statements[0].hazards.isEmpty) + } + + func testEveryStatementIsTerminated() throws { + let statements = try builder.build( + operations: [.createTable(snapshot("users")), .dropTable(name: "old", schema: nil)], + foreignKeysByTable: [:] + ) + + for statement in statements { + XCTAssertTrue(statement.sql.hasSuffix(";"), "\(statement.sql) is not terminated") + } + } +} + +final class SyncSafetyClassifierTests: XCTestCase { + private let classifier = SyncSafetyClassifier() + + private func column(_ name: String, _ dataType: String, nullable: Bool = true) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), name: name, dataType: dataType, isNullable: nullable, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: nil, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: false + ) + } + + func testDropColumnIsRefusedByDefault() { + let hazards = classifier.hazards(for: .deleteColumn(column("email", "varchar(255)"))) + + XCTAssertEqual(hazards.first?.severity, .refusedByDefault) + XCTAssertEqual(hazards.first?.kind, .dataLoss) + } + + func testNarrowingTypeChangeIsRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("name", "varchar(255)"), + new: column("name", "varchar(50)") + )) + + XCTAssertTrue(hazards.contains { $0.kind == .lossyTypeChange && $0.severity == .refusedByDefault }) + } + + func testWideningTypeChangeIsNotRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("name", "varchar(50)"), + new: column("name", "varchar(255)") + )) + + XCTAssertFalse(hazards.contains { $0.kind == .lossyTypeChange }) + } + + func testMakingColumnNotNullIsRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("email", "varchar(50)", nullable: true), + new: column("email", "varchar(50)", nullable: false) + )) + + XCTAssertTrue(hazards.contains { $0.kind == .dataLoss && $0.severity == .refusedByDefault }) + } + + func testAddColumnCarriesNoHazard() { + XCTAssertTrue(classifier.hazards(for: .addColumn(column("nickname", "varchar(20)"))).isEmpty) + } + + func testPrimaryKeyChangeIsRefused() { + let hazards = classifier.hazards(for: .modifyPrimaryKey(old: ["id"], new: ["id", "tenant"])) + + XCTAssertEqual(hazards.first?.severity, .refusedByDefault) + } +} + +final class CompareSyncEngineFamilyTests: XCTestCase { + func testSameTypeIsAlwaysAllowed() { + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .postgresql)) + } + + func testMySqlAndMariaDbAreCompatibleInBothDirections() { + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .mariadb)) + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .mariadb, to: .mysql)) + } + + func testUnrelatedEnginesAreRefused() { + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .mysql)) + } + + func testUnknownFutureTypeIsCompatibleOnlyWithItself() { + let future = DatabaseType(rawValue: "SomeFutureEngine") + + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: future, to: future)) + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: future, to: .mysql)) + } + + func testCrossEngineDataWarningOnlyAppearsWhenTypesDiffer() { + XCTAssertNil(CompareSyncEngineFamily.crossEngineDataWarning(from: .mysql, to: .mysql)) + XCTAssertNotNil(CompareSyncEngineFamily.crossEngineDataWarning(from: .mysql, to: .postgresql)) + } +} diff --git a/TableProTests/Core/Compare/StructureDiffEngineTests.swift b/TableProTests/Core/Compare/StructureDiffEngineTests.swift new file mode 100644 index 000000000..a2b7f3d65 --- /dev/null +++ b/TableProTests/Core/Compare/StructureDiffEngineTests.swift @@ -0,0 +1,356 @@ +// +// StructureDiffEngineTests.swift +// TableProTests +// + +import Foundation +import XCTest + +@testable import TablePro + +final class StructureDiffEngineTests: XCTestCase { + private func column( + _ name: String, + _ dataType: String = "int", + nullable: Bool = true, + defaultValue: String? = nil, + autoIncrement: Bool = false, + comment: String? = nil, + collation: String? = nil, + charset: String? = nil, + extra: String? = nil, + primaryKey: Bool = false + ) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), + name: name, + dataType: dataType, + isNullable: nullable, + defaultValue: defaultValue, + autoIncrement: autoIncrement, + unsigned: false, + comment: comment, + collation: collation, + onUpdate: nil, + charset: charset, + extra: extra, + isPrimaryKey: primaryKey + ) + } + + private func index( + _ name: String, + columns: [String], + unique: Bool = false, + primary: Bool = false + ) -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), + name: name, + columns: columns, + type: .btree, + isUnique: unique, + isPrimary: primary, + comment: nil, + columnPrefixes: [:], + whereClause: nil + ) + } + + private func foreignKey( + _ name: String, + columns: [String], + referencedTable: String, + referencedColumns: [String] + ) -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: columns, + referencedTable: referencedTable, + referencedColumns: referencedColumns, + referencedSchema: nil, + onDelete: .noAction, + onUpdate: .noAction + ) + } + + private func table( + _ name: String, + columns: [EditableColumnDefinition], + indexes: [EditableIndexDefinition] = [], + foreignKeys: [EditableForeignKeyDefinition] = [], + engine: String? = nil, + collation: String? = nil + ) -> TableStructureSnapshot { + TableStructureSnapshot( + name: name, + schema: nil, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + engine: engine, + charset: nil, + collation: collation + ) + } + + // MARK: - Table presence + + func testTableOnlyInSourceIsCreateAndDefaultsToSkip() { + let engine = StructureDiffEngine() + let report = engine.compare( + source: [table("users", columns: [column("id")])], + target: [] + ) + + XCTAssertEqual(report.results.count, 1) + XCTAssertEqual(report.results[0].status, .onlyInSource) + XCTAssertEqual(report.results[0].suggestedAction, .create) + } + + func testTableOnlyInTargetIsDrop() { + let engine = StructureDiffEngine() + let report = engine.compare( + source: [], + target: [table("legacy", columns: [column("id")])] + ) + + XCTAssertEqual(report.results[0].status, .onlyInTarget) + XCTAssertEqual(report.results[0].suggestedAction, .drop) + } + + func testIdenticalTablesReportIdentical() { + let engine = StructureDiffEngine() + let source = table("users", columns: [column("id"), column("name", "varchar(20)")]) + let target = table("users", columns: [column("id"), column("name", "varchar(20)")]) + + let result = engine.compareTable(source: source, target: target) + + XCTAssertEqual(result.status, .identical) + XCTAssertTrue(result.changes.isEmpty) + } + + // MARK: - Column changes, direction is target becomes source + + func testMissingColumnInTargetProducesAddColumn() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id"), column("email", "varchar(255)")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .differs) + guard case .addColumn(let added) = result.changes.first else { + return XCTFail("Expected addColumn, got \(result.changes)") + } + XCTAssertEqual(added.name, "email") + } + + func testExtraColumnInTargetProducesDeleteColumn() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id")]), + target: table("users", columns: [column("id"), column("obsolete")]) + ) + + guard case .deleteColumn(let removed) = result.changes.first else { + return XCTFail("Expected deleteColumn, got \(result.changes)") + } + XCTAssertEqual(removed.name, "obsolete") + } + + func testModifyColumnCarriesTargetAsOldAndSourceAsNew() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id", "bigint")]), + target: table("users", columns: [column("id", "int")]) + ) + + guard case .modifyColumn(let old, let new) = result.changes.first else { + return XCTFail("Expected modifyColumn, got \(result.changes)") + } + XCTAssertEqual(old.dataType, "int", "old must be the target's current state") + XCTAssertEqual(new.dataType, "bigint", "new must be the source's desired state") + } + + func testNullabilityChangeIsDetected() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id", nullable: false)]), + target: table("users", columns: [column("id", nullable: true)]) + ) + + XCTAssertEqual(result.status, .differs) + XCTAssertEqual(result.changes.count, 1) + } + + // MARK: - Ignore options each kill a named false positive + + func testIdentifierCaseOnlyDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("Users", columns: [column("ID")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .identical) + } + + func testIdentifierCaseDifferenceIsReportedWhenOptionOff() { + var options = StructureCompareOptions() + options.ignoreIdentifierCase = false + let result = StructureDiffEngine(options: options).compareTable( + source: table("users", columns: [column("ID")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .differs) + } + + func testAutoIncrementSeedOnlyDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("id", extra: "auto_increment=1522")]), + target: table("users", columns: [column("id", extra: "auto_increment=184")]) + ) + + XCTAssertEqual(result.status, .identical, "AUTO_INCREMENT drift must not be a difference") + } + + func testWhitespaceOnlyDefaultDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("flag", defaultValue: "0")]), + target: table("users", columns: [column("flag", defaultValue: " 0 ")]) + ) + + XCTAssertEqual(result.status, .identical) + } + + func testCollationOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + let source = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_general_ci")]) + let target = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_unicode_ci")]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreCollationAndCharset = false + XCTAssertEqual( + StructureDiffEngine(options: options).compareTable(source: source, target: target).status, + .differs + ) + } + + func testCommentOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + let source = table("users", columns: [column("id", comment: "primary id")]) + let target = table("users", columns: [column("id", comment: nil)]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreCommentsAndOwners = false + XCTAssertEqual( + StructureDiffEngine(options: options).compareTable(source: source, target: target).status, + .differs + ) + } + + func testColumnOrderIsIgnoredByDefaultAndNotedWhenOptionOff() { + let source = table("users", columns: [column("a"), column("b")]) + let target = table("users", columns: [column("b"), column("a")]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreColumnOrder = false + let result = StructureDiffEngine(options: options).compareTable(source: source, target: target) + XCTAssertEqual(result.status, .differs) + XCTAssertTrue(result.changes.isEmpty, "reordering emits no DDL") + XCTAssertEqual(result.notes.count, 1) + } + + // MARK: - Name-only differences never mark an object changed + + func testIndexMatchedOnStructureDespiteGeneratedNameDifference() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("email")], indexes: [index("idx_a1b2", columns: ["email"])]), + target: table("users", columns: [column("email")], indexes: [index("idx_c3d4", columns: ["email"])]) + ) + + XCTAssertTrue(result.changes.isEmpty, "a name-only index difference must emit no DDL") + XCTAssertEqual(result.notes.count, 1) + } + + func testIndexColumnDifferenceIsARealChange() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a", "b"])]), + target: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a"])]) + ) + + XCTAssertEqual(result.changes.count, 2, "expected one add and one delete") + } + + func testForeignKeyMatchedOnStructureDespiteNameDifference() { + let source = table( + "orders", + columns: [column("user_id")], + foreignKeys: [foreignKey("fk_1", columns: ["user_id"], referencedTable: "users", referencedColumns: ["id"])] + ) + let target = table( + "orders", + columns: [column("user_id")], + foreignKeys: [foreignKey("fk_2", columns: ["user_id"], referencedTable: "users", referencedColumns: ["id"])] + ) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + XCTAssertTrue(result.changes.isEmpty) + XCTAssertEqual(result.notes.count, 1) + } + + // MARK: - Primary key + + func testPrimaryKeyDifferenceProducesSingleModifyPrimaryKey() { + let source = table("users", columns: [column("id", primaryKey: true), column("tenant", primaryKey: true)]) + let target = table("users", columns: [column("id", primaryKey: true), column("tenant")]) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + let primaryKeyChanges = result.changes.filter { + if case .modifyPrimaryKey = $0 { return true } + return false + } + XCTAssertEqual(primaryKeyChanges.count, 1) + guard case .modifyPrimaryKey(let old, let new) = primaryKeyChanges[0] else { + return XCTFail("Expected modifyPrimaryKey") + } + XCTAssertEqual(old, ["id"]) + XCTAssertEqual(new, ["id", "tenant"]) + } + + func testPrimaryKeyMembershipAloneDoesNotAlsoEmitModifyColumn() { + let source = table("users", columns: [column("id", primaryKey: true)]) + let target = table("users", columns: [column("id", primaryKey: false)]) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + let columnChanges = result.changes.filter { + if case .modifyColumn = $0 { return true } + return false + } + XCTAssertTrue(columnChanges.isEmpty, "primary key membership is reported once, via modifyPrimaryKey") + } + + // MARK: - Symmetry + + func testComparisonIsSymmetricInTheObjectsItReports() { + let engine = StructureDiffEngine() + let left = [table("a", columns: [column("id")]), table("b", columns: [column("id")])] + let right = [table("b", columns: [column("id")]), table("c", columns: [column("id")])] + + let forward = engine.compare(source: left, target: right) + let backward = engine.compare(source: right, target: left) + + XCTAssertEqual(forward.count(of: .onlyInSource), backward.count(of: .onlyInTarget)) + XCTAssertEqual(forward.count(of: .onlyInTarget), backward.count(of: .onlyInSource)) + XCTAssertEqual(forward.count(of: .identical), backward.count(of: .identical)) + } +} diff --git a/docs/docs.json b/docs/docs.json index 5ce68d781..b3ced4f0b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -133,6 +133,7 @@ "features/csv-inspector", "features/import-export", "features/backup-restore", + "features/compare-sync", "features/change-tracking", "features/filtering" ] diff --git a/docs/features/compare-sync.mdx b/docs/features/compare-sync.mdx new file mode 100644 index 000000000..89beafbb9 --- /dev/null +++ b/docs/features/compare-sync.mdx @@ -0,0 +1,125 @@ +--- +title: Compare & Sync +description: Compare structure or data between two connections and generate the SQL script that syncs the target +--- + +Compare two connections and generate the SQL script that brings one in line with the other. Use it to check whether staging matches production, to diff two versions of a schema, or to copy rows between servers. + +Requires a Starter license. + +## Opening it + +- **File > Compare & Sync Databases...** +- Right-click a connection in the sidebar or on the welcome screen and choose **Compare/Sync with...**. The connection you clicked is filled in as the source. + +## Source and target + +The **source** is the connection that will not change. The **target** is the connection that will change. + +Both pickers start empty. Nothing is preselected as the target, so the write side is always a deliberate choice. A sentence under the pickers spells out what will happen, for example *Compare `staging` and write changes to `prod`*. Use **Swap** to reverse the direction. + +A connection whose safe mode level is **Read-Only** cannot be chosen as the target. It appears in the list with the reason shown, so you find out at selection time rather than after comparing. + +The target connection's colour is carried into the window and onto the apply button, so a connection you have tagged as production stays visually distinct. + +Nothing is written to either database until you apply. Until then the window shows **Comparing only, nothing has been written**. + +## Comparing structure + +Structure comparison reads column, index, foreign key, and primary key metadata from both sides and compares the parsed values. It does not diff `CREATE TABLE` text, because driver-rendered DDL varies by formatting and by system-generated constraint names, which reports identical objects as different. + +Each table lands in one of four states: **only in source**, **only in target**, **different**, or **identical**. Tables that cannot be compared get their own group with the reason, so nothing is silently skipped. + +Every object starts unchecked. You choose what to apply. + +### What is ignored + +These differences drift between environments by design, so they are ignored by default. Turn any of them off to have the difference reported. + +| Option | What it ignores | +| --- | --- | +| Identifier case | `Users` and `users` are the same table | +| Column order | Same columns in a different order | +| Whitespace in text | Padding inside default values and comments | +| Auto-increment seed | `AUTO_INCREMENT=184` against `AUTO_INCREMENT=1522` | +| Collation and character set | Column and table collation differences | +| Comments and owners | Column comments, object owners, and definers | + +Indexes and foreign keys are matched on structure, not on name. An index that matches on columns, uniqueness, and type but carries a different system-generated name is reported as a note rather than as a change, so no DDL is generated for it. + +Renames are never guessed. A renamed column shows as a drop and an add, because no tool can tell a rename from a drop plus an add without being told. + +### Same-engine rule + +Generating a structure sync script needs matching database types. Column data types are engine-specific strings, so building DDL for one engine out of another engine's metadata is not sound. MySQL and MariaDB are treated as one family. + +Two different engines can still be compared. You get the differences as a read-only view, with no script. + +## Comparing data + +Data comparison matches rows by a key and walks both sides in key order, so neither table is loaded into memory in full. + +Key columns are filled in from the source table's primary key. Composite keys are supported. A table with no primary key, or with no columns in common, is listed as not comparable rather than matched on a guess, because matching on every column cannot tell one row from two identical rows. + +Tables are matched by name across the two sides, and only the columns present on both are compared. + +Three actions control what the script contains: + +- **Insert missing rows** (on by default) +- **Update differing rows** (on by default) +- **Delete extra rows** (off by default) + +Delete is off by default so the first run cannot remove a row you did not mean to touch. + +### Which columns are compared + +Columns used to compare are separate from columns that get written. Exclude `updated_at` from comparison and rows that differ only in that column stop showing as differences, while the column is still carried into any row that is inserted or updated. + +### How values are judged equal + +- `NULL` equals only `NULL`. It is never treated as an empty string. +- Numbers compare exactly unless you set a tolerance. A tolerance of `0.001` treats `109.05999755859375` and `109.05999755859381` as equal. The comparison gives the same answer whichever side you call the source. +- Timestamps compare as instants at the fractional precision you choose. The same instant written at two offsets, such as `08:00:00-08:00` and `11:00:00-05:00`, is not a difference. No timezone conversion is applied anywhere in the comparison. +- Binary values compare by bytes. + +When a value is flagged, the row inspector names the rule that fired, so you can tell why two values that look the same were treated as different. + +## The script + +The generated script is editable before it runs. + +Statements are ordered by foreign key dependency: tables are dropped children first, and created parents first. For data, inserts come before updates before deletes. A circular foreign key still emits every table. + +### Held-back statements + +Anything that can destroy data is listed but not executed unless you allow it for that run: + +- Dropping a table or a column +- A type change that can truncate values, such as `varchar(255)` to `varchar(50)` +- Making a nullable column `NOT NULL` +- Changing the primary key + +Held-back statements stay visible in the preview, so what you see is what would run. There is no setting that permanently allows them; the choice is made per run. + +Collation changes and dropped indexes are shown as warnings rather than held back. + +## Applying + +Applying runs the script against the target only. You confirm once, in an alert that names the target and counts the statements. + +Data sync runs in a transaction where the target supports one. Structure sync runs in a transaction only where the target supports transactional DDL; on MySQL and Oracle it does not, so statements that already ran stay applied if a later one fails. The window says which case applies rather than implying a rollback you will not get. + +If you choose to continue past errors, the transaction option is disabled, because the two combined leave the target half-applied. + +Progress is reported per statement and the run can be cancelled. Cancelling stops the run between statements; a statement already in flight finishes. + +## Saving a setup + +Save the source, target, mode, scope, ignore options, and key choices as a named comparison you can run again. + +## Limits + +- One direction per run. To sync both ways, swap and run again. +- Two live connections only. Backups, dump files, and source control are not comparison endpoints. +- No scheduling. Every comparison is started by you and reviewed before anything runs. +- Structure sync needs a driver that reports comparable structure metadata and can generate DDL. Data sync needs rows addressable by a key. A driver that declares neither stops the comparison with the reason shown once both sides are connected. From 5971751998b8e8777159e3ae539b4d05efc299c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 16:42:55 +0700 Subject: [PATCH 2/3] feat(compare): key and column selection, definition diff, profiles, and close guard (#721) --- TablePro/AppDelegate.swift | 14 ++ .../Core/Compare/CompareSyncRunRegistry.swift | 34 ++++ .../Compare/CompareSyncSession+Data.swift | 8 + .../Compare/CompareSyncSession+Editing.swift | 115 +++++++++++ .../Core/Compare/CompareSyncSession+Run.swift | 10 + .../Core/Compare/CompareSyncSession.swift | 3 + .../Compare/TableDefinitionRenderer.swift | 68 +++++++ .../Views/Compare/CompareStatusBanner.swift | 13 +- .../Views/Compare/CompareSyncCloseGuard.swift | 75 ++++++++ .../Views/Compare/CompareSyncSetupView.swift | 50 +++++ .../Views/Compare/CompareSyncWindowView.swift | 2 +- .../Views/Compare/DataCompareReviewView.swift | 110 ++++++++++- .../Compare/StructureCompareReviewView.swift | 25 ++- .../Compare/StructureDefinitionDiffView.swift | 145 ++++++++++++++ .../TableDefinitionRendererTests.swift | 181 ++++++++++++++++++ TableProUITests/CompareSyncUITests.swift | 95 +++++++++ docs/features/compare-sync.mdx | 12 +- 17 files changed, 946 insertions(+), 14 deletions(-) create mode 100644 TablePro/Core/Compare/CompareSyncRunRegistry.swift create mode 100644 TablePro/Core/Compare/CompareSyncSession+Editing.swift create mode 100644 TablePro/Core/Compare/TableDefinitionRenderer.swift create mode 100644 TablePro/Views/Compare/CompareSyncCloseGuard.swift create mode 100644 TablePro/Views/Compare/StructureDefinitionDiffView.swift create mode 100644 TableProTests/Core/Compare/TableDefinitionRendererTests.swift create mode 100644 TableProUITests/CompareSyncUITests.swift diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 9dd33a442..999a2dbc7 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -125,6 +125,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if CompareSyncRunRegistry.shared.isApplying { + let alert = NSAlert() + alert.messageText = String(localized: "A sync is still running") + alert.informativeText = String( + format: String(localized: "Quitting stops the run against %@. Statements that already ran stay applied."), + CompareSyncRunRegistry.shared.applyingTargetNames.joined(separator: ", ") + ) + alert.alertStyle = .critical + alert.addButton(withTitle: String(localized: "Keep Running")) + alert.addButton(withTitle: String(localized: "Stop and Quit")) + alert.buttons[1].hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { return .terminateCancel } + } + let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges() if hasUnsaved { let alert = NSAlert() diff --git a/TablePro/Core/Compare/CompareSyncRunRegistry.swift b/TablePro/Core/Compare/CompareSyncRunRegistry.swift new file mode 100644 index 000000000..1c4a87aa3 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncRunRegistry.swift @@ -0,0 +1,34 @@ +// +// CompareSyncRunRegistry.swift +// TablePro +// +// Tracks sessions that are mid-apply so closing the window or quitting the app +// can warn before a half-applied script is abandoned. +// + +import Foundation + +@MainActor +internal final class CompareSyncRunRegistry { + internal static let shared = CompareSyncRunRegistry() + + private var applyingTargets: [ObjectIdentifier: String] = [:] + + private init() {} + + internal func markApplying(_ session: CompareSyncSession, target: String) { + applyingTargets[ObjectIdentifier(session)] = target + } + + internal func clear(_ session: CompareSyncSession) { + applyingTargets.removeValue(forKey: ObjectIdentifier(session)) + } + + internal var isApplying: Bool { + !applyingTargets.isEmpty + } + + internal var applyingTargetNames: [String] { + applyingTargets.values.sorted() + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession+Data.swift b/TablePro/Core/Compare/CompareSyncSession+Data.swift index 76498ccd3..d3342397e 100644 --- a/TablePro/Core/Compare/CompareSyncSession+Data.swift +++ b/TablePro/Core/Compare/CompareSyncSession+Data.swift @@ -63,6 +63,7 @@ internal extension CompareSyncSession { } self.activity = .comparing + let pending = self.pendingSelectedTables var plans = try await Self.buildPlans( sourceDriver: sourceDriver, targetDriver: targetDriver, @@ -71,6 +72,13 @@ internal extension CompareSyncSession { existing: self.dataPlans ) + if !pending.isEmpty { + for index in plans.indices { + plans[index].isEnabled = pending.contains(plans[index].id) + } + self.pendingSelectedTables = [] + } + for index in plans.indices where plans[index].isEnabled && plans[index].isComparable { try Task.checkCancellation() plans[index].summary = try await self.runDataDiff( diff --git a/TablePro/Core/Compare/CompareSyncSession+Editing.swift b/TablePro/Core/Compare/CompareSyncSession+Editing.swift new file mode 100644 index 000000000..c3780ea76 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession+Editing.swift @@ -0,0 +1,115 @@ +// +// CompareSyncSession+Editing.swift +// TablePro +// +// Editing the comparison setup after a first run: key columns, which columns +// take part in the comparison, and saved profiles. +// + +import Foundation + +internal extension CompareSyncSession { + // MARK: - Key columns + + func setKeyColumns(_ columns: [String], for planId: String) { + guard let index = dataPlans.firstIndex(where: { $0.id == planId }) else { return } + dataPlans[index].keyColumns = columns + dataPlans[index].summary = nil + dataPlans[index].unavailableReason = columns.isEmpty + ? String(localized: "No primary key. Choose key columns to compare this table.") + : nil + invalidateGeneratedScript() + } + + func toggleKeyColumn(_ column: String, for planId: String) { + guard let plan = dataPlans.first(where: { $0.id == planId }) else { return } + var columns = plan.keyColumns + if let existing = columns.firstIndex(where: { $0.caseInsensitiveCompare(column) == .orderedSame }) { + columns.remove(at: existing) + } else { + columns.append(column) + } + setKeyColumns(columns, for: planId) + } + + func isKeyColumn(_ column: String, in plan: DataComparePlan) -> Bool { + plan.keyColumns.contains { $0.caseInsensitiveCompare(column) == .orderedSame } + } + + // MARK: - Comparison columns + + func isColumnCompared(_ column: String) -> Bool { + !dataOptions.excludedFromComparison.contains { $0.caseInsensitiveCompare(column) == .orderedSame } + } + + func toggleComparedColumn(_ column: String) { + if let existing = dataOptions.excludedFromComparison + .first(where: { $0.caseInsensitiveCompare(column) == .orderedSame }) { + dataOptions.excludedFromComparison.remove(existing) + } else { + dataOptions.excludedFromComparison.insert(column) + } + clearDataSummaries() + } + + func clearDataSummaries() { + for index in dataPlans.indices { + dataPlans[index].summary = nil + } + invalidateGeneratedScript() + } + + var needsRecompare: Bool { + guard mode == .data, !dataPlans.isEmpty else { return false } + return dataPlans.contains { $0.isEnabled && $0.isComparable && $0.summary == nil } + } + + private func invalidateGeneratedScript() { + statements = [] + editedScript = nil + } + + // MARK: - Profiles + + var savedProfiles: [CompareSyncProfile] { + guard let source, let target else { return [] } + return CompareSyncProfileStorage.shared.profiles( + source: source.connectionId, target: target.connectionId, mode: mode + ) + } + + func saveProfile(named name: String) { + guard let source, let target, !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let profile = CompareSyncProfile( + name: name, + sourceConnectionId: source.connectionId, + targetConnectionId: target.connectionId, + mode: mode, + structureOptions: structureOptions, + dataOptions: dataOptions, + selectedTables: selectedTableIdentifiers + ) + CompareSyncProfileStorage.shared.save(profile) + } + + func apply(_ profile: CompareSyncProfile) { + mode = profile.mode + structureOptions = profile.structureOptions + dataOptions = profile.dataOptions + resetComparison() + pendingSelectedTables = Set(profile.selectedTables) + } + + func deleteProfile(_ profile: CompareSyncProfile) { + CompareSyncProfileStorage.shared.delete(profile) + } + + private var selectedTableIdentifiers: [String] { + switch mode { + case .structure: + return tableActions.filter { $0.value != .skip }.map { $0.key }.sorted() + case .data: + return dataPlans.filter { $0.isEnabled }.map { $0.id }.sorted() + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession+Run.swift b/TablePro/Core/Compare/CompareSyncSession+Run.swift index bd8d270b8..a75245f41 100644 --- a/TablePro/Core/Compare/CompareSyncSession+Run.swift +++ b/TablePro/Core/Compare/CompareSyncSession+Run.swift @@ -46,7 +46,15 @@ internal extension CompareSyncSession { sourceSnapshots.map { ($0.qualifiedName, $0) }, uniquingKeysWith: { first, _ in first } ) + self.targetSnapshotCache = Dictionary( + targetSnapshots.map { ($0.qualifiedName, $0) }, + uniquingKeysWith: { first, _ in first } + ) self.tableActions = [:] + for result in report.comparable where self.pendingSelectedTables.contains(result.id) { + self.tableActions[result.id] = result.suggestedAction + } + self.pendingSelectedTables = [] self.statements = [] self.editedScript = nil self.lastAction = .compared(Date(), differences: report.comparable.filter { $0.status != .identical }.count) @@ -100,9 +108,11 @@ internal extension CompareSyncSession { guard let self else { return } self.activity = .applying self.hasWrittenToTarget = true + CompareSyncRunRegistry.shared.markApplying(self, target: target.qualifiedDescription) defer { self.activity = .idle self.progress = nil + CompareSyncRunRegistry.shared.clear(self) } do { diff --git a/TablePro/Core/Compare/CompareSyncSession.swift b/TablePro/Core/Compare/CompareSyncSession.swift index cd0c3394b..6fef0a07e 100644 --- a/TablePro/Core/Compare/CompareSyncSession.swift +++ b/TablePro/Core/Compare/CompareSyncSession.swift @@ -74,6 +74,8 @@ internal final class CompareSyncSession { internal var hasWrittenToTarget = false internal var sourceSnapshotCache: [String: TableStructureSnapshot] = [:] + internal var targetSnapshotCache: [String: TableStructureSnapshot] = [:] + internal var pendingSelectedTables: Set = [] internal var runTask: Task? @@ -197,6 +199,7 @@ internal final class CompareSyncSession { internal func resetComparison() { structureReport = nil sourceSnapshotCache = [:] + targetSnapshotCache = [:] dataPlans = [] tableActions = [:] statements = [] diff --git a/TablePro/Core/Compare/TableDefinitionRenderer.swift b/TablePro/Core/Compare/TableDefinitionRenderer.swift new file mode 100644 index 000000000..9607dbd96 --- /dev/null +++ b/TablePro/Core/Compare/TableDefinitionRenderer.swift @@ -0,0 +1,68 @@ +// +// TableDefinitionRenderer.swift +// TablePro +// +// Canonical text for one table's structure, rendered from parsed metadata +// rather than from driver DDL. Both sides of a comparison render through the +// same function, so a formatting difference can never show up as a difference. +// + +import Foundation + +internal enum TableDefinitionRenderer { + internal static func lines(for snapshot: TableStructureSnapshot) -> [String] { + var result: [String] = ["TABLE \(snapshot.name)"] + + for column in snapshot.columns { + result.append(" COLUMN \(column.name) \(column.dataType)\(columnAttributes(column))") + } + + let primaryKey = snapshot.primaryKeyColumns + if !primaryKey.isEmpty { + result.append(" PRIMARY KEY (\(primaryKey.joined(separator: ", ")))") + } + + for index in snapshot.indexes.filter({ !$0.isPrimary }) + .sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) { + let unique = index.isUnique ? "UNIQUE " : "" + var line = " \(unique)INDEX \(index.name) (\(index.columns.joined(separator: ", "))) USING \(index.type.rawValue)" + if let whereClause = index.whereClause, !whereClause.isEmpty { + line += " WHERE \(whereClause)" + } + result.append(line) + } + + for foreignKey in snapshot.foreignKeys + .sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) { + let columns = foreignKey.columns.joined(separator: ", ") + let referenced = foreignKey.referencedColumns.joined(separator: ", ") + result.append( + " FOREIGN KEY \(foreignKey.name) (\(columns)) REFERENCES \(foreignKey.referencedTable) (\(referenced))" + + " ON DELETE \(foreignKey.onDelete.rawValue) ON UPDATE \(foreignKey.onUpdate.rawValue)" + ) + } + + if let engine = snapshot.engine, !engine.isEmpty { + result.append(" ENGINE \(engine)") + } + if let collation = snapshot.collation, !collation.isEmpty { + result.append(" COLLATE \(collation)") + } + return result + } + + private static func columnAttributes(_ column: EditableColumnDefinition) -> String { + var parts: [String] = [] + if column.unsigned { parts.append("UNSIGNED") } + parts.append(column.isNullable ? "NULL" : "NOT NULL") + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + parts.append("DEFAULT \(defaultValue)") + } + if column.autoIncrement { parts.append("AUTO_INCREMENT") } + if let onUpdate = column.onUpdate, !onUpdate.isEmpty { parts.append("ON UPDATE \(onUpdate)") } + if let charset = column.charset, !charset.isEmpty { parts.append("CHARACTER SET \(charset)") } + if let collation = column.collation, !collation.isEmpty { parts.append("COLLATE \(collation)") } + if let comment = column.comment, !comment.isEmpty { parts.append("COMMENT '\(comment)'") } + return parts.isEmpty ? "" : " " + parts.joined(separator: " ") + } +} diff --git a/TablePro/Views/Compare/CompareStatusBanner.swift b/TablePro/Views/Compare/CompareStatusBanner.swift index 0f879d52e..c062b71cb 100644 --- a/TablePro/Views/Compare/CompareStatusBanner.swift +++ b/TablePro/Views/Compare/CompareStatusBanner.swift @@ -99,9 +99,16 @@ internal struct CompareSyncFooter: View { .disabled(!session.canCompare) case .review: Button(String(localized: "Back")) { session.step = .setup } - Button(String(localized: "Generate Script")) { session.buildScript() } - .keyboardShortcut(.defaultAction) - .disabled(!session.canBuildScript) + if session.needsRecompare { + Button(String(localized: "Compare Again")) { session.runComparison() } + .keyboardShortcut(.defaultAction) + Button(String(localized: "Generate Script")) { session.buildScript() } + .disabled(!session.canBuildScript) + } else { + Button(String(localized: "Generate Script")) { session.buildScript() } + .keyboardShortcut(.defaultAction) + .disabled(!session.canBuildScript) + } case .script: Button(String(localized: "Back")) { session.step = .review } Button(String(localized: "Apply to Target")) { isConfirmingApply = true } diff --git a/TablePro/Views/Compare/CompareSyncCloseGuard.swift b/TablePro/Views/Compare/CompareSyncCloseGuard.swift new file mode 100644 index 000000000..7cb7d0564 --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncCloseGuard.swift @@ -0,0 +1,75 @@ +// +// CompareSyncCloseGuard.swift +// TablePro +// +// Refuses to close the window while a script is being applied, so a run is not +// abandoned halfway without the user saying so. The delegate forwards every +// other message to whatever delegate SwiftUI installed. +// + +import AppKit +import SwiftUI + +internal struct CompareSyncCloseGuard: NSViewRepresentable { + internal let session: CompareSyncSession + + internal func makeNSView(context: Context) -> NSView { + let view = CloseGuardHostView() + view.session = session + return view + } + + internal func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? CloseGuardHostView)?.session = session + } +} + +private final class CloseGuardHostView: NSView { + var session: CompareSyncSession? { + didSet { proxy.session = session } + } + + private let proxy = CloseGuardDelegateProxy() + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { return } + guard !(window.delegate is CloseGuardDelegateProxy) else { return } + proxy.forwarding = window.delegate + window.delegate = proxy + } +} + +private final class CloseGuardDelegateProxy: NSObject, NSWindowDelegate { + weak var forwarding: NSWindowDelegate? + weak var session: CompareSyncSession? + + override func responds(to aSelector: Selector!) -> Bool { + if super.responds(to: aSelector) { return true } + return forwarding?.responds(to: aSelector) ?? false + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + if forwarding?.responds(to: aSelector) == true { return forwarding } + return super.forwardingTarget(for: aSelector) + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard let session, session.activity == .applying else { + return forwarding?.windowShouldClose?(sender) ?? true + } + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = String(localized: "A sync is still running") + alert.informativeText = String( + localized: "Closing now stops the run between statements. Statements that already ran stay applied." + ) + alert.addButton(withTitle: String(localized: "Keep Running")) + alert.addButton(withTitle: String(localized: "Stop and Close")) + guard alert.runModal() == .alertSecondButtonReturn else { return false } + + session.cancelRunningWork() + return true + } +} diff --git a/TablePro/Views/Compare/CompareSyncSetupView.swift b/TablePro/Views/Compare/CompareSyncSetupView.swift index c5f83d121..e1d0721de 100644 --- a/TablePro/Views/Compare/CompareSyncSetupView.swift +++ b/TablePro/Views/Compare/CompareSyncSetupView.swift @@ -13,6 +13,7 @@ internal struct CompareSyncSetupView: View { @Bindable internal var session: CompareSyncSession @State private var candidates: [CompareSyncEndpoint] = [] + @State private var profileName = "" internal var body: some View { ScrollView { @@ -20,6 +21,7 @@ internal struct CompareSyncSetupView: View { modePicker endpointPickers directionSummary + profilesSection optionsSection } .padding(20) @@ -129,6 +131,54 @@ internal struct CompareSyncSetupView: View { } } + @ViewBuilder + private var profilesSection: some View { + if session.source != nil, session.target != nil { + VStack(alignment: .leading, spacing: 6) { + Text("Saved comparisons") + .font(.headline) + + HStack(spacing: 8) { + let profiles = session.savedProfiles + if profiles.isEmpty { + Text("None saved for this pair yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Menu { + ForEach(profiles) { profile in + Button(profile.name) { session.apply(profile) } + } + Divider() + ForEach(profiles) { profile in + Button( + String(format: String(localized: "Delete %@"), profile.name), + role: .destructive + ) { + session.deleteProfile(profile) + } + } + } label: { + Label(String(localized: "Load"), systemImage: "tray.and.arrow.down") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + Spacer() + + TextField(String(localized: "Name"), text: $profileName) + .frame(width: 160) + Button(String(localized: "Save")) { + session.saveProfile(named: profileName) + profileName = "" + } + .disabled(profileName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + @ViewBuilder private var optionsSection: some View { if session.mode == .structure { diff --git a/TablePro/Views/Compare/CompareSyncWindowView.swift b/TablePro/Views/Compare/CompareSyncWindowView.swift index 01c02a08e..239e04088 100644 --- a/TablePro/Views/Compare/CompareSyncWindowView.swift +++ b/TablePro/Views/Compare/CompareSyncWindowView.swift @@ -13,7 +13,6 @@ internal struct CompareSyncWindowView: View { @State private var session = CompareSyncSession() @State private var isConfirmingApply = false - @State private var isConfirmingClose = false internal var body: some View { VStack(spacing: 0) { @@ -29,6 +28,7 @@ internal struct CompareSyncWindowView: View { CompareSyncFooter(session: session, isConfirmingApply: $isConfirmingApply) } .frame(minWidth: 900, minHeight: 560) + .background(CompareSyncCloseGuard(session: session)) .task { applyPrefill() } .alert(String(localized: "Apply changes?"), isPresented: $isConfirmingApply) { Button(String(localized: "Cancel"), role: .cancel) {} diff --git a/TablePro/Views/Compare/DataCompareReviewView.swift b/TablePro/Views/Compare/DataCompareReviewView.swift index 8efb6a7fb..c569f059c 100644 --- a/TablePro/Views/Compare/DataCompareReviewView.swift +++ b/TablePro/Views/Compare/DataCompareReviewView.swift @@ -87,7 +87,27 @@ internal struct DataCompareReviewView: View { @ViewBuilder private var rowPane: some View { - if let plan = selectedPlan, let summary = plan.summary { + if let plan = selectedPlan { + VStack(spacing: 0) { + DataComparePlanEditor(session: session, plan: plan) + Divider() + rowContent(for: plan) + } + } else { + VStack(spacing: 8) { + Image(systemName: "tablecells") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text("Select a table to see its row differences") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func rowContent(for plan: DataComparePlan) -> some View { + if let summary = plan.summary { VStack(spacing: 0) { HStack { Picker("", selection: $filter) { @@ -98,9 +118,6 @@ internal struct DataCompareReviewView: View { .labelsHidden() .frame(width: 150) Spacer() - Text(String(format: String(localized: "Key: %@"), plan.keyColumns.joined(separator: ", "))) - .font(.caption) - .foregroundStyle(.secondary) } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -121,17 +138,98 @@ internal struct DataCompareReviewView: View { } } else { VStack(spacing: 8) { - Image(systemName: "tablecells") + Image(systemName: "arrow.clockwise") .font(.largeTitle) .foregroundStyle(.tertiary) - Text("Select a table to see its row differences") + Text(plan.unavailableReason ?? String(localized: "Not compared yet. Run Compare Again to refresh this table.")) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } + .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } +internal struct DataComparePlanEditor: View { + @Bindable internal var session: CompareSyncSession + internal let plan: DataComparePlan + + internal var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + columnMenu( + title: String(localized: "Key columns"), + summary: plan.keyColumns.isEmpty + ? String(localized: "None chosen") + : plan.keyColumns.joined(separator: ", "), + systemImage: "key.fill" + ) { + ForEach(plan.columns, id: \.self) { column in + Toggle(column, isOn: Binding( + get: { session.isKeyColumn(column, in: plan) }, + set: { _ in session.toggleKeyColumn(column, for: plan.id) } + )) + } + } + + columnMenu( + title: String(localized: "Compared columns"), + summary: comparedSummary, + systemImage: "text.magnifyingglass" + ) { + ForEach(nonKeyColumns, id: \.self) { column in + Toggle(column, isOn: Binding( + get: { session.isColumnCompared(column) }, + set: { _ in session.toggleComparedColumn(column) } + )) + } + } + + Spacer() + } + + Text("Excluded columns are still written on insert and update. Changing either list needs another comparison.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var nonKeyColumns: [String] { + plan.columns.filter { !session.isKeyColumn($0, in: plan) } + } + + private var comparedSummary: String { + let excluded = nonKeyColumns.filter { !session.isColumnCompared($0) } + guard !excluded.isEmpty else { return String(localized: "All") } + return String(format: String(localized: "%d excluded"), excluded.count) + } + + private func columnMenu( + title: String, + summary: String, + systemImage: String, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + content() + } label: { + Label(summary, systemImage: systemImage) + .font(.caption) + .lineLimit(1) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } +} + internal struct DataComparePlanRow: View { @Binding internal var plan: DataComparePlan diff --git a/TablePro/Views/Compare/StructureCompareReviewView.swift b/TablePro/Views/Compare/StructureCompareReviewView.swift index aa6bc2fed..c981f99bc 100644 --- a/TablePro/Views/Compare/StructureCompareReviewView.swift +++ b/TablePro/Views/Compare/StructureCompareReviewView.swift @@ -136,7 +136,11 @@ internal struct StructureCompareReviewView: View { @ViewBuilder private var detailPane: some View { if let result = visibleResults.first(where: { $0.id == selection }) { - CompareResultDetailView(result: result) + CompareResultDetailView( + result: result, + sourceSnapshot: session.sourceSnapshotCache[result.id], + targetSnapshot: session.targetSnapshotCache[result.id] + ) } else { VStack(spacing: 8) { Image(systemName: "sidebar.right") @@ -247,6 +251,8 @@ internal struct CompareResultRow: View { internal struct CompareResultDetailView: View { internal let result: TableDiffResult + internal let sourceSnapshot: TableStructureSnapshot? + internal let targetSnapshot: TableStructureSnapshot? internal var body: some View { ScrollView { @@ -266,6 +272,23 @@ internal struct CompareResultDetailView: View { } } + if let sourceSnapshot, let targetSnapshot { + StructureDefinitionDiffView( + sourceLines: TableDefinitionRenderer.lines(for: sourceSnapshot), + targetLines: TableDefinitionRenderer.lines(for: targetSnapshot) + ) + } else if let onlySide = sourceSnapshot ?? targetSnapshot { + VStack(alignment: .leading, spacing: 4) { + Text(sourceSnapshot == nil ? "Definition on the target" : "Definition on the source") + .font(.subheadline.weight(.semibold)) + ForEach(TableDefinitionRenderer.lines(for: onlySide), id: \.self) { line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + } + if result.changes.isEmpty { Text("No structural changes.") .foregroundStyle(.secondary) diff --git a/TablePro/Views/Compare/StructureDefinitionDiffView.swift b/TablePro/Views/Compare/StructureDefinitionDiffView.swift new file mode 100644 index 000000000..debd600c2 --- /dev/null +++ b/TablePro/Views/Compare/StructureDefinitionDiffView.swift @@ -0,0 +1,145 @@ +// +// StructureDefinitionDiffView.swift +// TablePro +// +// Source and target definitions side by side. Both sides are rendered from +// parsed metadata through the same function, so only real differences show. +// Difference type carries a glyph as well as a colour. +// + +import SwiftUI + +internal struct StructureDefinitionDiffView: View { + internal let sourceLines: [String] + internal let targetLines: [String] + + @State private var isUnified = false + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + private var pairs: [DiffPair] { + DiffComputer.computeSplit(before: targetLines, after: sourceLines) + } + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Definition") + .font(.subheadline.weight(.semibold)) + Spacer() + Picker("", selection: $isUnified) { + Text("Split").tag(false) + Text("Unified").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 140) + } + .padding(.bottom, 6) + + if isUnified { + unifiedBody + } else { + splitBody + } + } + } + + private var splitBody: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + columnHeader(String(localized: "Target")) + columnHeader(String(localized: "Source")) + } + ForEach(Array(pairs.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 0) { + diffCell(pair.before, kind: pair.kind, isBefore: true) + diffCell(pair.after, kind: pair.kind, isBefore: false) + } + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private var unifiedBody: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(DiffComputer.computeUnified(from: pairs)) { line in + HStack(spacing: 6) { + Text(marker(for: line.kind)) + .font(.system(.caption2, design: .monospaced)) + .frame(width: 12) + Text(line.text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(background(for: line.kind)) + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private func columnHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + + private func diffCell(_ text: String?, kind: DiffPair.Kind, isBefore: Bool) -> some View { + HStack(spacing: 4) { + Text(glyph(for: kind, isBefore: isBefore)) + .font(.system(.caption2, design: .monospaced)) + .frame(width: 10) + Text(text ?? "") + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .frame(maxWidth: .infinity, alignment: .leading) + .background(splitBackground(kind: kind, isBefore: isBefore, isEmpty: text == nil)) + } + + private func glyph(for kind: DiffPair.Kind, isBefore: Bool) -> String { + switch kind { + case .unchanged: return " " + case .changed: return "~" + case .added: return isBefore ? " " : "+" + case .removed: return isBefore ? "-" : " " + } + } + + private func marker(for kind: DiffUnifiedLine.Kind) -> String { + switch kind { + case .context: return " " + case .added: return "+" + case .removed: return "-" + } + } + + private func splitBackground(kind: DiffPair.Kind, isBefore: Bool, isEmpty: Bool) -> Color { + guard !differentiateWithoutColor else { return .clear } + guard !isEmpty else { return Color.secondary.opacity(0.05) } + switch kind { + case .unchanged: return .clear + case .changed: return Color.orange.opacity(0.12) + case .added: return isBefore ? .clear : Color.green.opacity(0.12) + case .removed: return isBefore ? Color.red.opacity(0.12) : .clear + } + } + + private func background(for kind: DiffUnifiedLine.Kind) -> Color { + guard !differentiateWithoutColor else { return .clear } + switch kind { + case .context: return .clear + case .added: return Color.green.opacity(0.12) + case .removed: return Color.red.opacity(0.12) + } + } +} diff --git a/TableProTests/Core/Compare/TableDefinitionRendererTests.swift b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift new file mode 100644 index 000000000..04cc2e2bb --- /dev/null +++ b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift @@ -0,0 +1,181 @@ +// +// TableDefinitionRendererTests.swift +// TableProTests +// + +import Foundation +import XCTest + +@testable import TablePro + +final class TableDefinitionRendererTests: XCTestCase { + private func column( + _ name: String, + _ dataType: String = "int", + nullable: Bool = true, + primaryKey: Bool = false, + comment: String? = nil + ) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), name: name, dataType: dataType, isNullable: nullable, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: comment, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: primaryKey + ) + } + + private func index(_ name: String, columns: [String], unique: Bool = false) -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), name: name, columns: columns, type: .btree, isUnique: unique, + isPrimary: false, comment: nil, columnPrefixes: [:], whereClause: nil + ) + } + + func testRendersColumnsPrimaryKeyAndIndexes() { + let snapshot = TableStructureSnapshot( + name: "users", + columns: [column("id", "int", nullable: false, primaryKey: true), column("email", "varchar(255)")], + indexes: [index("idx_email", columns: ["email"], unique: true)] + ) + + let lines = TableDefinitionRenderer.lines(for: snapshot) + + XCTAssertEqual(lines.first, "TABLE users") + XCTAssertTrue(lines.contains { $0.contains("COLUMN id int NOT NULL") }) + XCTAssertTrue(lines.contains { $0.contains("COLUMN email varchar(255) NULL") }) + XCTAssertTrue(lines.contains(" PRIMARY KEY (id)")) + XCTAssertTrue(lines.contains { $0.contains("UNIQUE INDEX idx_email (email)") }) + } + + func testRenderingIsDeterministicRegardlessOfIndexOrder() { + let first = TableStructureSnapshot( + name: "t", + columns: [column("id")], + indexes: [index("b_idx", columns: ["a"]), index("a_idx", columns: ["b"])] + ) + let second = TableStructureSnapshot( + name: "t", + columns: [column("id")], + indexes: [index("a_idx", columns: ["b"]), index("b_idx", columns: ["a"])] + ) + + XCTAssertEqual( + TableDefinitionRenderer.lines(for: first), + TableDefinitionRenderer.lines(for: second), + "index declaration order must not change the rendered definition" + ) + } + + func testIdenticalSnapshotsRenderIdenticallySoTheDiffIsEmpty() { + let snapshot = TableStructureSnapshot( + name: "users", + columns: [column("id", nullable: false), column("name", "varchar(20)", comment: "hi")], + indexes: [index("i", columns: ["name"])] + ) + + let pairs = DiffComputer.computeSplit( + before: TableDefinitionRenderer.lines(for: snapshot), + after: TableDefinitionRenderer.lines(for: snapshot) + ) + + XCTAssertTrue(pairs.allSatisfy { $0.kind == .unchanged }) + } + + func testChangedColumnTypeProducesAChangedLine() { + let target = TableStructureSnapshot(name: "t", columns: [column("id", "int")]) + let source = TableStructureSnapshot(name: "t", columns: [column("id", "bigint")]) + + let pairs = DiffComputer.computeSplit( + before: TableDefinitionRenderer.lines(for: target), + after: TableDefinitionRenderer.lines(for: source) + ) + + XCTAssertTrue(pairs.contains { $0.kind != .unchanged }) + } +} + +final class CompareSyncProfileStorageTests: XCTestCase { + private var defaults: UserDefaults! + private var storage: CompareSyncProfileStorage! + private let suiteName = "CompareSyncProfileStorageTests" + + override func setUp() { + super.setUp() + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = UserDefaults(suiteName: suiteName) + storage = CompareSyncProfileStorage(defaults: defaults) + } + + override func tearDown() { + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = nil + storage = nil + super.tearDown() + } + + private func profile( + name: String, + source: UUID, + target: UUID, + mode: CompareSyncMode = .structure + ) -> CompareSyncProfile { + CompareSyncProfile( + name: name, + sourceConnectionId: source, + targetConnectionId: target, + mode: mode, + structureOptions: .default, + dataOptions: .default, + selectedTables: ["users"] + ) + } + + func testSavedProfileRoundTrips() { + let source = UUID() + let target = UUID() + storage.save(profile(name: "nightly", source: source, target: target)) + + let loaded = storage.profiles(source: source, target: target, mode: .structure) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "nightly") + XCTAssertEqual(loaded[0].selectedTables, ["users"]) + } + + func testProfilesAreScopedToSourceTargetAndMode() { + let source = UUID() + let target = UUID() + storage.save(profile(name: "structure", source: source, target: target, mode: .structure)) + storage.save(profile(name: "data", source: source, target: target, mode: .data)) + + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .structure).map(\.name), ["structure"]) + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .data).map(\.name), ["data"]) + XCTAssertTrue(storage.profiles(source: target, target: source, mode: .structure).isEmpty) + } + + func testSavingSameProfileIdUpdatesRatherThanDuplicates() { + let source = UUID() + let target = UUID() + var existing = profile(name: "first", source: source, target: target) + storage.save(existing) + existing.name = "renamed" + storage.save(existing) + + let loaded = storage.profiles(source: source, target: target, mode: .structure) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "renamed") + } + + func testDeleteRemovesOnlyThatProfile() { + let source = UUID() + let target = UUID() + let keep = profile(name: "keep", source: source, target: target) + let drop = profile(name: "drop", source: source, target: target) + storage.save(keep) + storage.save(drop) + + storage.delete(drop) + + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .structure).map(\.name), ["keep"]) + } +} diff --git a/TableProUITests/CompareSyncUITests.swift b/TableProUITests/CompareSyncUITests.swift new file mode 100644 index 000000000..bdb7b991b --- /dev/null +++ b/TableProUITests/CompareSyncUITests.swift @@ -0,0 +1,95 @@ +import XCTest + +final class CompareSyncUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + private func launchAndOpenCompareSync() throws -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["File"].click() + + let item = menuBar.menuItems["Compare & Sync Databases..."] + XCTAssertTrue(item.waitForExistence(timeout: 5), "Compare & Sync must be reachable from the File menu") + guard item.isEnabled else { + throw XCTSkip("Compare & Sync is licence gated and unavailable in this build") + } + item.click() + return app + } + + private func compareWindow(in app: XCUIApplication) -> XCUIElement { + app.windows["Compare & Sync"] + } + + func testCompareSyncOpensFromFileMenu() throws { + let app = try launchAndOpenCompareSync() + + XCTAssertTrue( + compareWindow(in: app).waitForExistence(timeout: 10), + "Choosing the menu item must open the Compare & Sync window" + ) + } + + func testBannerStatesNothingHasBeenWrittenBeforeAnyRun() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let banner = window.staticTexts["Comparing only. Nothing has been written."] + XCTAssertTrue( + banner.waitForExistence(timeout: 5), + "The window must say nothing has been written until the user applies" + ) + } + + func testCompareIsDisabledUntilBothEndpointsAreChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let compareButton = window.buttons["Compare"] + XCTAssertTrue(compareButton.waitForExistence(timeout: 5)) + XCTAssertFalse( + compareButton.isEnabled, + "Compare must stay disabled while no target is chosen, so the write side is always deliberate" + ) + } + + func testTargetPickerStartsWithNoConnectionChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let placeholders = window.popUpButtons.matching( + NSPredicate(format: "value == %@", "Choose a connection") + ) + XCTAssertGreaterThanOrEqual( + placeholders.count, 2, + "Neither source nor target may be preselected" + ) + } + + func testSwapIsDisabledWhenNoEndpointIsChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let swap = window.buttons.matching( + NSPredicate(format: "label CONTAINS[c] %@", "Swap") + ).firstMatch + guard swap.waitForExistence(timeout: 5) else { + throw XCTSkip("Swap control not exposed to accessibility in this build") + } + XCTAssertFalse(swap.isEnabled, "Swap has nothing to swap before an endpoint is chosen") + } +} diff --git a/docs/features/compare-sync.mdx b/docs/features/compare-sync.mdx index 89beafbb9..aa30a1a8b 100644 --- a/docs/features/compare-sync.mdx +++ b/docs/features/compare-sync.mdx @@ -59,7 +59,9 @@ Two different engines can still be compared. You get the differences as a read-o Data comparison matches rows by a key and walks both sides in key order, so neither table is loaded into memory in full. -Key columns are filled in from the source table's primary key. Composite keys are supported. A table with no primary key, or with no columns in common, is listed as not comparable rather than matched on a guess, because matching on every column cannot tell one row from two identical rows. +Key columns are filled in from the source table's primary key, and you can change them per table from the **Key columns** menu above the row list. Composite keys are supported. A table with no key, or with no columns in common, is listed as not comparable rather than matched on a guess, because matching on every column cannot tell one row from two identical rows. + +Changing keys or compared columns clears that table's result. Press **Compare Again** to refresh it. Tables are matched by name across the two sides, and only the columns present on both are compared. @@ -73,7 +75,9 @@ Delete is off by default so the first run cannot remove a row you did not mean t ### Which columns are compared -Columns used to compare are separate from columns that get written. Exclude `updated_at` from comparison and rows that differ only in that column stop showing as differences, while the column is still carried into any row that is inserted or updated. +Columns used to compare are separate from columns that get written. Use the **Compared columns** menu to exclude one. Exclude `updated_at` and rows that differ only in that column stop showing as differences, while the column is still carried into any row that is inserted or updated. + +Exclusions apply by column name across every table in the comparison, which is what you want for audit columns that repeat. ### How values are judged equal @@ -113,9 +117,11 @@ If you choose to continue past errors, the transaction option is disabled, becau Progress is reported per statement and the run can be cancelled. Cancelling stops the run between statements; a statement already in flight finishes. +Closing the window or quitting TablePro while a run is in progress asks first, and says that statements which already ran stay applied. + ## Saving a setup -Save the source, target, mode, scope, ignore options, and key choices as a named comparison you can run again. +Give a comparison a name and **Save** it, and it is stored against that source, target, and mode. Load it later from the same screen to restore the mode, ignore options, value rules, and which tables were selected. Delete one from the same menu. ## Limits From 856a9f6adf1e88469ddcc19661aedbc4b95b673f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 17:05:08 +0700 Subject: [PATCH 3/3] fix(compare): make the merge join's key order contract explicit and verified (#721) --- .../Core/Compare/CompareSyncExecutor.swift | 6 +- .../Compare/CompareSyncSession+Data.swift | 35 ++- .../Compare/CompareSyncSession+Editing.swift | 4 +- .../Core/Compare/CompareSyncSession+Run.swift | 9 +- TablePro/Core/Compare/DataDiffEngine.swift | 167 +++++++----- TablePro/Core/Compare/KeyOrdering.swift | 125 +++++++++ .../Core/Compare/SchemaSyncOperation.swift | 6 +- .../Compare/SchemaSyncScriptBuilder.swift | 19 +- .../Views/Compare/DataCompareReviewView.swift | 7 + .../Core/Compare/DataDiffEngineTests.swift | 31 --- .../Compare/DataSyncScriptBuilderTests.swift | 247 ++++++++++++++++++ .../Core/Compare/KeyOrderingTests.swift | 189 ++++++++++++++ .../SchemaSyncScriptBuilderTests.swift | 28 ++ .../Compare/StreamingRowProviderTests.swift | 160 ++++++++++++ docs/features/compare-sync.mdx | 3 +- 15 files changed, 920 insertions(+), 116 deletions(-) create mode 100644 TablePro/Core/Compare/KeyOrdering.swift create mode 100644 TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift create mode 100644 TableProTests/Core/Compare/KeyOrderingTests.swift create mode 100644 TableProTests/Core/Compare/StreamingRowProviderTests.swift diff --git a/TablePro/Core/Compare/CompareSyncExecutor.swift b/TablePro/Core/Compare/CompareSyncExecutor.swift index 431df3a9c..e6a91daf7 100644 --- a/TablePro/Core/Compare/CompareSyncExecutor.swift +++ b/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -204,10 +204,14 @@ internal actor CompareSyncExecutor { private static func digest(of statements: [SyncStatement]) -> String { var digest = "" + var length = 0 for statement in statements { - guard digest.count < 10_000 else { break } + guard length < Self.digestCharacterLimit else { break } digest += statement.sql + "\n" + length += (statement.sql as NSString).length + 1 } return digest } + + private static let digestCharacterLimit = 10_000 } diff --git a/TablePro/Core/Compare/CompareSyncSession+Data.swift b/TablePro/Core/Compare/CompareSyncSession+Data.swift index d3342397e..ba26c7b8d 100644 --- a/TablePro/Core/Compare/CompareSyncSession+Data.swift +++ b/TablePro/Core/Compare/CompareSyncSession+Data.swift @@ -14,6 +14,7 @@ internal struct DataComparePlan: Identifiable, Hashable { internal let table: String internal let schema: String? internal var columns: [String] + internal var columnTypes: [String: String] = [:] internal var keyColumns: [String] internal var isEnabled: Bool internal var unavailableReason: String? @@ -166,7 +167,7 @@ internal extension CompareSyncSession { keyColumns: plan.keyColumns, driver: targetDriver ) - let engine = DataDiffEngine(options: options, columns: plan.columns) + let engine = DataDiffEngine(options: options, columns: plan.columns, columnTypes: plan.columnTypes) return try await engine.compare( source: StreamingRowProvider(stream: sourceDriver.streamRows(query: sourceQuery), columns: plan.columns), target: StreamingRowProvider(stream: targetDriver.streamRows(query: targetQuery), columns: plan.columns) @@ -205,24 +206,42 @@ internal extension CompareSyncSession { let primaryKey = sourceColumns.filter { $0.isPrimaryKey }.map { $0.name } let identifier = table.schema.map { "\($0).\(table.name)" } ?? table.name let carried = previous[identifier] + let columnTypes = Dictionary( + sourceColumns.map { ($0.name, $0.dataType) }, + uniquingKeysWith: { first, _ in first } + ) var plan = DataComparePlan( table: table.name, schema: table.schema ?? sourceSchema, columns: shared, - keyColumns: carried?.keyColumns.isEmpty == false ? carried?.keyColumns ?? primaryKey : primaryKey, + columnTypes: columnTypes, + keyColumns: carried.map { $0.keyColumns } ?? primaryKey, isEnabled: carried?.isEnabled ?? true, unavailableReason: nil, summary: nil ) - - if shared.isEmpty { - plan.unavailableReason = String(localized: "No columns in common.") - } else if plan.keyColumns.isEmpty { - plan.unavailableReason = String(localized: "No primary key. Choose key columns to compare this table.") - } + plan.unavailableReason = Self.unavailableReason(for: plan) plans.append(plan) } return plans.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } } + + static func unavailableReason(for plan: DataComparePlan) -> String? { + if plan.columns.isEmpty { + return String(localized: "No columns in common.") + } + if plan.keyColumns.isEmpty { + return String(localized: "No primary key. Choose key columns to compare this table.") + } + let available = Set(plan.columns.map { $0.lowercased() }) + let missing = plan.keyColumns.filter { !available.contains($0.lowercased()) } + guard missing.isEmpty else { + return String( + format: String(localized: "Key column %@ is not present on both sides. Choose a different key."), + missing.joined(separator: ", ") + ) + } + return nil + } } diff --git a/TablePro/Core/Compare/CompareSyncSession+Editing.swift b/TablePro/Core/Compare/CompareSyncSession+Editing.swift index c3780ea76..1b8ae8e62 100644 --- a/TablePro/Core/Compare/CompareSyncSession+Editing.swift +++ b/TablePro/Core/Compare/CompareSyncSession+Editing.swift @@ -15,9 +15,7 @@ internal extension CompareSyncSession { guard let index = dataPlans.firstIndex(where: { $0.id == planId }) else { return } dataPlans[index].keyColumns = columns dataPlans[index].summary = nil - dataPlans[index].unavailableReason = columns.isEmpty - ? String(localized: "No primary key. Choose key columns to compare this table.") - : nil + dataPlans[index].unavailableReason = CompareSyncSession.unavailableReason(for: dataPlans[index]) invalidateGeneratedScript() } diff --git a/TablePro/Core/Compare/CompareSyncSession+Run.swift b/TablePro/Core/Compare/CompareSyncSession+Run.swift index a75245f41..9c0cb4bfa 100644 --- a/TablePro/Core/Compare/CompareSyncSession+Run.swift +++ b/TablePro/Core/Compare/CompareSyncSession+Run.swift @@ -107,7 +107,6 @@ internal extension CompareSyncSession { runTask = Task { [weak self] in guard let self else { return } self.activity = .applying - self.hasWrittenToTarget = true CompareSyncRunRegistry.shared.markApplying(self, target: target.qualifiedDescription) defer { self.activity = .idle @@ -117,6 +116,7 @@ internal extension CompareSyncSession { do { let targetDriver = try Self.driver(for: target) + self.hasWrittenToTarget = true let result = try await self.executorRun( statements: self.statements, target: target, @@ -208,16 +208,17 @@ internal extension CompareSyncSession { let dependencies = snapshot.foreignKeys.compactMap { foreignKey -> PluginForeignKeyInfo? in guard let column = foreignKey.columns.first, let referencedColumn = foreignKey.referencedColumns.first else { return nil } + let referencedSchema = foreignKey.referencedSchema ?? snapshot.schema return PluginForeignKeyInfo( name: foreignKey.name, column: column, - referencedTable: foreignKey.referencedTable, + referencedTable: SchemaSyncOperation.qualify(foreignKey.referencedTable, referencedSchema), referencedColumn: referencedColumn, - referencedSchema: foreignKey.referencedSchema + referencedSchema: referencedSchema ) } guard !dependencies.isEmpty else { continue } - map[snapshot.name] = dependencies + map[snapshot.qualifiedName] = dependencies } return map } diff --git a/TablePro/Core/Compare/DataDiffEngine.swift b/TablePro/Core/Compare/DataDiffEngine.swift index b0db8c818..18a83dc92 100644 --- a/TablePro/Core/Compare/DataDiffEngine.swift +++ b/TablePro/Core/Compare/DataDiffEngine.swift @@ -6,6 +6,12 @@ // order and walked in lockstep, so neither side is ever materialized and no // server-side hash function has to agree between two engines. // +// The walk depends on the client comparator agreeing with the order the server +// sent rows in. See KeyOrdering: numeric keys agree by construction, and any +// other key is verified as the stream is read, so a disagreeing collation is +// reported instead of silently producing a delete for a row that exists on both +// sides. +// import Foundation import TableProPluginKit @@ -69,6 +75,7 @@ internal struct DataDiffSummary { internal let updateCount: Int internal let deleteCount: Int internal let identicalCount: Int + internal let skippedNullKeyCount: Int internal let entries: [RowDiffEntry] internal let truncatedEntries: Bool @@ -81,15 +88,36 @@ internal struct DataDiffSummary { } } +internal struct KeyedRow { + internal let row: DataRow + internal let key: [PluginCellValue] +} + +internal enum ComparisonSide: String { + case source + case target + + internal var displayName: String { + switch self { + case .source: return String(localized: "source") + case .target: return String(localized: "target") + } + } +} + internal struct DataDiffEngine { private let options: DataCompareOptions private let comparator: CellValueComparator private let comparisonColumns: [String] + private let ordering: KeyOrdering - internal init(options: DataCompareOptions, columns: [String]) { + internal init(options: DataCompareOptions, columns: [String], columnTypes: [String: String] = [:]) { self.options = options self.comparator = CellValueComparator(options: options) self.comparisonColumns = options.comparisonColumns(from: columns) + self.ordering = KeyOrdering( + orders: KeyOrdering.orders(for: options.keyColumns, columnTypes: columnTypes) + ) } internal func compare( @@ -101,65 +129,70 @@ internal struct DataDiffEngine { } var accumulator = Accumulator(limit: options.maxRetainedEntries) - var sourceRow = try await source.nextRow() - var targetRow = try await target.nextRow() + let sourceReader = KeyedRowReader( + provider: source, keyColumns: options.keyColumns, ordering: ordering, side: .source + ) + let targetReader = KeyedRowReader( + provider: target, keyColumns: options.keyColumns, ordering: ordering, side: .target + ) + + var left = try await sourceReader.next(&accumulator) + var right = try await targetReader.next(&accumulator) - while sourceRow != nil || targetRow != nil { + while left != nil || right != nil { try Task.checkCancellation() - guard let left = sourceRow else { - accumulator.add(deleteEntry(for: targetRow)) - targetRow = try await target.nextRow() + guard let sourceEntry = left else { + accumulator.add(deleteEntry(for: right)) + right = try await targetReader.next(&accumulator) continue } - guard let right = targetRow else { - accumulator.add(insertEntry(for: left)) - sourceRow = try await source.nextRow() + guard let targetEntry = right else { + accumulator.add(insertEntry(for: sourceEntry)) + left = try await sourceReader.next(&accumulator) continue } - let leftKey = keyComponents(of: left) - let rightKey = keyComponents(of: right) - switch KeyOrdering.compare(leftKey, rightKey) { + switch ordering.compare(sourceEntry.key, targetEntry.key) { case .orderedSame: - accumulator.add(matchedEntry(source: left, target: right)) - sourceRow = try await source.nextRow() - targetRow = try await target.nextRow() + accumulator.add(matchedEntry(source: sourceEntry, target: targetEntry)) + left = try await sourceReader.next(&accumulator) + right = try await targetReader.next(&accumulator) case .orderedAscending: - accumulator.add(insertEntry(for: left)) - sourceRow = try await source.nextRow() + accumulator.add(insertEntry(for: sourceEntry)) + left = try await sourceReader.next(&accumulator) case .orderedDescending: - accumulator.add(deleteEntry(for: right)) - targetRow = try await target.nextRow() + accumulator.add(deleteEntry(for: targetEntry)) + right = try await targetReader.next(&accumulator) } } return accumulator.summary() } - private func insertEntry(for row: DataRow) -> RowDiffEntry { + private func insertEntry(for entry: KeyedRow) -> RowDiffEntry { RowDiffEntry( kind: .insert, - keyDescription: keyDescription(of: row), - sourceRow: row, + keyDescription: KeyOrdering.description(of: entry.key), + sourceRow: entry.row, targetRow: nil ) } - private func deleteEntry(for row: DataRow?) -> RowDiffEntry { + private func deleteEntry(for entry: KeyedRow?) -> RowDiffEntry { RowDiffEntry( kind: .delete, - keyDescription: row.map { keyDescription(of: $0) } ?? "", + keyDescription: entry.map { KeyOrdering.description(of: $0.key) } ?? "", sourceRow: nil, - targetRow: row + targetRow: entry?.row ) } - private func matchedEntry(source: DataRow, target: DataRow) -> RowDiffEntry { + private func matchedEntry(source: KeyedRow, target: KeyedRow) -> RowDiffEntry { var differences: [CellDifference] = [] for column in comparisonColumns { - let sourceValue = source.value(for: column) - let targetValue = target.value(for: column) + let sourceValue = source.row.value(for: column) + let targetValue = target.row.value(for: column) let outcome = comparator.compare(sourceValue, targetValue) guard !outcome.isEqual else { continue } differences.append(CellDifference( @@ -171,31 +204,22 @@ internal struct DataDiffEngine { } return RowDiffEntry( kind: differences.isEmpty ? .identical : .update, - keyDescription: keyDescription(of: source), - sourceRow: source, - targetRow: target, + keyDescription: KeyOrdering.description(of: source.key), + sourceRow: source.row, + targetRow: target.row, cellDifferences: differences ) } - - private func keyComponents(of row: DataRow) -> [PluginCellValue] { - options.keyColumns.map { row.value(for: $0) } - } - - private func keyDescription(of row: DataRow) -> String { - options.keyColumns - .map { column in KeyOrdering.sortKey(row.value(for: column)) } - .joined(separator: ", ") - } } -private extension DataDiffEngine { +internal extension DataDiffEngine { struct Accumulator { private let limit: Int private var insertCount = 0 private var updateCount = 0 private var deleteCount = 0 private var identicalCount = 0 + private var skippedNullKeyCount = 0 private var entries: [RowDiffEntry] = [] private var truncated = false @@ -203,6 +227,10 @@ private extension DataDiffEngine { self.limit = limit } + mutating func addSkippedNullKey() { + skippedNullKeyCount += 1 + } + mutating func add(_ entry: RowDiffEntry) { switch entry.kind { case .insert: insertCount += 1 @@ -223,6 +251,7 @@ private extension DataDiffEngine { updateCount: updateCount, deleteCount: deleteCount, identicalCount: identicalCount, + skippedNullKeyCount: skippedNullKeyCount, entries: entries, truncatedEntries: truncated ) @@ -230,32 +259,48 @@ private extension DataDiffEngine { } } -internal enum KeyOrdering { - internal static func compare(_ lhs: [PluginCellValue], _ rhs: [PluginCellValue]) -> ComparisonResult { - for (left, right) in zip(lhs, rhs) { - let leftKey = sortKey(left) - let rightKey = sortKey(right) - if leftKey == rightKey { continue } - if let leftNumber = Double(leftKey), let rightNumber = Double(rightKey) { - return leftNumber < rightNumber ? .orderedAscending : .orderedDescending +private final class KeyedRowReader { + private let provider: DataRowProviding + private let keyColumns: [String] + private let ordering: KeyOrdering + private let side: ComparisonSide + private var previousKey: [PluginCellValue]? + + init(provider: DataRowProviding, keyColumns: [String], ordering: KeyOrdering, side: ComparisonSide) { + self.provider = provider + self.keyColumns = keyColumns + self.ordering = ordering + self.side = side + } + + func next(_ accumulator: inout DataDiffEngine.Accumulator) async throws -> KeyedRow? { + while let row = try await provider.nextRow() { + let key = keyColumns.map { row.value(for: $0) } + if KeyOrdering.hasNullComponent(key) { + accumulator.addSkippedNullKey() + continue } - return leftKey < rightKey ? .orderedAscending : .orderedDescending + try checkOrder(of: key) + previousKey = key + return KeyedRow(row: row, key: key) } - if lhs.count == rhs.count { return .orderedSame } - return lhs.count < rhs.count ? .orderedAscending : .orderedDescending + return nil } - internal static func sortKey(_ value: PluginCellValue) -> String { - switch value { - case .null: return "" - case .text(let text): return text - case .bytes(let data): return data.base64EncodedString() - } + private func checkOrder(of key: [PluginCellValue]) throws { + guard ordering.requiresStreamOrderCheck, let previousKey else { return } + guard ordering.compare(previousKey, key) == .orderedDescending else { return } + let explanation = String( + localized: "The %1$@ sorted rows differently than the comparison expects, near key %2$@. Pick a numeric key, or one that sorts by byte value." + ) + throw CompareSyncError.streamOutOfOrder( + String(format: explanation, side.displayName, KeyOrdering.description(of: key)) + ) } } internal final class ArrayRowProvider: DataRowProviding { - private var rows: [DataRow] + private let rows: [DataRow] private var index = 0 internal init(rows: [DataRow]) { diff --git a/TablePro/Core/Compare/KeyOrdering.swift b/TablePro/Core/Compare/KeyOrdering.swift new file mode 100644 index 000000000..75a6ad2ca --- /dev/null +++ b/TablePro/Core/Compare/KeyOrdering.swift @@ -0,0 +1,125 @@ +// +// KeyOrdering.swift +// TablePro +// +// The order the merge join walks both streams in. +// +// Rows arrive ordered by the server's own collation, so the client comparator +// has to agree with it or the join desynchronises and reports rows that exist +// on both sides as a delete plus an insert. Two rules keep that from happening +// silently: +// +// 1. A numeric key column is compared numerically, which matches every engine's +// numeric ordering exactly. This covers the ordinary integer primary key. +// 2. Anything else is compared by UTF-8 bytes, and the stream is checked as it +// is read. A server whose collation disagrees is reported, not guessed at. +// +// A NULL is never a usable row identity, so a row carrying one in a key column +// is excluded from the comparison rather than given an arbitrary sort position. +// + +import Foundation +import TableProPluginKit + +internal struct KeyOrdering { + internal enum ColumnOrder: Equatable { + case numeric + case binaryText + } + + private let orders: [ColumnOrder] + + internal init(orders: [ColumnOrder]) { + self.orders = orders + } + + internal var requiresStreamOrderCheck: Bool { + orders.contains(.binaryText) + } + + internal func compare(_ lhs: [PluginCellValue], _ rhs: [PluginCellValue]) -> ComparisonResult { + for (index, pair) in zip(lhs, rhs).enumerated() { + let order = index < orders.count ? orders[index] : .binaryText + let result = Self.compare(pair.0, pair.1, using: order) + guard result == .orderedSame else { return result } + } + if lhs.count == rhs.count { return .orderedSame } + return lhs.count < rhs.count ? .orderedAscending : .orderedDescending + } + + internal static func hasNullComponent(_ key: [PluginCellValue]) -> Bool { + key.contains { if case .null = $0 { return true } else { return false } } + } + + internal static func description(of key: [PluginCellValue]) -> String { + key.map { value in + switch value { + case .null: return "NULL" + case .text(let text): return text + case .bytes(let data): return data.base64EncodedString() + } + } + .joined(separator: ", ") + } + + internal static func orders( + for keyColumns: [String], + columnTypes: [String: String] + ) -> [ColumnOrder] { + keyColumns.map { column in + let lowered = column.lowercased() + let declared = columnTypes.first { $0.key.lowercased() == lowered }?.value + return isNumeric(declared) ? .numeric : .binaryText + } + } + + internal static func isNumeric(_ dataType: String?) -> Bool { + guard let dataType else { return false } + let base = dataType.lowercased().prefix { $0.isLetter || $0 == " " }.trimmingCharacters(in: .whitespaces) + if numericTypeNames.contains(base) { return true } + guard let leading = base.split(separator: " ").first else { return false } + return numericTypeNames.contains(String(leading)) + } + + private static let numericTypeNames: Set = [ + "int", "integer", "tinyint", "smallint", "mediumint", "bigint", + "serial", "bigserial", "smallserial", "int2", "int4", "int8", + "decimal", "numeric", "number", "float", "double", "double precision", + "real", "money", "float4", "float8" + ] + + private static func compare( + _ lhs: PluginCellValue, + _ rhs: PluginCellValue, + using order: ColumnOrder + ) -> ComparisonResult { + switch order { + case .numeric: + let left = numericValue(lhs) + let right = numericValue(rhs) + if left == right { return .orderedSame } + return left < right ? .orderedAscending : .orderedDescending + case .binaryText: + let left = byteValue(lhs) + let right = byteValue(rhs) + if left == right { return .orderedSame } + return left.lexicographicallyPrecedes(right) ? .orderedAscending : .orderedDescending + } + } + + private static func numericValue(_ value: PluginCellValue) -> Double { + switch value { + case .null: return -.infinity + case .text(let text): return Double(text.trimmingCharacters(in: .whitespaces)) ?? 0 + case .bytes: return 0 + } + } + + private static func byteValue(_ value: PluginCellValue) -> [UInt8] { + switch value { + case .null: return [] + case .text(let text): return Array(text.utf8) + case .bytes(let data): return Array(data) + } + } +} diff --git a/TablePro/Core/Compare/SchemaSyncOperation.swift b/TablePro/Core/Compare/SchemaSyncOperation.swift index f67d6f812..b5ba8c8e8 100644 --- a/TablePro/Core/Compare/SchemaSyncOperation.swift +++ b/TablePro/Core/Compare/SchemaSyncOperation.swift @@ -39,7 +39,11 @@ internal enum SchemaSyncOperation: Identifiable { } } - private static func qualify(_ name: String, _ schema: String?) -> String { + internal var tableIdentifier: String { + Self.qualify(tableName, schema) + } + + internal static func qualify(_ name: String, _ schema: String?) -> String { guard let schema, !schema.isEmpty else { return name } return "\(schema).\(name)" } diff --git a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift index 647ca7c5d..908efd408 100644 --- a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift +++ b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -56,15 +56,20 @@ internal struct SchemaSyncScriptBuilder { childrenFirst: Bool ) -> [SchemaSyncOperation] { guard operations.count > 1 else { return operations } - let byName = Dictionary( - operations.map { ($0.tableName, $0) }, - uniquingKeysWith: { first, _ in first } - ) + var byIdentifier: [String: [SchemaSyncOperation]] = [:] + for operation in operations { + byIdentifier[operation.tableIdentifier, default: []].append(operation) + } let ordered = ForeignKeyTopologicalSort.orderedNames( - operations.map { $0.tableName }, + operations.map { $0.tableIdentifier }, foreignKeysByTable: foreignKeysByTable ) - let resolved = ordered.compactMap { byName[$0] } + var emitted: Set = [] + var resolved: [SchemaSyncOperation] = [] + for identifier in ordered where !emitted.contains(identifier) { + emitted.insert(identifier) + resolved.append(contentsOf: byIdentifier[identifier] ?? []) + } return childrenFirst ? resolved.reversed() : resolved } @@ -170,12 +175,14 @@ internal enum CompareSyncError: LocalizedError { case unsupportedOperation(String) case incompatibleEngines(String) case noComparisonKey(String) + case streamOutOfOrder(String) internal var errorDescription: String? { switch self { case .unsupportedOperation(let message): return message case .incompatibleEngines(let message): return message case .noComparisonKey(let message): return message + case .streamOutOfOrder(let message): return message } } } diff --git a/TablePro/Views/Compare/DataCompareReviewView.swift b/TablePro/Views/Compare/DataCompareReviewView.swift index c569f059c..246a824cc 100644 --- a/TablePro/Views/Compare/DataCompareReviewView.swift +++ b/TablePro/Views/Compare/DataCompareReviewView.swift @@ -254,6 +254,13 @@ internal struct DataComparePlanRow: View { countLabel(String(localized: "Update"), summary.updateCount, "pencil.circle") countLabel(String(localized: "Delete"), summary.deleteCount, "minus.circle") countLabel(String(localized: "Same"), summary.identicalCount, "equal.circle") + if summary.skippedNullKeyCount > 0 { + countLabel( + String(localized: "Skipped, NULL key"), + summary.skippedNullKeyCount, + "exclamationmark.circle" + ) + } } .font(.caption2) .foregroundStyle(.secondary) diff --git a/TableProTests/Core/Compare/DataDiffEngineTests.swift b/TableProTests/Core/Compare/DataDiffEngineTests.swift index 3e9a1024e..92264f7bb 100644 --- a/TableProTests/Core/Compare/DataDiffEngineTests.swift +++ b/TableProTests/Core/Compare/DataDiffEngineTests.swift @@ -327,34 +327,3 @@ final class CellValueComparatorTests: XCTestCase { XCTAssertFalse(subject.compare(.bytes(Data([1, 2, 3])), .bytes(Data([1, 2, 4]))).isEqual) } } - -final class KeyOrderingTests: XCTestCase { - func testNumericKeysOrderNumericallyNotLexically() { - let result = KeyOrdering.compare([.text("9")], [.text("10")]) - - XCTAssertEqual(result, .orderedAscending, "9 must sort before 10") - } - - func testTextKeysOrderLexically() { - XCTAssertEqual(KeyOrdering.compare([.text("a")], [.text("b")]), .orderedAscending) - } - - func testEqualKeysReportSame() { - XCTAssertEqual(KeyOrdering.compare([.text("a"), .text("1")], [.text("a"), .text("1")]), .orderedSame) - } - - func testOrderingIsAntisymmetric() { - let pairs: [([PluginCellValue], [PluginCellValue])] = [ - ([.text("1")], [.text("2")]), - ([.text("b")], [.text("a")]), - ([.null], [.text("x")]) - ] - - for (left, right) in pairs { - let forward = KeyOrdering.compare(left, right) - let backward = KeyOrdering.compare(right, left) - XCTAssertNotEqual(forward, backward) - XCTAssertNotEqual(forward, .orderedSame) - } - } -} diff --git a/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift new file mode 100644 index 000000000..996b3991f --- /dev/null +++ b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift @@ -0,0 +1,247 @@ +// +// DataSyncScriptBuilderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +/// `sqlLiteral(for:)` lives only in a protocol extension, so it is statically +/// dispatched and cannot be overridden here. These tests assert against the +/// shared default, which emits numeric-looking text as a bare numeric literal. +private final class QuotingDriver: PluginDatabaseDriver, @unchecked Sendable { + func quoteIdentifier(_ name: String) -> String { "`\(name)`" } + + func connect() async throws {} + func disconnect() {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +final class DataSyncScriptBuilderTests: XCTestCase { + private let driver = QuotingDriver() + + private func options( + insert: Bool = true, + update: Bool = true, + delete: Bool = true, + keys: [String] = ["id"] + ) -> DataCompareOptions { + var result = DataCompareOptions() + result.keyColumns = keys + result.insertMissingRows = insert + result.updateDifferingRows = update + result.deleteExtraRows = delete + return result + } + + private func row(_ pairs: [String: PluginCellValue]) -> DataRow { + DataRow(values: pairs) + } + + private func build( + entries: [RowDiffEntry], + options overrides: DataCompareOptions? = nil, + columns: [String] = ["id", "name"], + schema: String? = nil + ) -> [SyncStatement] { + DataSyncScriptBuilder(targetDriver: driver, options: overrides ?? options()) + .build(table: "users", schema: schema, writeColumns: columns, entries: entries) + } + + // MARK: - Insert + + func testInsertQuotesIdentifiersAndEscapesValues() { + let entry = RowDiffEntry( + kind: .insert, + keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("O'Hara")]), + targetRow: nil + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual( + statements[0].sql, + "INSERT INTO `users` (`id`, `name`) VALUES (1, 'O''Hara');" + ) + } + + func testInsertQualifiesWithSchemaWhenPresent() { + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("a")]), targetRow: nil + ) + + let statements = build(entries: [entry], schema: "app") + + XCTAssertTrue(statements[0].sql.hasPrefix("INSERT INTO `app`.`users`"), statements[0].sql) + } + + // MARK: - Update + + func testUpdateSetsNonKeyColumnsAndKeysTheWhereClause() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("new")]), + targetRow: row(["id": .text("1"), "name": .text("old")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual(statements[0].sql, "UPDATE `users` SET `name` = 'new' WHERE `id` = 1;") + XCTAssertFalse(statements[0].sql.contains("SET `id`"), "the key must never be assigned in SET") + } + + func testUpdateIsSkippedWhenEveryColumnIsAKey() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "1", + sourceRow: row(["id": .text("1")]), targetRow: row(["id": .text("1")]) + ) + + let statements = build(entries: [entry], options: options(keys: ["id"]), columns: ["id"]) + + XCTAssertTrue(statements.isEmpty, "there is nothing to assign, so no statement should be produced") + } + + func testCompositeKeyProducesConjunctionInWhereClause() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "a, 1", + sourceRow: row(["tenant": .text("a"), "id": .text("1"), "name": .text("new")]), + targetRow: row(["tenant": .text("a"), "id": .text("1"), "name": .text("old")]) + ) + + let statements = build( + entries: [entry], + options: options(keys: ["tenant", "id"]), + columns: ["tenant", "id", "name"] + ) + + XCTAssertTrue(statements[0].sql.contains("WHERE `tenant` = 'a' AND `id` = 1;"), statements[0].sql) + } + + // MARK: - Delete + + func testDeleteIsKeyedAndCarriesARefusedHazard() { + let entry = RowDiffEntry( + kind: .delete, keyDescription: "7", + sourceRow: nil, targetRow: row(["id": .text("7"), "name": .text("x")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual(statements[0].sql, "DELETE FROM `users` WHERE `id` = 7;") + XCTAssertTrue(statements[0].isRefusedByDefault, "a delete must be held back until allowed") + } + + // MARK: - NULL handling + + func testNullKeyUsesIsNullRatherThanEquality() { + let entry = RowDiffEntry( + kind: .delete, keyDescription: "NULL", + sourceRow: nil, targetRow: row(["id": .null, "name": .text("x")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements[0].sql, "DELETE FROM `users` WHERE `id` IS NULL;") + } + + func testNullValueIsWrittenAsNullLiteralOnInsert() { + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .null]), targetRow: nil + ) + + let statements = build(entries: [entry]) + + XCTAssertTrue(statements[0].sql.hasSuffix("VALUES (1, NULL);"), statements[0].sql) + } + + // MARK: - Action toggles + + func testDisabledActionsProduceNoStatements() { + let entries = [ + RowDiffEntry(kind: .insert, keyDescription: "1", sourceRow: row(["id": .text("1")]), targetRow: nil), + RowDiffEntry( + kind: .update, keyDescription: "2", + sourceRow: row(["id": .text("2"), "name": .text("a")]), + targetRow: row(["id": .text("2"), "name": .text("b")]) + ), + RowDiffEntry(kind: .delete, keyDescription: "3", sourceRow: nil, targetRow: row(["id": .text("3")])) + ] + + let statements = build(entries: entries, options: options(insert: false, update: false, delete: false)) + + XCTAssertTrue(statements.isEmpty) + } + + func testIdenticalRowsNeverProduceStatements() { + let entry = RowDiffEntry( + kind: .identical, keyDescription: "1", + sourceRow: row(["id": .text("1")]), targetRow: row(["id": .text("1")]) + ) + + XCTAssertTrue(build(entries: [entry]).isEmpty) + } + + // MARK: - Ordering + + func testInsertsComeBeforeUpdatesWhichComeBeforeDeletes() { + let entries = [ + RowDiffEntry(kind: .delete, keyDescription: "3", sourceRow: nil, targetRow: row(["id": .text("3")])), + RowDiffEntry( + kind: .update, keyDescription: "2", + sourceRow: row(["id": .text("2"), "name": .text("a")]), + targetRow: row(["id": .text("2"), "name": .text("b")]) + ), + RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("c")]), targetRow: nil + ) + ] + + let verbs = build(entries: entries).map { String($0.sql.prefix(6)) } + + XCTAssertEqual(verbs, ["INSERT", "UPDATE", "DELETE"]) + } + + // MARK: - Write set + + func testExcludedFromComparisonColumnIsStillWritten() { + var overrides = options() + overrides.excludedFromComparison = ["updated_at"] + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("a"), "updated_at": .text("2026-01-01")]), + targetRow: nil + ) + + let statements = build(entries: [entry], options: overrides, columns: ["id", "name", "updated_at"]) + + XCTAssertTrue( + statements[0].sql.contains("`updated_at`") && statements[0].sql.contains("'2026-01-01'"), + "a column excluded from matching must still be written: \(statements[0].sql)" + ) + } +} diff --git a/TableProTests/Core/Compare/KeyOrderingTests.swift b/TableProTests/Core/Compare/KeyOrderingTests.swift new file mode 100644 index 000000000..90f98d971 --- /dev/null +++ b/TableProTests/Core/Compare/KeyOrderingTests.swift @@ -0,0 +1,189 @@ +// +// KeyOrderingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class KeyOrderingPolicyTests: XCTestCase { + private func numeric() -> KeyOrdering { KeyOrdering(orders: [.numeric]) } + private func text() -> KeyOrdering { KeyOrdering(orders: [.binaryText]) } + + func testNumericKeysOrderNumerically() { + XCTAssertEqual(numeric().compare([.text("9")], [.text("10")]), .orderedAscending) + XCTAssertEqual(numeric().compare([.text("10")], [.text("9")]), .orderedDescending) + } + + func testTextKeysOrderByBytesNotByNumericValue() { + XCTAssertEqual( + text().compare([.text("10")], [.text("9")]), .orderedAscending, + "a text key must sort the way a byte-ordered collation sorts, not numerically" + ) + } + + func testTextOrderingIsCaseSensitiveByBytes() { + XCTAssertEqual(text().compare([.text("Carol")], [.text("bob")]), .orderedAscending) + } + + func testEqualKeysReportSame() { + XCTAssertEqual(text().compare([.text("a"), .text("1")], [.text("a"), .text("1")]), .orderedSame) + } + + func testOrderingIsAntisymmetric() { + let pairs: [([PluginCellValue], [PluginCellValue])] = [ + ([.text("1")], [.text("2")]), + ([.text("b")], [.text("a")]), + ([.bytes(Data([1]))], [.bytes(Data([2]))]) + ] + for (left, right) in pairs { + XCTAssertNotEqual(text().compare(left, right), text().compare(right, left)) + } + } + + func testCompositeKeyFallsThroughToSecondColumn() { + let ordering = KeyOrdering(orders: [.binaryText, .numeric]) + + XCTAssertEqual( + ordering.compare([.text("a"), .text("2")], [.text("a"), .text("10")]), + .orderedAscending + ) + } + + // MARK: - Type classification + + func testNumericTypesAreClassifiedNumeric() { + for type in ["int", "INT(11)", "bigint unsigned", "numeric(10,2)", "double precision", "serial"] { + XCTAssertTrue(KeyOrdering.isNumeric(type), "\(type) should compare numerically") + } + } + + func testTextAndDateTypesAreNotNumeric() { + for type in ["varchar(255)", "text", "uuid", "timestamp", "date", "bytea"] { + XCTAssertFalse(KeyOrdering.isNumeric(type), "\(type) should not compare numerically") + } + } + + func testOrdersDerivedFromDeclaredColumnTypes() { + let orders = KeyOrdering.orders( + for: ["id", "name"], + columnTypes: ["id": "bigint", "name": "varchar(50)"] + ) + + XCTAssertEqual(orders, [.numeric, .binaryText]) + } + + func testUnknownColumnTypeFallsBackToBinaryText() { + XCTAssertEqual(KeyOrdering.orders(for: ["mystery"], columnTypes: [:]), [.binaryText]) + } + + // MARK: - Stream order check + + func testNumericOnlyKeysNeedNoStreamCheck() { + XCTAssertFalse(KeyOrdering(orders: [.numeric]).requiresStreamOrderCheck) + } + + func testTextKeysRequireStreamCheck() { + XCTAssertTrue(KeyOrdering(orders: [.numeric, .binaryText]).requiresStreamOrderCheck) + } + + // MARK: - NULL keys + + func testNullComponentIsDetected() { + XCTAssertTrue(KeyOrdering.hasNullComponent([.text("a"), .null])) + XCTAssertFalse(KeyOrdering.hasNullComponent([.text("a"), .text("b")])) + } +} + +final class DataDiffOrderingSafetyTests: XCTestCase { + private func row(_ id: String) -> DataRow { + DataRow(values: ["id": .text(id), "name": .text("n")]) + } + + private func engine(numericKey: Bool) -> DataDiffEngine { + var options = DataCompareOptions() + options.keyColumns = ["id"] + return DataDiffEngine( + options: options, + columns: ["id", "name"], + columnTypes: ["id": numericKey ? "int" : "varchar(20)"] + ) + } + + /// The case that previously produced a delete for a row present on both sides. + func testDisagreeingCollationIsReportedInsteadOfDeletingAMatchingRow() async { + let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) + let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) + + do { + let summary = try await engine(numericKey: false).compare(source: source, target: target) + XCTAssertEqual( + summary.deleteCount, 0, + "a row present on both sides must never be reported as a delete" + ) + } catch let error as CompareSyncError { + guard case .streamOutOfOrder = error else { + return XCTFail("Expected an out-of-order report, got \(error)") + } + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testOutOfOrderTextStreamThrowsRatherThanMismatching() async { + let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) + let target = ArrayRowProvider(rows: [row("Alice")]) + + do { + _ = try await engine(numericKey: false).compare(source: source, target: target) + XCTFail("Expected an out-of-order error for a case-insensitively sorted stream") + } catch let error as CompareSyncError { + guard case .streamOutOfOrder(let message) = error else { + return XCTFail("Expected streamOutOfOrder, got \(error)") + } + XCTAssertTrue( + message.contains("Carol"), + "the message must name the key the read stopped at: \(message)" + ) + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testInOrderTextStreamComparesNormally() async throws { + let source = ArrayRowProvider(rows: [row("Alice"), row("Carol"), row("bob")]) + let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) + + let summary = try await engine(numericKey: false).compare(source: source, target: target) + + XCTAssertEqual(summary.identicalCount, 2) + XCTAssertEqual(summary.insertCount, 1) + XCTAssertEqual(summary.deleteCount, 0) + } + + func testNumericKeysNeedNoOrderCheckAndCompareNumerically() async throws { + let source = ArrayRowProvider(rows: [row("2"), row("9"), row("10")]) + let target = ArrayRowProvider(rows: [row("2"), row("10")]) + + let summary = try await engine(numericKey: true).compare(source: source, target: target) + + XCTAssertEqual(summary.identicalCount, 2) + XCTAssertEqual(summary.insertCount, 1, "9 is only on the source") + XCTAssertEqual(summary.deleteCount, 0, "10 exists on both sides and must not be deleted") + } + + func testRowsWithNullKeysAreSkippedNotGivenAnArbitraryOrder() async throws { + let withNullKey = DataRow(values: ["id": .null, "name": .text("n")]) + let source = ArrayRowProvider(rows: [withNullKey, row("1"), row("2")]) + let target = ArrayRowProvider(rows: [row("1")]) + + let summary = try await engine(numericKey: true).compare(source: source, target: target) + + XCTAssertEqual(summary.skippedNullKeyCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.insertCount, 1, "only the non-null key rows take part") + } +} diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift index 58586d3d6..87939491d 100644 --- a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -173,6 +173,34 @@ final class SchemaSyncScriptBuilderTests: XCTestCase { XCTAssertEqual(ordered.map { $0.tableName }, ["c", "b", "a"]) } + func testSameTableNameInTwoSchemasBothSurviveOrdering() { + let operations: [SchemaSyncOperation] = [ + .alterTable(name: "users", schema: "app", changes: []), + .alterTable(name: "users", schema: "audit", changes: []) + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: [:]) + + XCTAssertEqual(ordered.count, 2, "a bare-name collision must not drop or duplicate an operation") + XCTAssertEqual(Set(ordered.map { $0.tableIdentifier }), ["app.users", "audit.users"]) + } + + func testOrderingUsesQualifiedNamesForDependencies() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = [ + "orders": [PluginForeignKeyInfo( + name: "fk", column: "customer_id", referencedTable: "customers", referencedColumn: "id" + )] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableIdentifier }, ["customers", "orders"]) + } + func testCircularForeignKeysStillEmitEveryTable() { let operations: [SchemaSyncOperation] = [ .createTable(snapshot("a")), diff --git a/TableProTests/Core/Compare/StreamingRowProviderTests.swift b/TableProTests/Core/Compare/StreamingRowProviderTests.swift new file mode 100644 index 000000000..9aca8f7da --- /dev/null +++ b/TableProTests/Core/Compare/StreamingRowProviderTests.swift @@ -0,0 +1,160 @@ +// +// StreamingRowProviderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class StreamingRowProviderTests: XCTestCase { + private func stream(_ elements: [PluginStreamElement]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for element in elements { continuation.yield(element) } + continuation.finish() + } + } + + private func header(_ columns: [String]) -> PluginStreamElement { + .header(PluginStreamHeader(columns: columns, columnTypeNames: columns.map { _ in "text" })) + } + + func testHeaderColumnsAreUsedToNameValues() async throws { + let provider = StreamingRowProvider( + stream: stream([header(["id", "name"]), .rows([[.text("1"), .text("a")]])]) + ) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1")) + XCTAssertEqual(row?.value(for: "name"), .text("a")) + } + + func testExplicitColumnsSurviveAMissingHeader() async throws { + let provider = StreamingRowProvider( + stream: stream([.rows([[.text("1"), .text("a")]])]), + columns: ["id", "name"] + ) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "name"), .text("a")) + } + + func testRowsAcrossMultipleBatchesAreAllReturnedInOrder() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id"]), + .rows([[.text("1")], [.text("2")]]), + .rows([[.text("3")]]) + ])) + + var seen: [String] = [] + while let row = try await provider.nextRow() { + if case .text(let value) = row.value(for: "id") { seen.append(value) } + } + + XCTAssertEqual(seen, ["1", "2", "3"]) + } + + func testEmptyBatchesAreSkippedRatherThanEndingTheStream() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id"]), + .rows([]), + .rows([]), + .rows([[.text("1")]]) + ])) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1"), "empty batches must not be mistaken for end of stream") + } + + func testExhaustedStreamReturnsNilRepeatedly() async throws { + let provider = StreamingRowProvider(stream: stream([header(["id"]), .rows([[.text("1")]])])) + + _ = try await provider.nextRow() + + let firstNil = try await provider.nextRow() + let secondNil = try await provider.nextRow() + XCTAssertNil(firstNil) + XCTAssertNil(secondNil) + } + + func testShortRowDoesNotOverrunTheColumnList() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id", "name", "extra"]), + .rows([[.text("1")]]) + ])) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1")) + XCTAssertEqual(row?.value(for: "name"), .null, "a column with no value reads as NULL") + } + + func testErrorsFromTheStreamPropagate() async { + struct Boom: Error {} + let failing = AsyncThrowingStream { continuation in + continuation.yield(self.header(["id"])) + continuation.finish(throwing: Boom()) + } + let provider = StreamingRowProvider(stream: failing) + + do { + _ = try await provider.nextRow() + XCTFail("Expected the stream error to surface") + } catch { + XCTAssertTrue(error is Boom) + } + } +} + +final class KeyOrderedQueryTests: XCTestCase { + private final class Quoting: PluginDatabaseDriver, @unchecked Sendable { + func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } + func connect() async throws {} + func disconnect() {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + } + + func testQuerySelectsRequestedColumnsOrderedByKey() { + let sql = KeyOrderedQuery.build( + table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], driver: Quoting() + ) + + XCTAssertEqual(sql, "SELECT \"id\", \"name\" FROM \"users\" ORDER BY \"id\"") + } + + func testCompositeKeyOrdersByEveryKeyColumn() { + let sql = KeyOrderedQuery.build( + table: "t", schema: nil, columns: ["a"], keyColumns: ["tenant", "id"], driver: Quoting() + ) + + XCTAssertTrue(sql.hasSuffix("ORDER BY \"tenant\", \"id\""), sql) + } + + func testSchemaIsQualifiedAndQuoted() { + let sql = KeyOrderedQuery.build( + table: "users", schema: "app", columns: ["id"], keyColumns: ["id"], driver: Quoting() + ) + + XCTAssertTrue(sql.contains("FROM \"app\".\"users\""), sql) + } +} diff --git a/docs/features/compare-sync.mdx b/docs/features/compare-sync.mdx index aa30a1a8b..8c57990cf 100644 --- a/docs/features/compare-sync.mdx +++ b/docs/features/compare-sync.mdx @@ -81,7 +81,8 @@ Exclusions apply by column name across every table in the comparison, which is w ### How values are judged equal -- `NULL` equals only `NULL`. It is never treated as an empty string. +- `NULL` equals only `NULL`. It is never treated as an empty string. A row whose *key* is NULL cannot identify anything, so it is skipped and counted rather than given an arbitrary sort position. +- Key columns must sort the same way on both sides. A numeric key does by construction. For any other key TablePro checks the order as rows arrive and stops with an explanation if the server's collation sorts differently, rather than reporting a row that exists on both sides as a delete. - Numbers compare exactly unless you set a tolerance. A tolerance of `0.001` treats `109.05999755859375` and `109.05999755859381` as equal. The comparison gives the same answer whichever side you call the source. - Timestamps compare as instants at the fractional precision you choose. The same instant written at two offsets, such as `08:00:00-08:00` and `11:00:00-05:00`, is not a difference. No timezone conversion is applied anywhere in the comparison. - Binary values compare by bytes.