Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
194 changes: 194 additions & 0 deletions TablePro/Core/Compare/CompareMetadataService+SourceDefinitions.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
110 changes: 7 additions & 103 deletions TablePro/Core/Compare/CompareMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -159,106 +151,33 @@ 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,
tables: [String]
) 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(
Expand All @@ -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)
}
}

Expand Down
Loading
Loading