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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`.
- Materialized view indexes ignored by Compare & Sync, and lost when it or Copy To recreated the view.
- 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,24 @@ internal struct RoutineSourceRead: Sendable {
internal let signature: String?
internal let source: String
internal let failure: String?
internal let indexes: ObjectIndexRead?

internal init(
name: String,
kind: CompareObjectKind,
schema: String?,
signature: String?,
source: String,
failure: String? = nil
failure: String? = nil,
indexes: ObjectIndexRead? = nil
) {
self.name = name
self.kind = kind
self.schema = schema
self.signature = signature
self.source = source
self.failure = failure
self.indexes = indexes
}
}

Expand All @@ -39,21 +42,22 @@ internal extension CompareMetadataService {
)

nonisolated static func readViewDefinitions(
_ views: [PluginTableInfo],
_ views: [TableStructureRead],
schema: String?,
using plugin: any PluginDatabaseDriver
) async throws -> [RoutineSourceRead] {
var reads: [RoutineSourceRead] = []
for view in views {
try Task.checkCancellation()
let viewSchema = view.schema ?? schema
let viewSchema = view.table.schema ?? schema
reads.append(try await definitionRead(
name: view.name,
kind: CompareTableKindClassifier.kind(of: view),
name: view.table.name,
kind: CompareTableKindClassifier.kind(of: view.table),
schema: viewSchema,
signature: nil
signature: nil,
indexes: view.objectIndexes
) {
try await plugin.fetchViewDefinition(view: view.name, schema: viewSchema)
try await plugin.fetchViewDefinition(view: view.table.name, schema: viewSchema)
})
}
return reads
Expand Down Expand Up @@ -175,19 +179,22 @@ internal extension CompareMetadataService {
kind: CompareObjectKind,
schema: String?,
signature: String?,
indexes: ObjectIndexRead? = nil,
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)
return RoutineSourceRead(
name: name, kind: kind, schema: schema, signature: signature, source: source, indexes: indexes
)
} 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
source: "", failure: error.localizedDescription, indexes: indexes
)
}
}
Expand Down
58 changes: 48 additions & 10 deletions TablePro/Core/Compare/CompareMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal struct TableStructureRead: Sendable {
internal let foreignKeys: [PluginForeignKeyInfo]
internal let metadata: PluginTableMetadata?
internal let failure: String?
internal var objectIndexes: ObjectIndexRead?

internal var snapshot: TableStructureSnapshot? {
snapshot(indexes: indexes)
Expand Down Expand Up @@ -118,6 +119,7 @@ internal struct CompareMetadataService {
try await manager.ensureConnected(connection)
let schema = endpoint.schema
let databaseType = endpoint.databaseType
let indexedKinds = SourceObjectIndexes.carriedKinds(on: databaseType)
let wanted = names.map { Set($0.map { $0.lowercased() }) }

return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in
Expand All @@ -129,7 +131,8 @@ internal struct CompareMetadataService {
}
return try await Self.read(
tables: tables, schema: schema, profile: profile,
narrowed: wanted != nil, databaseType: databaseType, using: plugin
narrowed: wanted != nil, databaseType: databaseType,
indexedKinds: indexedKinds, using: plugin
)
}
}
Expand Down Expand Up @@ -191,7 +194,7 @@ internal struct CompareMetadataService {
internal func viewDefinitions(
for endpoint: DatabaseEndpoint,
connection: DatabaseConnection,
views: [PluginTableInfo]
views: [TableStructureRead]
) async throws -> [RoutineSourceRead] {
try await manager.ensureConnected(connection)
let schema = endpoint.schema
Expand Down Expand Up @@ -223,6 +226,7 @@ internal struct CompareMetadataService {
profile: TableReadProfile,
narrowed: Bool,
databaseType: DatabaseType,
indexedKinds: Set<CompareObjectKind>,
using plugin: any PluginDatabaseDriver
) async throws -> [TableStructureRead] {
let bulk = narrowed
Expand All @@ -240,7 +244,10 @@ internal struct CompareMetadataService {
: Self.fallbackConcurrency

return try await map(tables, concurrency: concurrency) { table in
await read(table: table, schema: table.schema ?? schema, profile: profile, bulk: bulk, using: plugin)
await read(
table: table, schema: table.schema ?? schema, profile: profile, bulk: bulk,
indexedKinds: indexedKinds, using: plugin
)
}
}

Expand Down Expand Up @@ -380,30 +387,56 @@ internal struct CompareMetadataService {
schema: String?,
profile: TableReadProfile,
bulk: BulkMetadata,
indexedKinds: Set<CompareObjectKind>,
using plugin: any PluginDatabaseDriver
) async -> TableStructureRead {
let kind = CompareTableKindClassifier.kind(of: table)
let objectIndexes = kind != .table && indexedKinds.contains(kind)
? await objectIndexes(of: table, schema: schema, profile: profile, bulk: bulk, using: plugin)
: nil
do {
let columns = try await columns(of: table, schema: schema, bulk: bulk, using: plugin)
let indexes = try await indexes(of: table, schema: schema, profile: profile, bulk: bulk, using: plugin)
let indexes = try await indexes(
of: table, schema: schema, profile: profile, bulk: bulk, objectIndexes: objectIndexes, using: plugin
)
let foreignKeys = try await foreignKeys(
of: table, schema: schema, profile: profile, bulk: bulk, using: plugin
)
let metadata = await metadata(of: table, schema: schema, profile: profile, bulk: bulk, using: plugin)
return TableStructureRead(
table: table, columns: columns, indexes: indexes,
foreignKeys: foreignKeys, metadata: metadata, failure: nil
foreignKeys: foreignKeys, metadata: metadata, failure: nil, objectIndexes: objectIndexes
)
} catch {
Self.logger.warning(
"Structure read failed for \(table.name, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)"
)
return TableStructureRead(
table: table, columns: [], indexes: [], foreignKeys: [],
metadata: nil, failure: error.localizedDescription
metadata: nil, failure: error.localizedDescription, objectIndexes: objectIndexes
)
}
}

nonisolated private static func objectIndexes(
of object: PluginTableInfo,
schema: String?,
profile: TableReadProfile,
bulk: BulkMetadata,
using plugin: any PluginDatabaseDriver
) async -> ObjectIndexRead? {
guard profile.wantsIndexes else { return nil }
guard bulk.indexes == nil else { return .read(bulk.lookup(bulk.indexes, object.name) ?? []) }
do {
return .read(try await plugin.fetchIndexes(table: object.name, schema: schema))
} catch {
Self.logger.warning(
"Index read failed for \(object.name, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)"
)
return .failed(error.localizedDescription)
}
}

/// A table with no columns is not a real answer, so a name missing from the whole-schema list
/// is read on its own. That keeps the per-table failure reporting, which an absent dictionary
/// entry cannot express.
Expand All @@ -425,20 +458,25 @@ internal struct CompareMetadataService {
/// offers to drop every index the table really has. So the failure fails the table, the way the
/// column read already did, and the comparison reports it rather than acting on it.
///
/// A view is the one object where the failure means nothing: it has neither indexes nor foreign
/// keys, and engines disagree on whether asking answers empty or refuses.
/// A view or a materialized view is not asked here. Its indexes are part of its comparison only
/// where the engine's structure matrix says that kind takes them, and that read keeps its own
/// failure in `objectIndexes` rather than failing the object: engines whose views take no index
/// disagree on whether asking answers empty or refuses, and several answer with clustering or
/// sort keys that no `CREATE INDEX` can write back.
nonisolated private static func indexes(
of table: PluginTableInfo,
schema: String?,
profile: TableReadProfile,
bulk: BulkMetadata,
objectIndexes: ObjectIndexRead?,
using plugin: any PluginDatabaseDriver
) async throws -> [PluginIndexInfo] {
guard profile.wantsIndexes else { return [] }
guard bulk.indexes == nil else { return bulk.lookup(bulk.indexes, table.name) ?? [] }
guard CompareTableKindClassifier.kind(of: table) == .table else {
return (try? await plugin.fetchIndexes(table: table.name, schema: schema)) ?? []
guard case .read(let indexes) = objectIndexes else { return [] }
return indexes
}
guard bulk.indexes == nil else { return bulk.lookup(bulk.indexes, table.name) ?? [] }
return try await plugin.fetchIndexes(table: table.name, schema: schema)
}

Expand Down
27 changes: 25 additions & 2 deletions TablePro/Core/Compare/CompareObjectResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
// A table is compared as parsed metadata and yields a `SchemaChange` list. A
// view, procedure, function or trigger has no such structure: its definition is
// a body of SQL, so it is compared as normalised text and the only honest
// answers are create, replace and drop. Both arrive here as one type, so the
// answers are create, replace and drop. A materialized view whose engine takes
// indexes on it also carries those indexes, which change in place when the text
// is the same on both sides. Both arrive here as one type, so the
// results list, the selection model and the script builder do not each need to
// know which engine produced a row.
//
Expand Down Expand Up @@ -53,6 +55,9 @@ internal struct CompareObjectResult: Identifiable, Hashable, Sendable {
internal let targetDefinition: [String]
internal let notes: [String]
internal let comparisonError: String?
internal let sourceIndexes: [EditableIndexDefinition]?
internal let targetIndexes: [EditableIndexDefinition]?
internal let definitionMatches: Bool

internal init(
identity: CompareObjectIdentity,
Expand All @@ -61,7 +66,10 @@ internal struct CompareObjectResult: Identifiable, Hashable, Sendable {
sourceDefinition: [String] = [],
targetDefinition: [String] = [],
notes: [String] = [],
comparisonError: String? = nil
comparisonError: String? = nil,
sourceIndexes: [EditableIndexDefinition]? = nil,
targetIndexes: [EditableIndexDefinition]? = nil,
definitionMatches: Bool = false
) {
self.identity = identity
self.status = status
Expand All @@ -70,10 +78,25 @@ internal struct CompareObjectResult: Identifiable, Hashable, Sendable {
self.targetDefinition = targetDefinition
self.notes = notes
self.comparisonError = comparisonError
self.sourceIndexes = sourceIndexes
self.targetIndexes = targetIndexes
self.definitionMatches = definitionMatches
}

internal var id: String { identity.id }

internal var showsIndexes: Bool {
sourceIndexes != nil || (status == .onlyInTarget && targetIndexes != nil)
}

internal var sourceIndexLines: [String] {
TableDefinitionRenderer.indexLines(for: sourceIndexes ?? [])
}

internal var targetIndexLines: [String] {
TableDefinitionRenderer.indexLines(for: targetIndexes ?? [])
}

internal var isComparable: Bool { comparisonError == nil }

internal var suggestedAction: TableSyncAction {
Expand Down
11 changes: 7 additions & 4 deletions TablePro/Core/Compare/CompareRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,15 +362,16 @@ internal struct CompareRunner {
let diffEngine = SourceObjectDiffEngine(
options: session.structureOptions,
sourceDatabaseType: context.source.databaseType,
targetDatabaseType: context.target.databaseType
targetDatabaseType: context.target.databaseType,
targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType)
)

/// Each pair reads two independent endpoints, so the two sides run together rather than the
/// second waiting out the first.
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)) }
let sourceViews = sourceReads.filter { viewKinds.contains(CompareTableKindClassifier.kind(of: $0.table)) }
let targetViews = targetReads.filter { viewKinds.contains(CompareTableKindClassifier.kind(of: $0.table)) }
async let sourceDefinitions = metadataService.viewDefinitions(
for: context.source, connection: context.sourceConnection, views: sourceViews
)
Expand Down Expand Up @@ -445,7 +446,9 @@ internal struct CompareRunner {
targetDriver: plugin, targetDatabaseType: driver.connection.type
).build(operations: tableOperations, foreignKeysByTable: foreignKeys)
let sourceBuilder = SourceObjectSyncBuilder(
targetDriver: plugin, targetDatabaseType: driver.connection.type
targetDriver: plugin,
targetDatabaseType: driver.connection.type,
indexSchema: plugin.currentSchema
)
for entry in sourceDefined {
statements += try sourceBuilder.build(for: entry.result, action: entry.action)
Expand Down
19 changes: 10 additions & 9 deletions TablePro/Core/Compare/SchemaSyncScriptBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ internal struct SchemaSyncScriptBuilder {
return try createStatements(for: snapshot)
case .dropTable(let name, let schema):
return dropStatements(name: name, schema: schema)
case .alterTable(let name, let schema, let changes):
return try alterStatements(name: name, schema: schema, changes: changes)
case .alterTable(let name, _, let changes):
return try changeStatements(on: name, objectName: name, changes: changes)
}
}

Expand Down Expand Up @@ -135,19 +135,20 @@ internal struct SchemaSyncScriptBuilder {
}
}

private func alterStatements(
name: String,
schema: String?,
changes: [SchemaChange]
internal func changeStatements(
on relation: String,
objectName: String,
changes: [SchemaChange],
additionalHazards: (SchemaChange) -> [SyncHazard] = { _ in [] }
) throws -> [SyncStatement] {
let generator = SchemaStatementGenerator(tableName: name, pluginDriver: targetDriver)
let generator = SchemaStatementGenerator(tableName: relation, pluginDriver: targetDriver)
var statements: [SyncStatement] = []
for change in SchemaChangeOrdering.sorted(changes) {
let hazards = classifier.hazards(for: change, typeFamily: targetTypeFamily)
let hazards = classifier.hazards(for: change, typeFamily: targetTypeFamily) + additionalHazards(change)
let generated = try generator.generate(changes: [change])
for statement in generated {
statements += scriptText.sendableStatements(statement.sql).map { sql in
SyncStatement(sql: sql, objectName: name, summary: statement.description, hazards: hazards)
SyncStatement(sql: sql, objectName: objectName, summary: statement.description, hazards: hazards)
}
}
}
Expand Down
Loading
Loading