diff --git a/CHANGELOG.md b/CHANGELOG.md index 940b7b8e48..8ad4669e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -323,6 +323,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Saved Compare & Sync scripts that SQL*Plus, DISQL, the mysql client or SQL Server tools could not run. - Oracle, Dameng and MySQL SQL dumps whose routines and triggers the engine's own client could not restore. - Compare & Sync showing an Oracle unit missing the `;` after its `END` as identical. +- Compare & Sync scripting a `DROP` with no `CREATE` for a view, routine or trigger whose definition it could not read. +- Compare & Sync offering to drop every target procedure, function or trigger when the source's list could not be read. +- DuckDB macro dropped and not recreated by a Compare & Sync replace. +- Copy To giving no reason for a view, routine or trigger whose definition could not be read. +- Copy To skipping a view, routine or trigger with a comment above its `CREATE`. - SSH jump hosts dropped from a connection synced to iPhone and iPad, and that connection then skipped on the way back. - An SSH tunnel pinned to port 22, and its auth method read back as Password, after a round trip through iPhone and iPad. - Redis database list failing on servers that refuse `CONFIG` or `INFO`, such as AWS ElastiCache and Azure Cache for Redis. (#3036) diff --git a/TablePro/Core/Compare/CompareMetadataService+SourceDefinitions.swift b/TablePro/Core/Compare/CompareMetadataService+SourceDefinitions.swift new file mode 100644 index 0000000000..f64a8fca3c --- /dev/null +++ b/TablePro/Core/Compare/CompareMetadataService+SourceDefinitions.swift @@ -0,0 +1,194 @@ +// +// CompareMetadataService+SourceDefinitions.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit +import TableProSQLGrammar + +internal struct RoutineSourceRead: Sendable { + internal let name: String + internal let kind: CompareObjectKind + internal let schema: String? + internal let signature: String? + internal let source: String + internal let failure: String? + + internal init( + name: String, + kind: CompareObjectKind, + schema: String?, + signature: String?, + source: String, + failure: String? = nil + ) { + self.name = name + self.kind = kind + self.schema = schema + self.signature = signature + self.source = source + self.failure = failure + } +} + +internal extension CompareMetadataService { + nonisolated private static let definitionLogger = Logger( + subsystem: "com.TablePro", category: "CompareMetadataService" + ) + + nonisolated static func readViewDefinitions( + _ views: [PluginTableInfo], + schema: String?, + using plugin: any PluginDatabaseDriver + ) async throws -> [RoutineSourceRead] { + var reads: [RoutineSourceRead] = [] + for view in views { + try Task.checkCancellation() + let viewSchema = view.schema ?? schema + reads.append(try await definitionRead( + name: view.name, + kind: CompareTableKindClassifier.kind(of: view), + schema: viewSchema, + signature: nil + ) { + try await plugin.fetchViewDefinition(view: view.name, schema: viewSchema) + }) + } + return reads + } + + nonisolated static func readRoutineDefinitions( + schema: String?, + endpointName: String, + using plugin: any PluginDatabaseDriver + ) async throws -> [RoutineSourceRead] { + let routines: [PluginRoutineInfo] + do { + routines = try await plugin.fetchRoutines(schema: schema) + } catch { + throw listingFailure(error, message: String( + format: String(localized: "The procedures and functions in %1$@ could not be listed: %2$@"), + endpointName, error.localizedDescription + )) + } + var reads: [RoutineSourceRead] = [] + for routine in routines { + try Task.checkCancellation() + reads.append(try await definitionRead( + name: routine.name, + kind: routine.kind == .procedure ? .procedure : .function, + schema: routine.schema ?? schema, + signature: routine.argumentSignature + ) { + try await plugin.fetchRoutineDDL(routine) + }) + } + return reads + } + + nonisolated static func readTriggerDefinitions( + tables: [String], + schema: String?, + endpointName: String, + using plugin: any PluginDatabaseDriver + ) async throws -> [RoutineSourceRead] { + let listed = try await listTriggers(tables: tables, schema: schema, endpointName: endpointName, using: plugin) + var reads: [RoutineSourceRead] = [] + for (trigger, owningTable) in listed { + try Task.checkCancellation() + reads.append(try await definitionRead( + name: trigger.name, + kind: .trigger, + schema: trigger.schema ?? schema, + signature: trigger.table ?? owningTable + ) { + if let definition = trigger.definition, StatementBlank.hasContent(definition) { + return definition + } + return try await plugin.fetchTriggerDDL(trigger) + }) + } + return reads + } + + nonisolated private static func listTriggers( + tables: [String], + schema: String?, + endpointName: String, + using plugin: any PluginDatabaseDriver + ) async throws -> [(trigger: PluginTriggerInfo, owningTable: String?)] { + guard plugin.providesBulkTriggerFetch else { + return try await listTriggersPerTable(tables, schema: schema, endpointName: endpointName, using: plugin) + } + let triggers: [PluginTriggerInfo] + do { + triggers = try await plugin.fetchAllTriggers(schema: schema) + } catch is CancellationError { + throw CancellationError() + } catch { + definitionLogger.warning( + "Whole-schema trigger read failed, falling back per table: \(error.publicLogShape, privacy: .public)" + ) + return try await listTriggersPerTable(tables, schema: schema, endpointName: endpointName, using: plugin) + } + let inScope = Set(tables.map { $0.lowercased() }) + return triggers + .filter { trigger in + guard let table = trigger.table?.lowercased() else { return true } + return inScope.contains(table) + } + .map { (trigger: $0, owningTable: nil) } + } + + nonisolated private static func listTriggersPerTable( + _ tables: [String], + schema: String?, + endpointName: String, + using plugin: any PluginDatabaseDriver + ) async throws -> [(trigger: PluginTriggerInfo, owningTable: String?)] { + var listed: [(trigger: PluginTriggerInfo, owningTable: String?)] = [] + for table in tables { + try Task.checkCancellation() + do { + listed += try await plugin.fetchTriggers(table: table, schema: schema) + .map { (trigger: $0, owningTable: table) } + } catch { + throw listingFailure(error, message: String( + format: String(localized: "The triggers on %1$@ in %2$@ could not be listed: %3$@"), + table, endpointName, error.localizedDescription + )) + } + } + return listed + } + + nonisolated private static func listingFailure(_ error: Error, message: String) -> Error { + guard !(error is CancellationError), !Task.isCancelled else { return CancellationError() } + definitionLogger.warning("Definition listing failed: \(error.publicLogShape, privacy: .public)") + return CompareSyncError.readFailed(message) + } + + nonisolated private static func definitionRead( + name: String, + kind: CompareObjectKind, + schema: String?, + signature: String?, + reading: () async throws -> String + ) async throws -> RoutineSourceRead { + do { + let source = try await reading() + return RoutineSourceRead(name: name, kind: kind, schema: schema, signature: signature, source: source) + } catch { + guard !(error is CancellationError), !Task.isCancelled else { throw CancellationError() } + definitionLogger.warning( + "Definition read failed for \(kind.rawValue, privacy: .public) \(name, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" + ) + return RoutineSourceRead( + name: name, kind: kind, schema: schema, signature: signature, + source: "", failure: error.localizedDescription + ) + } + } +} diff --git a/TablePro/Core/Compare/CompareMetadataService.swift b/TablePro/Core/Compare/CompareMetadataService.swift index 76be00236c..4b8c72228b 100644 --- a/TablePro/Core/Compare/CompareMetadataService.swift +++ b/TablePro/Core/Compare/CompareMetadataService.swift @@ -39,14 +39,6 @@ internal struct TableStructureRead: Sendable { } } -internal struct RoutineSourceRead: Sendable { - internal let name: String - internal let kind: CompareObjectKind - internal let schema: String? - internal let signature: String? - internal let source: String -} - @MainActor internal struct CompareMetadataService { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CompareMetadataService") @@ -159,44 +151,19 @@ internal struct CompareMetadataService { return try await (source, target) } - /// `fetchRoutines` supersedes the old per-kind pair and carries `identity`, which is what - /// `fetchRoutineDDL` needs to address an overloaded routine again. A routine whose DDL cannot - /// be read is still listed, with an empty definition, so it shows as present rather than - /// vanishing from the comparison. internal func routineReads( for endpoint: DatabaseEndpoint, connection: DatabaseConnection ) async throws -> [RoutineSourceRead] { try await manager.ensureConnected(connection) let schema = endpoint.schema + let endpointName = endpoint.qualifiedDescription return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in guard let plugin = Self.pluginDriver(from: driver) else { return [] } - let routines = (try? await plugin.fetchRoutines(schema: schema)) ?? [] - var reads: [RoutineSourceRead] = [] - for routine in routines { - try Task.checkCancellation() - var source = routine.definition ?? "" - if source.isEmpty { - source = (try? await plugin.fetchRoutineDDL(routine)) ?? "" - } - reads.append(RoutineSourceRead( - name: routine.name, - kind: routine.kind == .procedure ? .procedure : .function, - schema: routine.schema ?? schema, - signature: routine.argumentSignature, - source: source - )) - } - return reads + return try await Self.readRoutineDefinitions(schema: schema, endpointName: endpointName, using: plugin) } } - /// A trigger on a table that is not in scope is not in scope either, so the tables the - /// structure read already listed are the ones kept. - /// - /// The whole-schema read is one query where the driver has one. Where it does not, the - /// protocol's default answers with nothing rather than looping, so the per-table read is the - /// only correct fallback and `providesBulkTriggerFetch` is what tells the two apart. internal func triggerReads( for endpoint: DatabaseEndpoint, connection: DatabaseConnection, @@ -204,61 +171,13 @@ internal struct CompareMetadataService { ) async throws -> [RoutineSourceRead] { try await manager.ensureConnected(connection) let schema = endpoint.schema - let inScope = Set(tables.map { $0.lowercased() }) + let endpointName = endpoint.qualifiedDescription return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in guard let plugin = Self.pluginDriver(from: driver) else { return [] } - guard plugin.providesBulkTriggerFetch else { - return try await Self.perTableTriggerReads(tables: tables, schema: schema, using: plugin) - } - /// A failed whole-schema query is not an answer of "no triggers". Swallowing it made an - /// empty set authoritative on one side, so every trigger on the other side read as a - /// real difference and the script offered to drop or create all of them. - let triggers: [PluginTriggerInfo] - do { - triggers = try await plugin.fetchAllTriggers(schema: schema) - } catch is CancellationError { - throw CancellationError() - } catch { - Self.logger.warning( - "Whole-schema trigger read failed, falling back per table: \(error.publicLogShape, privacy: .public)" - ) - return try await Self.perTableTriggerReads(tables: tables, schema: schema, using: plugin) - } - return triggers - .filter { trigger in - guard let table = trigger.table?.lowercased() else { return true } - return inScope.contains(table) - } - .map { Self.read($0, schema: schema, fallbackTable: nil) } - } - } - - nonisolated private static func perTableTriggerReads( - tables: [String], - schema: String?, - using plugin: any PluginDatabaseDriver - ) async throws -> [RoutineSourceRead] { - var reads: [RoutineSourceRead] = [] - for table in tables { - try Task.checkCancellation() - guard let triggers = try? await plugin.fetchTriggers(table: table, schema: schema) else { continue } - reads += triggers.map { read($0, schema: schema, fallbackTable: table) } + return try await Self.readTriggerDefinitions( + tables: tables, schema: schema, endpointName: endpointName, using: plugin + ) } - return reads - } - - nonisolated private static func read( - _ trigger: PluginTriggerInfo, - schema: String?, - fallbackTable: String? - ) -> RoutineSourceRead { - RoutineSourceRead( - name: trigger.name, - kind: .trigger, - schema: trigger.schema ?? schema, - signature: trigger.table ?? fallbackTable, - source: trigger.definition ?? trigger.statement - ) } internal func viewDefinitions( @@ -270,22 +189,7 @@ internal struct CompareMetadataService { let schema = endpoint.schema return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in guard let plugin = Self.pluginDriver(from: driver) else { return [] } - var reads: [RoutineSourceRead] = [] - for view in views { - try Task.checkCancellation() - let definition = try? await plugin.fetchViewDefinition( - view: view.name, schema: view.schema ?? schema - ) - let source = definition ?? "" - reads.append(RoutineSourceRead( - name: view.name, - kind: CompareTableKindClassifier.kind(of: view), - schema: view.schema ?? schema, - signature: nil, - source: source - )) - } - return reads + return try await Self.readViewDefinitions(views, schema: schema, using: plugin) } } diff --git a/TablePro/Core/Compare/CompareRunner.swift b/TablePro/Core/Compare/CompareRunner.swift index aa10efc144..384e6ff8b0 100644 --- a/TablePro/Core/Compare/CompareRunner.swift +++ b/TablePro/Core/Compare/CompareRunner.swift @@ -358,6 +358,7 @@ internal struct CompareRunner { targetReads: [TableStructureRead] ) async throws -> [CompareObjectResult] { var results: [CompareObjectResult] = [] + let includedKinds = session.includedKinds let diffEngine = SourceObjectDiffEngine( options: session.structureOptions, sourceDatabaseType: context.source.databaseType, @@ -366,9 +367,10 @@ internal struct CompareRunner { /// Each pair reads two independent endpoints, so the two sides run together rather than the /// second waiting out the first. - if session.includedKinds.contains(.view) || session.includedKinds.contains(.materializedView) { - let sourceViews = sourceReads.map(\.table).filter { CompareTableKindClassifier.kind(of: $0) != .table } - let targetViews = targetReads.map(\.table).filter { CompareTableKindClassifier.kind(of: $0) != .table } + let viewKinds = includedKinds.intersection([.view, .materializedView]) + if !viewKinds.isEmpty { + let sourceViews = sourceReads.map(\.table).filter { viewKinds.contains(CompareTableKindClassifier.kind(of: $0)) } + let targetViews = targetReads.map(\.table).filter { viewKinds.contains(CompareTableKindClassifier.kind(of: $0)) } async let sourceDefinitions = metadataService.viewDefinitions( for: context.source, connection: context.sourceConnection, views: sourceViews ) @@ -378,7 +380,7 @@ internal struct CompareRunner { results += try await diffEngine.compare(source: sourceDefinitions, target: targetDefinitions) } - if session.includedKinds.contains(.procedure) || session.includedKinds.contains(.function) { + if includedKinds.contains(.procedure) || includedKinds.contains(.function) { async let sourceRoutines = metadataService.routineReads( for: context.source, connection: context.sourceConnection ) @@ -386,10 +388,9 @@ internal struct CompareRunner { for: context.target, connection: context.targetConnection ) results += try await diffEngine.compare(source: sourceRoutines, target: targetRoutines) - .filter { session.includedKinds.contains($0.identity.kind) } } - if session.includedKinds.contains(.trigger) { + if includedKinds.contains(.trigger) { async let sourceTriggers = metadataService.triggerReads( for: context.source, connection: context.sourceConnection, @@ -403,7 +404,7 @@ internal struct CompareRunner { results += try await diffEngine.compare(source: sourceTriggers, target: targetTriggers) } - return results + return results.filter { includedKinds.contains($0.identity.kind) } } private func structureStatements(_ context: Context) async throws -> [SyncStatement] { @@ -447,7 +448,7 @@ internal struct CompareRunner { targetDriver: plugin, targetDatabaseType: driver.connection.type ) for entry in sourceDefined { - statements += sourceBuilder.build(for: entry.result, action: entry.action) + statements += try sourceBuilder.build(for: entry.result, action: entry.action) } return statements } @@ -476,7 +477,13 @@ internal struct CompareRunner { actions: { actions[$0.id] ?? .skip }, sourceSnapshots: verification.sourceSnapshots ) - if let refusal = StructureChangeGuard.refusal(expected: expected, actual: actual) { + let unreadable = Dictionary( + verification.report.uncomparable.compactMap { result in + result.comparisonError.map { (result.id, $0) } + }, + uniquingKeysWith: { first, _ in first } + ) + if let refusal = StructureChangeGuard.refusal(expected: expected, actual: actual, unreadable: unreadable) { throw refusal } } diff --git a/TablePro/Core/Compare/SourceDefinitionDefect.swift b/TablePro/Core/Compare/SourceDefinitionDefect.swift new file mode 100644 index 0000000000..e236054dc9 --- /dev/null +++ b/TablePro/Core/Compare/SourceDefinitionDefect.swift @@ -0,0 +1,41 @@ +// +// SourceDefinitionDefect.swift +// TablePro +// + +import Foundation + +internal enum SourceDefinitionDefect: Equatable, Sendable { + case unreadable(String) + case empty + case notACreateStatement + + internal static func of(_ read: RoutineSourceRead, sentAs scriptText: SQLScriptText) -> SourceDefinitionDefect? { + if let failure = read.failure { return .unreadable(failure) } + return of(definition: read.source, sentAs: scriptText) + } + + internal static func of(definition: String, sentAs scriptText: SQLScriptText) -> SourceDefinitionDefect? { + guard let first = scriptText.sendableStatements(definition).first, + let keyword = scriptText.leadingKeyword(of: first) + else { return .empty } + return keyword == "CREATE" ? nil : .notACreateStatement + } + + internal func reason(on side: ComparisonSide) -> String { + switch (self, side) { + case (.unreadable(let failure), .source): + return String(format: String(localized: "The source's definition could not be read: %@"), failure) + case (.unreadable(let failure), .target): + return String(format: String(localized: "The target's definition could not be read: %@"), failure) + case (.empty, .source): + return String(localized: "The source returned an empty definition.") + case (.empty, .target): + return String(localized: "The target returned an empty definition.") + case (.notACreateStatement, .source): + return String(localized: "The source returned a body rather than a statement that recreates it.") + case (.notACreateStatement, .target): + return String(localized: "The target returned a body rather than a statement that recreates it.") + } + } +} diff --git a/TablePro/Core/Compare/SourceObjectDiffEngine.swift b/TablePro/Core/Compare/SourceObjectDiffEngine.swift index ef0cfe0e9a..94d87231a5 100644 --- a/TablePro/Core/Compare/SourceObjectDiffEngine.swift +++ b/TablePro/Core/Compare/SourceObjectDiffEngine.swift @@ -17,6 +17,7 @@ // import Foundation +import TableProSQLGrammar internal struct SourceObjectDiffEngine { private let options: StructureCompareOptions @@ -47,52 +48,51 @@ internal struct SourceObjectDiffEngine { for read in source { let key = matchKey(for: read) handled.insert(key) - guard let counterpart = targetByKey[key] else { - results.append(result(for: read, counterpart: nil, status: .onlyInSource)) - continue - } - let equal = normalize(read.source, scriptText: sourceScriptText) - == normalize(counterpart.source, scriptText: targetScriptText) - results.append(result(for: read, counterpart: counterpart, status: equal ? .identical : .differs)) + results.append(result(identifiedBy: read, source: read, target: targetByKey[key])) } for read in target where !handled.contains(matchKey(for: read)) { - results.append(result(for: read, counterpart: nil, status: .onlyInTarget)) + results.append(result(identifiedBy: read, source: nil, target: read)) } return results } private func result( - for read: RoutineSourceRead, - counterpart: RoutineSourceRead?, - status: TableDiffStatus + identifiedBy read: RoutineSourceRead, + source: RoutineSourceRead?, + target: RoutineSourceRead? ) -> CompareObjectResult { - let identity = CompareObjectIdentity( - kind: read.kind, schema: read.schema, name: read.name, signature: read.signature - ) - let sourceLines = status == .onlyInTarget ? [] : SqlNormalizer.lines(read.source) - let targetLines: [String] - switch status { - case .onlyInTarget: - targetLines = SqlNormalizer.lines(read.source) - case .onlyInSource: - targetLines = [] - case .differs, .identical: - targetLines = SqlNormalizer.lines(counterpart?.source ?? "") - } + let sourceDefect = source.flatMap { SourceDefinitionDefect.of($0, sentAs: sourceScriptText) } + let targetDefect = target.flatMap { SourceDefinitionDefect.of($0, sentAs: targetScriptText) } + let comparisonError = sourceDefect.map { $0.reason(on: .source) } ?? targetDefect.map { $0.reason(on: .target) } return CompareObjectResult( - identity: identity, - status: status, - sourceDefinition: sourceLines, - targetDefinition: targetLines, - notes: notes(for: read, status: status) + identity: CompareObjectIdentity( + kind: read.kind, schema: read.schema, name: read.name, signature: read.signature + ), + status: status(source: source, target: target, comparable: comparisonError == nil), + sourceDefinition: source.map(displayedLines) ?? [], + targetDefinition: target.map(displayedLines) ?? [], + comparisonError: comparisonError ) } - private func notes(for read: RoutineSourceRead, status: TableDiffStatus) -> [String] { - guard status != .identical, read.source.isEmpty else { return [] } - return [String(localized: "The driver did not return this object's definition, so only its name was compared.")] + private func status( + source: RoutineSourceRead?, + target: RoutineSourceRead?, + comparable: Bool + ) -> TableDiffStatus { + guard let source else { return .onlyInTarget } + guard let target else { return .onlyInSource } + guard comparable else { return .differs } + let equal = normalize(source.source, scriptText: sourceScriptText) + == normalize(target.source, scriptText: targetScriptText) + return equal ? .identical : .differs + } + + private func displayedLines(_ read: RoutineSourceRead) -> [String] { + guard read.failure == nil, StatementBlank.hasContent(read.source) else { return [] } + return SqlNormalizer.lines(read.source) } private func matchKey(for read: RoutineSourceRead) -> String { diff --git a/TablePro/Core/Compare/SourceObjectSyncBuilder.swift b/TablePro/Core/Compare/SourceObjectSyncBuilder.swift index 9cbb8e7628..9a913db6fb 100644 --- a/TablePro/Core/Compare/SourceObjectSyncBuilder.swift +++ b/TablePro/Core/Compare/SourceObjectSyncBuilder.swift @@ -16,9 +16,12 @@ // import Foundation +import os import TableProPluginKit internal struct SourceObjectSyncBuilder { + private static let logger = Logger(subsystem: "com.TablePro", category: "SourceObjectSyncBuilder") + private let targetDriver: any PluginDatabaseDriver private let targetDatabaseType: DatabaseType private let scriptText: SQLScriptText @@ -30,13 +33,15 @@ internal struct SourceObjectSyncBuilder { self.scriptText = SQLScriptText(databaseType: targetDatabaseType) } - internal func build(for result: CompareObjectResult, action: TableSyncAction) -> [SyncStatement] { + internal func build(for result: CompareObjectResult, action: TableSyncAction) throws -> [SyncStatement] { switch action { case .skip: return [] case .create: + try refuseWithoutACreateStatement(result) return createStatements(for: result, isReplacement: false) case .alter: + try refuseWithoutACreateStatement(result) guard replacesInPlace(result.identity, with: result) else { return dropStatements(for: result, isReplacement: true) + createStatements(for: result, isReplacement: false) @@ -47,6 +52,20 @@ internal struct SourceObjectSyncBuilder { } } + private func refuseWithoutACreateStatement(_ result: CompareObjectResult) throws { + let definition = result.sourceDefinition.joined(separator: "\n") + guard SourceDefinitionDefect.of(definition: definition, sentAs: scriptText) != nil else { return } + Self.logger.fault( + "Refused to script \(result.identity.kind.rawValue, privacy: .public) \(result.identity.name, privacy: .private(mask: .hash)) without a CREATE statement" + ) + throw CompareSyncError.unsupportedOperation( + String( + format: String(localized: "%@ cannot be scripted, because its definition is not a statement that recreates it."), + result.identity.displayName + ) + ) + } + /// Whether running `replacement`'s definition alone replaces `existing` on the target. Measured on Oracle 23ai, a /// DROP followed by a CREATE the engine refused left no trigger at all, while the same CREATE OR REPLACE refused /// on its own left the existing trigger VALID. A materialized view has no `CREATE OR REPLACE` on any engine. diff --git a/TablePro/Core/Compare/StructureChangeGuard.swift b/TablePro/Core/Compare/StructureChangeGuard.swift index 36b2e00673..59c548f67a 100644 --- a/TablePro/Core/Compare/StructureChangeGuard.swift +++ b/TablePro/Core/Compare/StructureChangeGuard.swift @@ -65,14 +65,25 @@ internal enum StructureChangeGuard { /// would make a busy database impossible to sync. internal static func refusal( expected: [String: StructureGenerationInput], - actual: [String: StructureGenerationInput] + actual: [String: StructureGenerationInput], + unreadable: [String: String] = [:] ) -> CompareSyncError? { for (id, input) in expected.sorted(by: { $0.key < $1.key }) { + if let reason = unreadable[id] { return unreadableAgain(input.qualifiedName, reason: reason) } guard let current = actual[id], current == input else { return changed(input.qualifiedName) } } return nil } + private static func unreadableAgain(_ name: String, reason: String) -> CompareSyncError { + .objectsChangedSinceComparison( + String( + format: String(localized: "%1$@ could not be read again before generating the script. %2$@"), + name, reason + ) + ) + } + private static func changed(_ name: String) -> CompareSyncError { .objectsChangedSinceComparison( String( diff --git a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index b4ee6a59a6..14395c38ec 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -164,18 +164,6 @@ internal enum ObjectCopyEligibility { ) } - /// A definition the driver reports as a bare body rather than as a statement. - /// - /// ClickHouse, Oracle, Dameng and BigQuery answer `fetchViewDefinition` with the view's SELECT, - /// not its `CREATE`. Executing that runs a read, which the runner would then report as the view - /// copied, after Replace had already dropped the target's. - internal static func isExecutableDefinition(_ definition: String) -> Bool { - definition - .trimmingCharacters(in: .whitespacesAndNewlines) - .uppercased() - .hasPrefix("CREATE") - } - internal static var definitionNotExecutableRefusal: String { String(localized: "This driver reports its body rather than a statement that recreates it.") } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift new file mode 100644 index 0000000000..5196fcf391 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift @@ -0,0 +1,110 @@ +// +// ObjectCopyPlanner+SourceDefinitions.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum ObjectCopyDefinitionOutcome: Equatable, Sendable { + case runnable(String) + case skipped(String) +} + +internal extension ObjectCopyPlanner { + nonisolated static func definitionOutcome( + _ read: RoutineSourceRead?, + sentAs scriptText: SQLScriptText + ) -> ObjectCopyDefinitionOutcome { + guard let read else { return .skipped(noDefinition) } + guard let defect = SourceDefinitionDefect.of(read, sentAs: scriptText) else { return .runnable(read.source) } + switch defect { + case .unreadable(let reason): + return .skipped(reason) + case .empty: + return .skipped(noDefinition) + case .notACreateStatement: + return .skipped(ObjectCopyEligibility.definitionNotExecutableRefusal) + } + } + + nonisolated static func sourceDefinitionReads( + for selections: [ObjectCopySelection], + views: [PluginTableInfo], + triggerTables: [String], + schema: String?, + endpointName: String, + using plugin: any PluginDatabaseDriver + ) async throws -> [String: RoutineSourceRead] { + var reads: [String: RoutineSourceRead] = [:] + + let viewSelections = selections.filter { $0.kind == .view || $0.kind == .materializedView } + if !viewSelections.isEmpty { + for read in try await CompareMetadataService.readViewDefinitions(views, schema: schema, using: plugin) { + guard let selection = viewSelections.first(where: { $0.name.lowercased() == read.name.lowercased() }) + else { continue } + reads[selection.id] = read + } + } + + let routines = selections.filter { $0.kind == .procedure || $0.kind == .function } + if !routines.isEmpty { + do { + for read in try await CompareMetadataService.readRoutineDefinitions( + schema: schema, endpointName: endpointName, using: plugin + ) { + guard let selection = routines.first(where: { + $0.kind == read.kind + && $0.name.lowercased() == read.name.lowercased() + && ($0.signature ?? "") == (read.signature ?? "") + }) else { continue } + reads[selection.id] = read + } + } catch { + try reads.merge(listingFailure(error, for: routines), uniquingKeysWith: { _, failed in failed }) + } + } + + let triggers = selections.filter { $0.kind == .trigger } + if !triggers.isEmpty { + do { + for read in try await CompareMetadataService.readTriggerDefinitions( + tables: triggerTables, schema: schema, endpointName: endpointName, using: plugin + ) { + guard let selection = triggers.first(where: { + $0.name.lowercased() == read.name.lowercased() + && ($0.owner.map { $0.lowercased() == (read.signature ?? "").lowercased() } ?? true) + }) else { continue } + reads[selection.id] = read + } + } catch { + try reads.merge(listingFailure(error, for: triggers), uniquingKeysWith: { _, failed in failed }) + } + } + return reads + } + + nonisolated private static func listingFailure( + _ error: Error, + for selections: [ObjectCopySelection] + ) throws -> [String: RoutineSourceRead] { + guard !(error is CancellationError), !Task.isCancelled else { throw CancellationError() } + let reason = error.localizedDescription + return Dictionary( + selections.map { selection in + ( + selection.id, + RoutineSourceRead( + name: selection.name, + kind: selection.kind, + schema: selection.schema, + signature: selection.signature ?? selection.owner, + source: "", + failure: reason + ) + ) + }, + uniquingKeysWith: { first, _ in first } + ) + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 43a337d6f4..2370856f10 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -753,24 +753,22 @@ internal struct ObjectCopyPlanner { return [] } - let definitions = try await sourceDefinitions( + let definitionReads = try await sourceDefinitions( sourceEndpoint: sourceEndpoint, selections: selections, sourceReads: sourceReads, connection: connection ) + let targetScriptText = SQLScriptText(databaseType: request.target.databaseType) var pending: [(selection: ObjectCopySelection, definition: String, target: ObjectCopySelection?)] = [] for selection in Self.orderedByKind(selections) { - guard let definition = definitions[selection.id], - !definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - skipped.append(ObjectCopySkip(selection: selection, reason: Self.noDefinition)) - continue - } - guard ObjectCopyEligibility.isExecutableDefinition(definition) else { - skipped.append(ObjectCopySkip( - selection: selection, reason: ObjectCopyEligibility.definitionNotExecutableRefusal - )) + let definition: String + switch Self.definitionOutcome(definitionReads[selection.id], sentAs: targetScriptText) { + case .skipped(let reason): + skipped.append(ObjectCopySkip(selection: selection, reason: reason)) continue + case .runnable(let runnable): + definition = runnable } let existing = targetObjects[Self.objectKey(for: selection)] /// Add rows promises the target's structure is kept, and these objects hold no rows at @@ -823,12 +821,11 @@ internal struct ObjectCopyPlanner { let builder = SourceObjectSyncBuilder(targetDriver: plugin, targetDatabaseType: driver.connection.type) var statements: [String: (drop: [SyncStatement], create: [SyncStatement])] = [:] for input in inputs { - let drop = input.drop.map { existing in - builder.replacesInPlace(existing.identity, with: input.create) - ? [] - : builder.build(for: existing, action: .drop) + let drop = try input.drop.map { existing -> [SyncStatement] in + guard !builder.replacesInPlace(existing.identity, with: input.create) else { return [] } + return try builder.build(for: existing, action: .drop) } ?? [] - let create = builder.build(for: input.create, action: .create) + let create = try builder.build(for: input.create, action: .create) statements[input.id] = (drop, create) } return statements @@ -854,55 +851,29 @@ internal struct ObjectCopyPlanner { selections: [ObjectCopySelection], sourceReads: [TableStructureRead], connection: DatabaseConnection - ) async throws -> [String: String] { - var definitions: [String: String] = [:] - - let views = selections.filter { $0.kind == .view || $0.kind == .materializedView } - if !views.isEmpty { - let infos = sourceReads.map(\.table).filter { info in - views.contains { $0.name.lowercased() == info.name.lowercased() } - } - for read in try await metadata.viewDefinitions( - for: sourceEndpoint, connection: connection, views: infos - ) { - guard let selection = views.first(where: { $0.name.lowercased() == read.name.lowercased() }) - else { continue } - definitions[selection.id] = read.source + ) async throws -> [String: RoutineSourceRead] { + let views = sourceReads.map(\.table).filter { info in + selections.contains { selection in + (selection.kind == .view || selection.kind == .materializedView) + && selection.name.lowercased() == info.name.lowercased() } } - - /// Matched on the argument signature as well as the name, because `f(integer)` and - /// `f(text)` are two routines and copying one must not carry the other's body. - let routines = selections.filter { $0.kind == .procedure || $0.kind == .function } - if !routines.isEmpty { - for read in try await metadata.routineReads(for: sourceEndpoint, connection: connection) { - guard let selection = routines.first(where: { - $0.kind == read.kind - && $0.name.lowercased() == read.name.lowercased() - && ($0.signature ?? "") == (read.signature ?? "") - }) else { continue } - definitions[selection.id] = read.source - } - } - - /// Asked of the tables the selected triggers name, not of the tables the user happened to - /// select. Deriving the lookup from the table selection meant a trigger chosen on its own - /// had no table to be found under and was always reported as having no definition. - let triggers = selections.filter { $0.kind == .trigger } - if !triggers.isEmpty { - let owners = Set(triggers.compactMap(\.owner)).union(sourceReads.map(\.table.name)) - for read in try await metadata.triggerReads( - for: sourceEndpoint, connection: connection, tables: Array(owners) - ) { - guard let selection = triggers.first(where: { - $0.name.lowercased() == read.name.lowercased() - && ($0.owner.map { $0.lowercased() == (read.signature ?? "").lowercased() } ?? true) - }) - else { continue } - definitions[selection.id] = read.source - } + let triggerTables = Set(selections.filter { $0.kind == .trigger }.compactMap(\.owner)) + .union(sourceReads.map(\.table.name)) + let schema = sourceEndpoint.schema + let endpointName = sourceEndpoint.qualifiedDescription + try await manager.ensureConnected(connection) + return try await manager.withMetadataDriver(scope: sourceEndpoint.scope) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [:] } + return try await Self.sourceDefinitionReads( + for: selections, + views: views, + triggerTables: Array(triggerTables), + schema: schema, + endpointName: endpointName, + using: plugin + ) } - return definitions } // MARK: - Ordering @@ -996,7 +967,7 @@ internal struct ObjectCopyPlanner { localized: "The target has it, but its structure could not be read." ) nonisolated private static let alreadyThere = String(localized: "Already in the target.") - nonisolated private static let noDefinition = String( + nonisolated internal static let noDefinition = String( localized: "The source reports no definition for it." ) nonisolated private static let structureOnlyObject = String( diff --git a/TablePro/Core/Utilities/SQL/SQLScriptText.swift b/TablePro/Core/Utilities/SQL/SQLScriptText.swift index c4c0379b53..61f468ad0e 100644 --- a/TablePro/Core/Utilities/SQL/SQLScriptText.swift +++ b/TablePro/Core/Utilities/SQL/SQLScriptText.swift @@ -59,6 +59,25 @@ internal struct SQLScriptText { return sendableStatements(definition).joined(separator: ";\n") } + internal func leadingKeyword(of statement: String) -> String? { + let text = statement as NSString + let length = text.length + var index = 0 + while index < length { + let blank = StatementBlank.blankLength(in: text, at: index) + guard blank == 0 else { + index += blank + continue + } + guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: grammar), span.kind.isComment else { + let head = text.substring(with: NSRange(location: index, length: min(length - index, 64))) + return String(head.prefix { $0.isLetter }).uppercased() + } + index = max(span.end, index + 1) + } + return nil + } + /// Every statement in `statements`, which are sendable texts, written as one script for this engine's client. internal func script(_ statements: [String]) -> String { let ended = statements diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f1333d03f5..375a17b054 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -34,6 +34,9 @@ } } } + }, + "%1$@ could not be read again before generating the script. %2$@" : { + }, "A sequence" : { "localizations" : { @@ -6353,6 +6356,9 @@ } } } + }, + "%@ cannot be scripted, because its definition is not a statement that recreates it." : { + }, "%@ does not allow NULL, so its default cannot be NULL" : { @@ -150589,6 +150595,21 @@ } } } + }, + "The procedures and functions in %1$@ could not be listed: %2$@" : { + + }, + "The source returned a body rather than a statement that recreates it." : { + + }, + "The source returned an empty definition." : { + + }, + "The target returned a body rather than a statement that recreates it." : { + + }, + "The target's definition could not be read: %@" : { + }, "switch %@" : { "extractionState" : "stale", @@ -154126,6 +154147,9 @@ }, "Table Type" : { + }, + "The source's definition could not be read: %@" : { + }, "table_name" : { "localizations" : { @@ -158564,40 +158588,6 @@ } } }, - "The driver did not return this object's definition, so only its name was compared." : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "드라이버가 이 객체의 정의를 반환하지 않아 이름만 비교했습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sürücü bu nesnenin tanımını döndürmedi, bu yüzden yalnızca adı karşılaştırıldı." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Driver không trả về định nghĩa của đối tượng này, nên chỉ tên của nó được so sánh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "驱动未返回此对象的定义,因此只比较了它的名称。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "驅動程式未傳回此物件的定義,因此只比較了它的名稱。" - } - } - } - }, "The dump is replayed into this database. Objects it names are overwritten and the change cannot be undone." : { }, @@ -162092,6 +162082,9 @@ }, "The target keeps what it has and the object is left out." : { + }, + "The target returned an empty definition." : { + }, "The target's object is dropped and built again from the source." : { @@ -162336,6 +162329,9 @@ } } } + }, + "The triggers on %1$@ in %2$@ could not be listed: %3$@" : { + }, "the tunnel command" : { diff --git a/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift b/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift new file mode 100644 index 0000000000..f11a3fd738 --- /dev/null +++ b/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift @@ -0,0 +1,200 @@ +// +// CompareSourceDefinitionReadTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class CompareSourceDefinitionReadTests: XCTestCase { + private let endpointName = "Reporting / shop" + + private func trigger( + _ name: String, + table: String = "orders", + statement: String = "SET NEW.n = 1", + definition: String? = nil + ) -> PluginTriggerInfo { + PluginTriggerInfo( + name: name, table: table, schema: "shop", timing: "BEFORE", event: "INSERT", + statement: statement, definition: definition + ) + } + + // MARK: - Views + + func testAViewTheDriverRefusesCarriesTheDriversReason() async throws { + let driver = SourceDefinitionStubDriver() + let refusal = PluginObjectSourceError.insufficientPrivilege("recent") + driver.viewDefinitions = ["recent": .failure(refusal), "totals": .failure(refusal)] + + let reads = try await CompareMetadataService.readViewDefinitions( + [ + PluginTableInfo(name: "recent", type: "VIEW", schema: "shop", comment: nil), + PluginTableInfo(name: "totals", type: "MATERIALIZED VIEW", schema: "shop", comment: nil) + ], + schema: "shop", + using: driver + ) + + XCTAssertEqual(reads.map(\.failure), [refusal.errorDescription, refusal.errorDescription]) + XCTAssertEqual(reads.map(\.source), ["", ""]) + XCTAssertEqual(reads.map(\.kind), [.view, .materializedView]) + } + + func testAReadableViewKeepsItsDefinition() async throws { + let driver = SourceDefinitionStubDriver() + driver.viewDefinitions = ["recent": .success("CREATE VIEW recent AS SELECT 1")] + + let reads = try await CompareMetadataService.readViewDefinitions( + [PluginTableInfo(name: "recent", type: "VIEW", schema: "shop", comment: nil)], + schema: "shop", + using: driver + ) + + XCTAssertNil(reads.first?.failure) + XCTAssertEqual(reads.first?.source, "CREATE VIEW recent AS SELECT 1") + } + + func testACancelledViewReadIsNotAFailure() async { + let driver = SourceDefinitionStubDriver() + driver.viewDefinitions = ["recent": .failure(CancellationError())] + + do { + _ = try await CompareMetadataService.readViewDefinitions( + [PluginTableInfo(name: "recent", type: "VIEW", schema: "shop", comment: nil)], + schema: "shop", + using: driver + ) + XCTFail("a cancelled read must not become an unreadable object") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + } + + // MARK: - Routines + + func testARoutineIsReadThroughItsDDLEvenWhenTheListingCarriesABody() async throws { + let driver = SourceDefinitionStubDriver() + driver.routines = .success([ + PluginRoutineInfo(name: "add", kind: .function, schema: "main", argumentSignature: "(a, b)", definition: "(a + b)") + ]) + driver.routineDDL = ["add": .success("CREATE OR REPLACE MACRO main.add(a, b) AS (a + b);")] + + let reads = try await CompareMetadataService.readRoutineDefinitions( + schema: "main", endpointName: endpointName, using: driver + ) + + XCTAssertEqual(reads.map(\.source), ["CREATE OR REPLACE MACRO main.add(a, b) AS (a + b);"]) + XCTAssertTrue(driver.recordedCalls.contains("fetchRoutineDDL:add")) + } + + func testARoutineWhoseDDLCannotBeReadCarriesTheReason() async throws { + let driver = SourceDefinitionStubDriver() + let refusal = PluginObjectSourceError.insufficientPrivilege("p") + driver.routines = .success([PluginRoutineInfo(name: "p", kind: .procedure, schema: "shop")]) + driver.routineDDL = ["p": .failure(refusal)] + + let reads = try await CompareMetadataService.readRoutineDefinitions( + schema: "shop", endpointName: endpointName, using: driver + ) + + XCTAssertEqual(reads.count, 1) + XCTAssertEqual(reads.first?.kind, .procedure) + XCTAssertEqual(reads.first?.failure, refusal.errorDescription) + XCTAssertEqual(reads.first?.source, "") + } + + func testARoutineListingThatFailsStopsTheRead() async { + let driver = SourceDefinitionStubDriver() + driver.routines = .failure(DefinitionReadStubError(message: "Access denied to information_schema")) + + do { + _ = try await CompareMetadataService.readRoutineDefinitions( + schema: "shop", endpointName: endpointName, using: driver + ) + XCTFail("an unlisted scope must not read as a scope with no routines") + } catch { + guard case CompareSyncError.readFailed(let message) = error else { + return XCTFail("expected readFailed, got \(error)") + } + XCTAssertTrue(message.contains(endpointName), message) + XCTAssertTrue(message.contains("Access denied to information_schema"), message) + } + } + + // MARK: - Triggers + + func testATriggerWithoutADefinitionIsReadThroughItsDDLAndNeverItsStatement() async throws { + let driver = SourceDefinitionStubDriver() + driver.wholeSchemaTriggers = .success([ + trigger("stamped"), + trigger("hidden") + ]) + driver.triggerDDL = [ + "stamped": .success("CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1"), + "hidden": .failure(PluginObjectSourceError.insufficientPrivilege("hidden")) + ] + + let reads = try await CompareMetadataService.readTriggerDefinitions( + tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver + ) + + XCTAssertEqual(reads.map(\.name), ["stamped", "hidden"]) + XCTAssertEqual(reads[0].source, "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1") + XCTAssertNil(reads[0].failure) + XCTAssertEqual(reads[1].source, "") + XCTAssertEqual(reads[1].failure, PluginObjectSourceError.insufficientPrivilege("hidden").errorDescription) + XCTAssertEqual(reads.map(\.signature), ["orders", "orders"]) + } + + func testATriggerWithADefinitionIsNotReadAgain() async throws { + let driver = SourceDefinitionStubDriver() + let definition = "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1" + driver.wholeSchemaTriggers = .success([trigger("stamped", definition: definition)]) + + let reads = try await CompareMetadataService.readTriggerDefinitions( + tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver + ) + + XCTAssertEqual(reads.map(\.source), [definition]) + XCTAssertFalse(driver.recordedCalls.contains("fetchTriggerDDL:stamped")) + } + + func testAPerTableTriggerListingThatFailsStopsTheRead() async { + let driver = SourceDefinitionStubDriver() + driver.tableTriggers = [ + "customers": .success([]), + "orders": .failure(DefinitionReadStubError(message: "TRIGGER command denied")) + ] + + do { + _ = try await CompareMetadataService.readTriggerDefinitions( + tables: ["customers", "orders"], schema: "shop", endpointName: endpointName, using: driver + ) + XCTFail("a table whose triggers could not be listed must not read as a table with none") + } catch { + guard case CompareSyncError.readFailed(let message) = error else { + return XCTFail("expected readFailed, got \(error)") + } + XCTAssertTrue(message.contains("orders"), message) + XCTAssertTrue(message.contains(endpointName), message) + XCTAssertTrue(message.contains("TRIGGER command denied"), message) + } + } + + func testAFailedWholeSchemaTriggerReadFallsBackPerTable() async throws { + let driver = SourceDefinitionStubDriver() + let definition = "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1" + driver.wholeSchemaTriggers = .failure(DefinitionReadStubError(message: "bulk read refused")) + driver.tableTriggers = ["orders": .success([trigger("stamped", definition: definition)])] + + let reads = try await CompareMetadataService.readTriggerDefinitions( + tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver + ) + + XCTAssertEqual(reads.map(\.source), [definition]) + XCTAssertTrue(driver.recordedCalls.contains("fetchTriggers:orders")) + } +} diff --git a/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift index 8f8417e432..ec9f85eb74 100644 --- a/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift +++ b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift @@ -17,9 +17,12 @@ final class SourceObjectDiffEngineTests: XCTestCase { kind: CompareObjectKind = .function, schema: String? = "public", signature: String? = nil, - source: String + source: String, + failure: String? = nil ) -> RoutineSourceRead { - RoutineSourceRead(name: name, kind: kind, schema: schema, signature: signature, source: source) + RoutineSourceRead( + name: name, kind: kind, schema: schema, signature: signature, source: source, failure: failure + ) } private func engine( @@ -45,7 +48,7 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testAnObjectOnlyOnTheSourceIsCreated() { let results = engine().compare( - source: [read("audit", source: "BEGIN END")], + source: [read("audit", source: "CREATE FUNCTION audit() BEGIN END")], target: [] ) @@ -57,7 +60,7 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testAnObjectOnlyOnTheTargetIsDropped() { let results = engine().compare( source: [], - target: [read("stale", source: "BEGIN END")] + target: [read("stale", source: "CREATE FUNCTION stale() BEGIN END")] ) XCTAssertEqual(results[0].status, .onlyInTarget) @@ -66,8 +69,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testAMatchingDefinitionIsIdentical() { let results = engine().compare( - source: [read("audit", source: "BEGIN\n SELECT 1;\nEND")], - target: [read("audit", source: "BEGIN\n SELECT 1;\nEND")] + source: [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")], + target: [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")] ) XCTAssertEqual(results[0].status, .identical) @@ -76,8 +79,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testADifferentDefinitionIsAlter() { let results = engine().compare( - source: [read("audit", source: "BEGIN SELECT 1; END")], - target: [read("audit", source: "BEGIN SELECT 2; END")] + source: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")], + target: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 2; END")] ) XCTAssertEqual(results[0].status, .differs) @@ -90,8 +93,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testTrailingSemicolonsAndLineEndingsAreNotADifference() { let results = engine().compare( - source: [read("audit", source: "BEGIN SELECT 1; END;")], - target: [read("audit", source: "BEGIN SELECT 1; END\r\n")] + source: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END;")], + target: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END\r\n")] ) XCTAssertEqual(results[0].status, .identical) @@ -133,8 +136,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { } func testWhitespaceIsADifferenceUntilItIsIgnored() { - let source = [read("audit", source: "BEGIN\n SELECT 1;\nEND")] - let target = [read("audit", source: "BEGIN SELECT 1; END")] + let source = [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")] + let target = [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")] XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) @@ -144,8 +147,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { } func testIdentifierCaseIsADifferenceUntilItIsIgnored() { - let source = [read("audit", source: "BEGIN SELECT 1; END")] - let target = [read("audit", source: "begin select 1; end")] + let source = [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")] + let target = [read("audit", source: "create function audit() begin select 1; end")] XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) @@ -161,11 +164,11 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testTwoOverloadsOfOneNameAreMatchedBySignature() { let results = engine().compare( source: [ - read("area", signature: "(integer)", source: "SELECT 1"), - read("area", signature: "(geometry)", source: "SELECT 2") + read("area", signature: "(integer)", source: "CREATE FUNCTION area(integer) BEGIN SELECT 1; END"), + read("area", signature: "(geometry)", source: "CREATE FUNCTION area(geometry) BEGIN SELECT 2; END") ], target: [ - read("area", signature: "(geometry)", source: "SELECT 2") + read("area", signature: "(geometry)", source: "CREATE FUNCTION area(geometry) BEGIN SELECT 2; END") ] ) @@ -176,8 +179,8 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testTwoKindsSharingOneNameAreNotMatched() { let results = engine().compare( - source: [read("audit", kind: .function, source: "SELECT 1")], - target: [read("audit", kind: .procedure, source: "SELECT 1")] + source: [read("audit", kind: .function, source: "CREATE FUNCTION audit() BEGIN END")], + target: [read("audit", kind: .procedure, source: "CREATE PROCEDURE audit() BEGIN END")] ) XCTAssertEqual(results.count, 2) @@ -186,24 +189,141 @@ final class SourceObjectDiffEngineTests: XCTestCase { func testTwoSchemasSharingOneNameAreNotMatched() { let results = engine().compare( - source: [read("audit", schema: "public", source: "SELECT 1")], - target: [read("audit", schema: "sales", source: "SELECT 1")] + source: [read("audit", schema: "public", source: "CREATE FUNCTION audit() BEGIN END")], + target: [read("audit", schema: "sales", source: "CREATE FUNCTION audit() BEGIN END")] ) XCTAssertEqual(results.count, 2) } - // MARK: - Missing definitions + // MARK: - Unreadable definitions + + func testASourceWhoseReadFailedIsNotComparedAgainstAReadableTarget() { + let denied = "SHOW VIEW command denied to user 'reader'@'%' for table 'v'" + let results = engine(databaseType: .mysql).compare( + source: [read("v", kind: .view, schema: nil, source: "", failure: denied)], + target: [read("v", kind: .view, schema: nil, source: "CREATE VIEW v AS SELECT 1")] + ) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable(denied).reason(on: .source)) + XCTAssertEqual(results[0].suggestedAction, .skip) + XCTAssertEqual(results[0].availableActions, [.skip]) + XCTAssertEqual(results[0].sourceDefinition, []) + XCTAssertEqual(results[0].targetDefinition, ["CREATE VIEW v AS SELECT 1"]) + } + + func testTwoFailedReadsOfOneObjectAreNotIdentical() { + let results = engine().compare( + source: [read("v", kind: .view, source: "", failure: "denied")], + target: [read("v", kind: .view, source: "", failure: "denied")] + ) + + XCTAssertEqual(results.count, 1) + XCTAssertNotEqual(results[0].status, .identical) + XCTAssertFalse(results[0].isComparable) + } + + func testAnUnreadableObjectOnlyOnTheSourceIsNeverCreated() { + let results = engine().compare( + source: [read("audit", source: "", failure: "permission denied")], + target: [] + ) + + XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable("permission denied").reason(on: .source)) + XCTAssertEqual(results[0].suggestedAction, .skip) + XCTAssertEqual(results[0].availableActions, [.skip]) + } - /// A driver that lists a routine but cannot return its body must not report it as identical to - /// another routine whose body is also empty. - func testAnObjectWithNoDefinitionCarriesANote() { + func testAnUnreadableObjectOnlyOnTheTargetNamesTheTargetAndIsNeverDropped() { let results = engine().compare( - source: [read("audit", source: "")], + source: [], + target: [read("stale", source: "", failure: "permission denied")] + ) + + XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable("permission denied").reason(on: .target)) + XCTAssertEqual(results[0].availableActions, [.skip]) + } + + func testADefinitionWithNothingToRunIsUnreadable() { + for blank in ["", " \n\t", "-- nothing here", "/* nothing */", "# nothing"] { + let results = engine(databaseType: .mysql).compare( + source: [read("audit", source: blank)], + target: [read("audit", source: "CREATE FUNCTION audit() RETURNS INT RETURN 1")] + ) + + XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.empty.reason(on: .source), blank) + XCTAssertEqual(results[0].availableActions, [.skip], blank) + } + } + + func testABodyThatIsNotACreateStatementIsUnreadable() { + for body in ["(a + b)", "SELECT 1 AS x"] { + let results = engine(databaseType: .duckdb).compare( + source: [read("add", source: body)], + target: [] + ) + + XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.notACreateStatement.reason(on: .source), body) + XCTAssertEqual(results[0].availableActions, [.skip], body) + XCTAssertEqual(results[0].sourceDefinition, [body], body) + } + } + + func testALeadingCommentDoesNotHideTheCreate() { + let definition = "-- Author: ops\n/* audit */\nCREATE PROCEDURE dbo.audit AS SET NOCOUNT ON; SELECT 1;" + + let results = engine(databaseType: .mssql).compare( + source: [read("audit", schema: "dbo", source: definition)], + target: [read("audit", schema: "dbo", source: definition)] + ) + + XCTAssertNil(results[0].comparisonError) + XCTAssertEqual(results[0].status, .identical) + } + + func testEachSideIsReadInItsOwnEnginesGrammar() { + let mysqlDefinition = "# reporting view\nCREATE VIEW v AS SELECT 1" + let postgresDefinition = "CREATE VIEW v AS SELECT 1" + + let fromMySQL = SourceObjectDiffEngine(sourceDatabaseType: .mysql, targetDatabaseType: .postgresql).compare( + source: [read("v", kind: .view, schema: nil, source: mysqlDefinition)], + target: [read("v", kind: .view, schema: nil, source: postgresDefinition)] + ) + let intoMySQL = SourceObjectDiffEngine(sourceDatabaseType: .postgresql, targetDatabaseType: .mysql).compare( + source: [read("v", kind: .view, schema: nil, source: postgresDefinition)], + target: [read("v", kind: .view, schema: nil, source: mysqlDefinition)] + ) + + XCTAssertNil(fromMySQL[0].comparisonError) + XCTAssertNil(intoMySQL[0].comparisonError) + } + + func testASQLServerModuleStoredAfterAlterOrCreateOrAlterIsReadable() { + let altered = "/* comment */ CREATE PROCEDURE Test1A AS SELECT 3;" + let createdOrAltered = "CrEaTe /*Y*/ PROCEDURE Test1B AS SELECT 2;" + + let results = engine(databaseType: .mssql).compare( + source: [ + read("Test1A", kind: .procedure, schema: "dbo", source: altered), + read("Test1B", kind: .procedure, schema: "dbo", source: createdOrAltered) + ], target: [] ) - XCTAssertFalse(results[0].notes.isEmpty) + XCTAssertEqual(results.map(\.comparisonError), [nil, nil]) + XCTAssertEqual(results.map(\.suggestedAction), [.create, .create]) + } + + func testAnUnreadableObjectIsNeitherADifferenceNorSelectable() { + let report = CompareReport(results: engine().compare( + source: [read("v", kind: .view, source: "", failure: "denied")], + target: [read("v", kind: .view, source: "CREATE VIEW v AS SELECT 1")] + )) + + XCTAssertEqual(report.uncomparable.count, 1) + XCTAssertTrue(report.comparable.isEmpty) + XCTAssertEqual(report.differenceCount, 0) } private func strict() -> StructureCompareOptions { diff --git a/TableProTests/Core/Compare/SourceObjectSyncBuilderTests.swift b/TableProTests/Core/Compare/SourceObjectSyncBuilderTests.swift index 9152876545..b5473ff4cd 100644 --- a/TableProTests/Core/Compare/SourceObjectSyncBuilderTests.swift +++ b/TableProTests/Core/Compare/SourceObjectSyncBuilderTests.swift @@ -72,8 +72,8 @@ final class SourceObjectSyncBuilderTests: XCTestCase { private func drop( _ identity: CompareObjectIdentity, driver: any PluginDatabaseDriver - ) -> String? { - SourceObjectSyncBuilder(targetDriver: driver, targetDatabaseType: .postgresql) + ) throws -> String? { + try SourceObjectSyncBuilder(targetDriver: driver, targetDatabaseType: .postgresql) .build(for: CompareObjectResult(identity: identity, status: .onlyInTarget), action: .drop) .first?.sql } @@ -81,7 +81,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// Two overloads are two routines, and a drop that names only `f` is refused as ambiguous. func testARoutineDropCarriesItsArgumentListWhereTheEngineNeedsOne() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity( kind: .function, schema: "public", name: "total", signature: "(integer)" ), @@ -93,7 +93,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { func testAProcedureDropUsesTheProcedureKeyword() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity( kind: .procedure, schema: "public", name: "rebuild", signature: "()" ), @@ -106,7 +106,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// The owning table travels in the signature slot, which is what lets the driver write the `ON`. func testATriggerDropNamesTheTableThatOwnsIt() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity( kind: .trigger, schema: "public", name: "audit", signature: "orders" ), @@ -119,7 +119,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// Nothing to hang the `ON` off, so the bare qualified name is all that can be written. func testATriggerWithNoOwnerFallsBackToTheQualifiedName() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity(kind: .trigger, schema: "public", name: "audit"), driver: DialectDropDriver() ), @@ -130,7 +130,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// An engine that rejects the argument list keeps the plain drop it has always had. func testAnEngineWithoutADialectDropKeepsTheQualifiedName() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity( kind: .function, schema: "shop", name: "total", signature: "(integer)" ), @@ -146,8 +146,8 @@ final class SourceObjectSyncBuilderTests: XCTestCase { _ definition: String, kind: CompareObjectKind, databaseType: DatabaseType - ) -> [String] { - SourceObjectSyncBuilder(targetDriver: PlainDropDriver(), targetDatabaseType: databaseType) + ) throws -> [String] { + try SourceObjectSyncBuilder(targetDriver: PlainDropDriver(), targetDatabaseType: databaseType) .build( for: CompareObjectResult( identity: CompareObjectIdentity(kind: kind, schema: "APP", name: "x"), @@ -162,7 +162,7 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// Measured on Oracle 23ai: sent with a `;` after the call, the trigger is stored INVALID. func testAnOracleCallTriggerGoesOutWithoutASemicolon() { XCTAssertEqual( - create( + try create( "CREATE OR REPLACE TRIGGER x BEFORE INSERT ON t FOR EACH ROW\nCALL p(:NEW.id);", kind: .trigger, databaseType: .oracle @@ -174,18 +174,18 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// And a procedure sent without its own `;` is stored INVALID the same way. func testAnOracleUnitKeepsItsOwnSemicolon() { let unit = "CREATE OR REPLACE PROCEDURE x IS\nBEGIN\n NULL;\nEND;" - XCTAssertEqual(create(unit, kind: .procedure, databaseType: .oracle), [unit]) + XCTAssertEqual(try create(unit, kind: .procedure, databaseType: .oracle), [unit]) } /// The generic grammar would cut a T-SQL body with no BEGIN into pieces the server rejects. func testAnUntrackedEngineSendsTheDefinitionWhole() { let body = "CREATE PROCEDURE dbo.x AS SET NOCOUNT ON; SELECT 1; SELECT 2;" - XCTAssertEqual(create(body, kind: .procedure, databaseType: .mssql), [body]) + XCTAssertEqual(try create(body, kind: .procedure, databaseType: .mssql), [body]) } func testAMySQLRoutineIsOneStatementWithoutItsSeparator() { XCTAssertEqual( - create("CREATE PROCEDURE x()\nBEGIN\n SELECT 1;\nEND;", kind: .procedure, databaseType: .mysql), + try create("CREATE PROCEDURE x()\nBEGIN\n SELECT 1;\nEND;", kind: .procedure, databaseType: .mysql), ["CREATE PROCEDURE x()\nBEGIN\n SELECT 1;\nEND"] ) } @@ -196,8 +196,8 @@ final class SourceObjectSyncBuilderTests: XCTestCase { _ definition: String, kind: CompareObjectKind = .trigger, driver: any PluginDatabaseDriver - ) -> [SyncStatement] { - SourceObjectSyncBuilder(targetDriver: driver, targetDatabaseType: .oracle).build( + ) throws -> [SyncStatement] { + try SourceObjectSyncBuilder(targetDriver: driver, targetDatabaseType: .oracle).build( for: CompareObjectResult( identity: CompareObjectIdentity(kind: kind, schema: "APP", name: "x", signature: "t"), status: .differs, @@ -209,25 +209,25 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// Measured on Oracle 23ai: a DROP followed by a CREATE the engine refused left no trigger, while /// the same CREATE OR REPLACE refused on its own left the existing one VALID. - func testADefinitionThatReplacesItselfIsNotDroppedFirst() { + func testADefinitionThatReplacesItselfIsNotDroppedFirst() throws { let definition = "CREATE OR REPLACE TRIGGER x BEFORE INSERT ON t FOR EACH ROW\nBEGIN NULL; END;" - let statements = replace(definition, driver: InPlaceReplacingDriver()) + let statements = try replace(definition, driver: InPlaceReplacingDriver()) XCTAssertEqual(statements.map(\.sql), [definition]) XCTAssertEqual(statements.first?.summary.hasPrefix("Replace trigger"), true) } - func testAReplacementIsDroppedFirstWhereTheDriverCannotReplaceInPlace() { + func testAReplacementIsDroppedFirstWhereTheDriverCannotReplaceInPlace() throws { let definition = "CREATE OR REPLACE TRIGGER x BEFORE INSERT ON t FOR EACH ROW\nBEGIN NULL; END;" - XCTAssertEqual(replace(definition, driver: PlainDropDriver()).map(\.sql), [ + XCTAssertEqual(try replace(definition, driver: PlainDropDriver()).map(\.sql), [ "DROP TRIGGER \"APP\".\"x\"", definition, ]) } - func testADefinitionWithoutOrReplaceIsDroppedFirst() { - let statements = replace( + func testADefinitionWithoutOrReplaceIsDroppedFirst() throws { + let statements = try replace( "CREATE TRIGGER x BEFORE INSERT ON t FOR EACH ROW\nBEGIN NULL; END;", driver: InPlaceReplacingDriver() ) @@ -235,8 +235,8 @@ final class SourceObjectSyncBuilderTests: XCTestCase { XCTAssertTrue(statements[0].sql.hasPrefix("DROP TRIGGER")) } - func testAMaterializedViewIsAlwaysDroppedFirst() { - let statements = replace( + func testAMaterializedViewIsAlwaysDroppedFirst() throws { + let statements = try replace( "CREATE OR REPLACE MATERIALIZED VIEW x AS SELECT 1 FROM dual", kind: .materializedView, driver: InPlaceReplacingDriver() @@ -248,11 +248,51 @@ final class SourceObjectSyncBuilderTests: XCTestCase { /// A view is addressed by name on every engine, so it must not be routed through either hook. func testAViewDropIsUnchanged() { XCTAssertEqual( - drop( + try drop( CompareObjectIdentity(kind: .view, schema: "public", name: "recent"), driver: DialectDropDriver() ), "DROP VIEW \"public\".\"recent\"" ) } + + // MARK: - A definition that cannot recreate the object + + func testAReplacementWithNoDefinitionIsRefusedRatherThanScriptedAsADropAlone() { + let result = CompareObjectResult( + identity: CompareObjectIdentity(kind: .view, schema: "shop", name: "recent"), + status: .differs, + sourceDefinition: [""] + ) + let builder = SourceObjectSyncBuilder(targetDriver: PlainDropDriver(), targetDatabaseType: .mysql) + + XCTAssertThrowsError(try builder.build(for: result, action: .alter)) { error in + XCTAssertTrue(error.localizedDescription.contains("shop.recent")) + } + XCTAssertThrowsError(try builder.build(for: result, action: .create)) + } + + func testAReplacementWhoseDefinitionIsABodyIsRefused() { + let result = CompareObjectResult( + identity: CompareObjectIdentity(kind: .function, schema: "main", name: "add", signature: "(a, b)"), + status: .differs, + sourceDefinition: ["SELECT 1 AS x"] + ) + let builder = SourceObjectSyncBuilder(targetDriver: PlainDropDriver(), targetDatabaseType: .duckdb) + + XCTAssertThrowsError(try builder.build(for: result, action: .alter)) + XCTAssertThrowsError(try builder.build(for: result, action: .create)) + } + + func testADropNeedsNoDefinition() throws { + let result = CompareObjectResult( + identity: CompareObjectIdentity(kind: .view, schema: "shop", name: "recent"), + status: .onlyInTarget + ) + + let statements = try SourceObjectSyncBuilder(targetDriver: PlainDropDriver(), targetDatabaseType: .mysql) + .build(for: result, action: .drop) + + XCTAssertEqual(statements.map(\.sql), ["DROP VIEW \"shop\".\"recent\""]) + } } diff --git a/TableProTests/Core/Compare/StructureChangeGuardTests.swift b/TableProTests/Core/Compare/StructureChangeGuardTests.swift index f8cadb5f86..6c04e6afe3 100644 --- a/TableProTests/Core/Compare/StructureChangeGuardTests.swift +++ b/TableProTests/Core/Compare/StructureChangeGuardTests.swift @@ -475,4 +475,33 @@ final class StructureChangeGuardTests: XCTestCase { "the message must name what changed, got \(message ?? "nil")" ) } + + func testAnObjectThatCouldNotBeReadAgainIsRefusedWithItsReason() throws { + let result = view(definition: ["CREATE VIEW recent_orders AS SELECT 1"]) + let reason = SourceDefinitionDefect.unreadable("SHOW VIEW command denied").reason(on: .source) + + let refusal = StructureChangeGuard.refusal( + expected: inputs([result], action: .create), + actual: [:], + unreadable: [result.id: reason] + ) + + let message = try XCTUnwrap(refusal?.errorDescription) + XCTAssertTrue(message.contains("shop.recent_orders"), message) + XCTAssertTrue(message.contains(reason), message) + XCTAssertFalse(message.contains("changed after it was compared"), message) + } + + func testAnUnreadableObjectOutsideTheSelectionDoesNotRefuse() { + let selected = view(definition: ["CREATE VIEW recent_orders AS SELECT 1"]) + let expected = inputs([selected], action: .create) + + XCTAssertNil( + StructureChangeGuard.refusal( + expected: expected, + actual: expected, + unreadable: ["view|shop|other|": "denied"] + ) + ) + } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index cf6473488d..d09c1096b6 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -229,9 +229,14 @@ final class ObjectCopyEligibilityTests: XCTestCase { /// CREATE. Running that is a read the runner would report as the view copied, after Replace /// had already dropped the target's. func testABareBodyIsNotAnExecutableDefinition() { - XCTAssertTrue(ObjectCopyEligibility.isExecutableDefinition("CREATE VIEW v AS SELECT 1")) - XCTAssertTrue(ObjectCopyEligibility.isExecutableDefinition("\n create or replace view v AS SELECT 1")) - XCTAssertFalse(ObjectCopyEligibility.isExecutableDefinition("SELECT id, name FROM orders")) - XCTAssertFalse(ObjectCopyEligibility.isExecutableDefinition(" ")) + let postgres = SQLScriptText(databaseType: .postgresql) + + XCTAssertNil(SourceDefinitionDefect.of(definition: "CREATE VIEW v AS SELECT 1", sentAs: postgres)) + XCTAssertNil(SourceDefinitionDefect.of(definition: "\n create or replace view v AS SELECT 1", sentAs: postgres)) + XCTAssertEqual( + SourceDefinitionDefect.of(definition: "SELECT id, name FROM orders", sentAs: postgres), + .notACreateStatement + ) + XCTAssertEqual(SourceDefinitionDefect.of(definition: " ", sentAs: postgres), .empty) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerSourceDefinitionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerSourceDefinitionTests.swift new file mode 100644 index 0000000000..3c640e8e52 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerSourceDefinitionTests.swift @@ -0,0 +1,148 @@ +// +// ObjectCopyPlannerSourceDefinitionTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class ObjectCopyPlannerSourceDefinitionTests: XCTestCase { + private let postgres = SQLScriptText(databaseType: .postgresql) + + private func read(_ source: String, failure: String? = nil) -> RoutineSourceRead { + RoutineSourceRead(name: "recent", kind: .view, schema: "public", signature: nil, source: source, failure: failure) + } + + private let view = ObjectCopySelection(kind: .view, name: "recent", schema: "public") + private let function = ObjectCopySelection(kind: .function, name: "total", schema: "public", signature: "(integer)") + private let trigger = ObjectCopySelection(kind: .trigger, name: "stamped", schema: "public", owner: "orders") + + // MARK: - Outcome + + func testAnUnreadableDefinitionIsSkippedWithTheDriversReason() { + let reason = PluginObjectSourceError.insufficientPrivilege("recent").errorDescription ?? "" + + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(read("", failure: reason), sentAs: postgres), + .skipped(reason) + ) + } + + func testAnEmptyOrMissingDefinitionIsSkippedAsHavingNone() { + XCTAssertEqual(ObjectCopyPlanner.definitionOutcome(nil, sentAs: postgres), .skipped(ObjectCopyPlanner.noDefinition)) + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(read(" \n "), sentAs: postgres), + .skipped(ObjectCopyPlanner.noDefinition) + ) + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(read("-- nothing"), sentAs: postgres), + .skipped(ObjectCopyPlanner.noDefinition) + ) + } + + func testABareBodyIsSkippedAsNotExecutable() { + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(read("SELECT id, name FROM orders"), sentAs: postgres), + .skipped(ObjectCopyEligibility.definitionNotExecutableRefusal) + ) + } + + func testACreateStatementIsRunnableBehindALeadingComment() { + let lowercase = "\n create or replace view recent AS SELECT 1" + let commented = "-- Author: ops\n/* audit */\nCREATE VIEW dbo.recent AS SELECT 1" + + XCTAssertEqual(ObjectCopyPlanner.definitionOutcome(read(lowercase), sentAs: postgres), .runnable(lowercase)) + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(read(commented), sentAs: SQLScriptText(databaseType: .mssql)), + .runnable(commented) + ) + } + + // MARK: - Reads + + func testAFailedRoutineListingSkipsTheRoutinesAndKeepsTheRest() async throws { + let driver = SourceDefinitionStubDriver() + driver.viewDefinitions = ["recent": .success("CREATE VIEW recent AS SELECT 1")] + driver.routines = .failure(DefinitionReadStubError(message: "permission denied for pg_proc")) + + let reads = try await ObjectCopyPlanner.sourceDefinitionReads( + for: [view, function], + views: [PluginTableInfo(name: "recent", type: "VIEW", schema: "public", comment: nil)], + triggerTables: [], + schema: "public", + endpointName: "Local / app / public", + using: driver + ) + + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(reads[view.id], sentAs: postgres), + .runnable("CREATE VIEW recent AS SELECT 1") + ) + guard case .skipped(let reason) = ObjectCopyPlanner.definitionOutcome(reads[function.id], sentAs: postgres) else { + return XCTFail("a routine whose listing failed must be skipped") + } + XCTAssertTrue(reason.contains("permission denied for pg_proc"), reason) + } + + func testAFailedTriggerListingSkipsTheTriggers() async throws { + let driver = SourceDefinitionStubDriver() + driver.tableTriggers = ["orders": .failure(DefinitionReadStubError(message: "TRIGGER command denied"))] + + let reads = try await ObjectCopyPlanner.sourceDefinitionReads( + for: [trigger], + views: [], + triggerTables: ["orders"], + schema: "public", + endpointName: "Local / app / public", + using: driver + ) + + guard case .skipped(let reason) = ObjectCopyPlanner.definitionOutcome(reads[trigger.id], sentAs: postgres) else { + return XCTFail("a trigger whose listing failed must be skipped") + } + XCTAssertTrue(reason.contains("TRIGGER command denied"), reason) + } + + func testARoutineWhoseDDLIsRefusedIsSkippedWithTheDriversReason() async throws { + let driver = SourceDefinitionStubDriver() + let refusal = PluginObjectSourceError.insufficientPrivilege("total") + driver.routines = .success([ + PluginRoutineInfo(name: "total", kind: .function, schema: "public", argumentSignature: "(integer)") + ]) + driver.routineDDL = ["total": .failure(refusal)] + + let reads = try await ObjectCopyPlanner.sourceDefinitionReads( + for: [function], + views: [], + triggerTables: [], + schema: "public", + endpointName: "Local / app / public", + using: driver + ) + + XCTAssertEqual( + ObjectCopyPlanner.definitionOutcome(reads[function.id], sentAs: postgres), + .skipped(refusal.errorDescription ?? "") + ) + } + + func testACancelledListingCancelsThePlan() async { + let driver = SourceDefinitionStubDriver() + driver.routines = .failure(CancellationError()) + + do { + _ = try await ObjectCopyPlanner.sourceDefinitionReads( + for: [function], + views: [], + triggerTables: [], + schema: "public", + endpointName: "Local / app / public", + using: driver + ) + XCTFail("a cancelled plan must not turn into skipped objects") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + } +} diff --git a/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift b/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift index e3106e03ad..63bd01c749 100644 --- a/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift +++ b/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift @@ -283,4 +283,21 @@ struct SQLScriptTextTests { #expect(Self.sqlServer.comparableText("CREATE VIEW v AS SELECT 1;") == Self.sqlServer.comparableText("CREATE VIEW v AS SELECT 1")) } + + // MARK: - Leading keyword + + @Test("The leading keyword is read past blanks and every comment the grammar knows") + func leadingKeywordSkipsCommentsAndBlanks() { + #expect(Self.sqlServer.leadingKeyword(of: "-- Author: ops\n/* audit */\n create procedure dbo.p AS SELECT 1") == "CREATE") + #expect(Self.mysql.leadingKeyword(of: "# note\nCREATE VIEW v AS SELECT 1") == "CREATE") + #expect(Self.postgres.leadingKeyword(of: "SELECT 1") == "SELECT") + #expect(Self.postgres.leadingKeyword(of: "(a + b)") == "") + } + + @Test("Text with no code in it has no leading keyword") + func leadingKeywordOfCommentOnlyTextIsNil() { + #expect(Self.postgres.leadingKeyword(of: "") == nil) + #expect(Self.postgres.leadingKeyword(of: " \n\t") == nil) + #expect(Self.sqlServer.leadingKeyword(of: "-- nothing\n/* at all */") == nil) + } } diff --git a/TableProTests/Helpers/SourceDefinitionStubDriver.swift b/TableProTests/Helpers/SourceDefinitionStubDriver.swift new file mode 100644 index 0000000000..2bca0ef4c1 --- /dev/null +++ b/TableProTests/Helpers/SourceDefinitionStubDriver.swift @@ -0,0 +1,90 @@ +// +// SourceDefinitionStubDriver.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit + +internal struct DefinitionReadStubError: LocalizedError { + internal let message: String + + internal var errorDescription: String? { message } +} + +internal final class SourceDefinitionStubDriver: PluginDatabaseDriver, @unchecked Sendable { + private let lock = NSLock() + private var calls: [String] = [] + + internal var viewDefinitions: [String: Result] = [:] + internal var routines: Result<[PluginRoutineInfo], any Error> = .success([]) + internal var routineDDL: [String: Result] = [:] + internal var wholeSchemaTriggers: Result<[PluginTriggerInfo], any Error>? + internal var tableTriggers: [String: Result<[PluginTriggerInfo], any Error>] = [:] + internal var triggerDDL: [String: Result] = [:] + + internal var recordedCalls: [String] { + lock.withLock { calls } + } + + private func record(_ call: String) { + lock.withLock { calls.append(call) } + } + + internal var providesBulkTriggerFetch: Bool { wholeSchemaTriggers != nil } + + internal func fetchViewDefinition(view: String, schema: String?) async throws -> String { + record("fetchViewDefinition:\(view)") + return try (viewDefinitions[view] ?? .success("")).get() + } + + internal func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + record("fetchRoutines") + return try routines.get() + } + + internal func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + record("fetchRoutineDDL:\(routine.name)") + return try (routineDDL[routine.name] ?? .failure(PluginObjectSourceError.unsupported(routine.name))).get() + } + + internal func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + record("fetchAllTriggers") + return try (wholeSchemaTriggers ?? .success([])).get() + } + + internal func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + record("fetchTriggers:\(table)") + return try (tableTriggers[table] ?? .success([])).get() + } + + internal func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + record("fetchTriggerDDL:\(trigger.name)") + return try (triggerDDL[trigger.name] ?? .failure(PluginObjectSourceError.notFound(trigger.name))).get() + } + + internal func connect() async throws {} + internal func disconnect() {} + internal var isConnected: Bool { true } + + internal func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + internal func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + internal func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + + internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + internal func fetchDatabases() async throws -> [String] { [] } + + internal func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} diff --git a/docs/features/compare-sync.mdx b/docs/features/compare-sync.mdx index 73e5deecd4..838d241107 100644 --- a/docs/features/compare-sync.mdx +++ b/docs/features/compare-sync.mdx @@ -65,7 +65,11 @@ Each object lands in one of four states: **only in source**, **only in target**, Every row carries an **Include** checkbox, and a group header carries one for everything under it. **Select > All** covers everything the pane is currently showing, so the search field and **Show Identical Objects** narrow what it reaches. Nothing is included until it is checked. -An object whose metadata could not be read keeps its own **Could Not Compare** section with the driver's reason. One unreadable object never stops the rest of the comparison. +An object that could not be read keeps its own **Could Not Compare** section with the reason, and no script touches it. A view, procedure, function or trigger lands there when the server refuses its definition, returns an empty one, or returns a body rather than a `CREATE` statement. On MySQL, an account without the `SHOW VIEW` privilege lists every view and is refused each definition. One unreadable object never stops the rest of the comparison. + +A procedure, function or trigger list that cannot be read stops the comparison and names the connection. To compare everything else, deselect that kind under **Objects to Compare** in **Options**. + +On MySQL a list that is short is not an error. An account without `EXECUTE` lists no procedures or functions, and one without `TRIGGER` lists no triggers, so the other side's copies show as only on that side. Compare from accounts that hold both privileges. The detail pane on the right has three tabs. **Definitions** shows the source and target side by side, split or unified, rendered from the same function so a formatting difference cannot appear as a real one. **Rows** is the data comparison. **Script** is the generated SQL. diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index a8836938a4..dc12cab21d 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -191,8 +191,10 @@ own SQL text and nothing rewrites the objects it names, so anywhere else it woul source. They are left out with the reason shown, which is why duplicating a MySQL database carries its tables and not its views. -A driver that answers with a view's `SELECT` rather than its `CREATE`, which ClickHouse, Oracle, -Dameng and BigQuery do, has that view left out for the same reason. +A driver that answers with a view's `SELECT` rather than its `CREATE`, which ClickHouse, Dameng and +BigQuery do, has that view left out for the same reason. A definition the source refuses to return +is left out with the server's reason. So is every selected procedure and function, or every selected +trigger, when the source cannot list that kind; the rest of the copy goes ahead. ## Ordering and foreign keys