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
10 changes: 6 additions & 4 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ extension MySQLPluginDriver {
/// `SHOW INDEX` returns text, so both are cast rather than read through a text accessor that
/// would depend on how the driver rendered an integer cell.
func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] {
let escapedDb = activeDatabaseName.replacingOccurrences(of: "'", with: "''")
let escapedDb = mysqlEscapeStringLiteral(routineSchema(schema))
let query = """
SELECT
TABLE_NAME, INDEX_NAME, COLUMN_NAME,
Expand Down Expand Up @@ -107,10 +107,12 @@ extension MySQLPluginDriver {

var providesBulkTableMetadataFetch: Bool { true }

/// `SHOW TABLE STATUS` with no `WHERE` is the whole schema, in the same column order the
/// per-table read indexes into.
/// `SHOW TABLE STATUS FROM` with no `WHERE` is the whole schema, in the same column order the
/// per-table read indexes into. The database is named rather than inherited from the session,
/// so a caller asking about another one is answered about the one it asked about.
func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] {
let result = try await execute(query: "SHOW TABLE STATUS")
let database = mysqlQuoteIdentifier(routineSchema(schema))
let result = try await execute(query: "SHOW TABLE STATUS FROM \(database)")
var metadata: [String: PluginTableMetadata] = [:]
for row in result.rows {
guard let name = row[safe: 0]?.asText else { continue }
Expand Down
2 changes: 1 addition & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ extension MySQLPluginDriver {
}
}

private func routineSchema(_ schema: String?) -> String {
func routineSchema(_ schema: String?) -> String {
guard let schema, !schema.isEmpty else { return activeDatabaseName }
return schema
}
Expand Down
45 changes: 34 additions & 11 deletions TablePro/Core/Compare/CompareMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,11 +368,34 @@ internal struct CompareMetadataService {

/// What a whole-schema read produced, or nothing where the driver has no single-query form for
/// it and the per-table read still has to run.
/// A whole-schema answer plus the folded spellings of its own keys, indexed once. Every table
/// that is missing from a sparse map asks for the folded fallback, so computing it per lookup
/// made the fallback quadratic in the table count.
private struct FoldedMap<Value: Sendable>: Sendable {
let byName: [String: Value]
let byFoldedName: [String: Value]

init(_ map: [String: Value]) {
byName = map
var folded: [String: Value] = [:]
var collided: Set<String> = []
for (key, value) in map {
let name = key.lowercased()
guard !collided.contains(name) else { continue }
if folded.updateValue(value, forKey: name) != nil {
folded.removeValue(forKey: name)
collided.insert(name)
}
}
byFoldedName = folded
}
}

private struct BulkMetadata: Sendable {
var columns: [String: [PluginColumnInfo]]?
var indexes: [String: [PluginIndexInfo]]?
var foreignKeys: [String: [PluginForeignKeyInfo]]?
var tableMetadata: [String: PluginTableMetadata]?
var columns: FoldedMap<[PluginColumnInfo]>?
var indexes: FoldedMap<[PluginIndexInfo]>?
var foreignKeys: FoldedMap<[PluginForeignKeyInfo]>?
var tableMetadata: FoldedMap<PluginTableMetadata>?

/// The folded spellings that name exactly one table in this scope. A folded fallback is
/// only safe for those.
Expand All @@ -396,16 +419,16 @@ internal struct CompareMetadataService {
unambiguousFolded = Set(counts.filter { $0.value == 1 }.keys)

if plugin.providesBulkColumnFetch {
columns = try? await plugin.fetchAllColumns(schema: schema)
columns = (try? await plugin.fetchAllColumns(schema: schema)).map(FoldedMap.init)
}
if profile.wantsIndexes, plugin.providesBulkIndexFetch {
indexes = try? await plugin.fetchAllIndexes(schema: schema)
indexes = (try? await plugin.fetchAllIndexes(schema: schema)).map(FoldedMap.init)
}
if profile.wantsForeignKeys, plugin.providesBulkForeignKeyFetch {
foreignKeys = try? await plugin.fetchAllForeignKeys(schema: schema)
foreignKeys = (try? await plugin.fetchAllForeignKeys(schema: schema)).map(FoldedMap.init)
}
if profile.wantsTableMetadata, plugin.providesBulkTableMetadataFetch {
tableMetadata = try? await plugin.fetchAllTableMetadata(schema: schema)
tableMetadata = (try? await plugin.fetchAllTableMetadata(schema: schema)).map(FoldedMap.init)
}
}

Expand All @@ -427,12 +450,12 @@ internal struct CompareMetadataService {
/// entry, and PostgreSQL allows `"Foo"` beside `"foo"`: a folded match there handed one
/// table's indexes to the other, and a DROP INDEX generated from that names the index
/// alone, so it would have dropped the real one.
func lookup<Value>(_ map: [String: Value]?, _ name: String) -> Value? {
func lookup<Value>(_ map: FoldedMap<Value>?, _ name: String) -> Value? {
guard let map else { return nil }
if let exact = map[name] { return exact }
if let exact = map.byName[name] { return exact }
let folded = name.lowercased()
guard unambiguousFolded.contains(folded) else { return nil }
return map.first { $0.key.lowercased() == folded }?.value
return map.byFoldedName[folded]
}
}

Expand Down
48 changes: 33 additions & 15 deletions TablePro/Core/Compare/CompareRunner+Data.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,18 @@ internal extension CompareRunner {
do {
let context = try resolveContext()
if let refusal = try await capabilityRefusal(context) {
guard session.owns(claim) else { return }
guard session.ownsAnswer(claim) else { return }
session.errorMessage = refusal
return
}
let plans = try await buildPlans(context)
let read = try await buildPlans(context)
/// The pair may have moved while this was reading. Publishing now would put one
/// pair's tables, columns and snapshots behind another pair's Compare.
guard session.owns(claim) else { return }
session.adoptDataPlans(plans)
guard session.ownsAnswer(claim) else { return }
adopt(read)
} catch is CancellationError {
} catch {
guard session.owns(claim) else { return }
guard session.ownsAnswer(claim) else { return }
session.errorMessage = error.localizedDescription
}
}
Expand All @@ -64,9 +64,9 @@ internal extension CompareRunner {
/// a stale key still merges the two row streams and still addresses the UPDATE and DELETE
/// it generates, so one reviewed row's statement can reach every row sharing that value.
/// `buildPlans` carries the user's ticks, keys and row exclusions onto the fresh list.
let built = try await buildPlans(context)
guard session.owns(claim) else { throw CancellationError() }
session.adoptDataPlans(built)
let read = try await buildPlans(context)
guard session.ownsAnswer(claim) else { throw CancellationError() }
adopt(read)
var plans = session.dataPlans

for index in plans.indices where plans[index].isEnabled && plans[index].isComparable {
Expand All @@ -88,10 +88,9 @@ internal extension CompareRunner {
}

try Task.checkCancellation()
guard session.owns(claim) else { throw CancellationError() }
session.dataPlans = plans
guard session.ownsAnswer(claim) else { throw CancellationError() }
session.applyComparedSummaries(from: plans)
session.hasLoadedDataPlans = true
session.selectedPlanId = plans.first { $0.isEnabled && $0.isComparable }?.id ?? plans.first?.id
session.detailPane = .rows
session.invalidateScript()

Expand Down Expand Up @@ -154,12 +153,27 @@ internal extension CompareRunner {

// MARK: - Plans

private func buildPlans(_ context: Context) async throws -> [DataComparePlan] {
/// Everything one read of the two sides produced, so the caller publishes all of it behind one
/// ownership check. Writing the snapshots here put one pair's foreign key graph and CREATE
/// TABLE source under another pair's Apply whenever the read outlived the pair it was started
/// for, which is the hazard the claim exists to close.
struct DataPlanRead {
let plans: [DataComparePlan]
let sourceSnapshots: [String: TableStructureSnapshot]
let unreadableTableCount: Int
}

private func adopt(_ read: DataPlanRead) {
session.sourceSnapshots = read.sourceSnapshots
session.unreadableTableCount = read.unreadableTableCount
session.adoptDataPlans(read.plans)
}

private func buildPlans(_ context: Context) async throws -> DataPlanRead {
let (sourceReads, targetReads) = try await metadataService.bothSideTableReads(
context: context, includeViews: false, profile: .data
)
try Task.checkCancellation()
session.unreadableTableCount = (sourceReads + targetReads).filter { $0.failure != nil }.count

/// Keyed on schema and name, not name alone: two schemas of one database can hold the same
/// table, and pairing on the bare name took the shared column set from the wrong
Expand All @@ -173,7 +187,7 @@ internal extension CompareRunner {
session.dataPlans.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }
)

session.sourceSnapshots = Dictionary(
let sourceSnapshots = Dictionary(
sourceReads.compactMap { $0.snapshot }.map { ($0.qualifiedName, $0) },
uniquingKeysWith: { first, _ in first }
)
Expand Down Expand Up @@ -205,6 +219,10 @@ internal extension CompareRunner {
plan.unavailableReason = DataComparePlan.unavailableReason(for: plan)
plans.append(plan)
}
return plans.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
return DataPlanRead(
plans: plans.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending },
sourceSnapshots: sourceSnapshots,
unreadableTableCount: (sourceReads + targetReads).filter { $0.failure != nil }.count
)
}
}
23 changes: 9 additions & 14 deletions TablePro/Core/Compare/CompareRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ internal struct CompareRunner {
do {
let context = try resolveContext()
if let refusal = try await capabilityRefusal(context) {
guard session.owns(claim) else { return }
guard session.ownsAnswer(claim) else { return }
session.errorMessage = refusal
return
}
Expand All @@ -53,13 +53,11 @@ internal struct CompareRunner {
case .data:
try await runDataCompare(context, claim: claim)
}
guard session.owns(claim) else { return }
guard session.ownsAnswer(claim) else { return }
session.informationalMessage = session.crossEngineNotice
} catch is CancellationError {
guard session.owns(claim) else { return }
session.informationalMessage = String(localized: "Comparison cancelled.")
} catch {
guard session.owns(claim) else { return }
guard session.ownsAnswer(claim) else { return }
session.errorMessage = error.localizedDescription
}
}
Expand Down Expand Up @@ -90,7 +88,7 @@ internal struct CompareRunner {

let claim = session.currentClaim
return Task { [session] in
session.activity = .comparing
session.activity = .buildingScript
defer { session.activity = .idle }
do {
let context = try resolveContext()
Expand All @@ -113,9 +111,9 @@ internal struct CompareRunner {
session.statements = built
session.detailPane = .script
} catch is CancellationError {
guard session.owns(claim) else { return }
session.informationalMessage = String(localized: "Script generation cancelled.")
} catch {
/// The full claim, not the answer alone: a build that failed for a selection the
/// user has since changed would blame an object they had just excluded.
guard session.owns(claim) else { return }
session.errorMessage = error.localizedDescription
}
Expand Down Expand Up @@ -173,6 +171,7 @@ internal struct CompareRunner {
/// armed left Apply enabled on a stale plan, one click from running the same
/// CREATE/ALTER/DELETE a second time.
session.markAppliedAndStale()
} catch is CancellationError {
} catch {
session.errorMessage = error.localizedDescription
}
Expand Down Expand Up @@ -264,17 +263,13 @@ internal struct CompareRunner {
results += try await sourceDefinedResults(context, sourceReads: sourceReads, targetReads: targetReads)

try Task.checkCancellation()
guard session.owns(claim) else { throw CancellationError() }
guard session.ownsAnswer(claim) else { throw CancellationError() }

let report = CompareReport(results: results)
session.sourceSnapshots = sourceByName
session.targetSnapshots = targetByName
session.report = report
session.actions = [:]
for result in report.comparable where session.pendingSelection.contains(result.id) {
session.actions[result.id] = result.suggestedAction
}
session.pendingSelection = []
session.adoptActions(for: report)
session.invalidateScript()
session.selectedObjectId = session.visibleResults.first?.id
session.lastAction = .compared(Date(), differences: report.differenceCount)
Expand Down
16 changes: 0 additions & 16 deletions TablePro/Core/Compare/CompareSyncProfileStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,6 @@ internal struct CompareSyncProfile: Codable, Hashable, Identifiable {
self.selectedObjects = selectedObjects
}

internal static func storageKey(source: DatabaseScope, target: DatabaseScope, mode: CompareSyncMode) -> String {
"\(key(for: source))|\(key(for: target))|\(mode.rawValue)"
}

internal var storageKey: String {
Self.storageKey(source: source, target: target, mode: mode)
}

private static func key(for scope: DatabaseScope) -> String {
"\(scope.connectionId.uuidString)/\(scope.database)/\(scope.schema ?? "")"
}
}

extension DatabaseScope: Codable {
Expand Down Expand Up @@ -134,11 +123,6 @@ internal final class CompareSyncProfileStorage {
}
}

internal func profiles(source: DatabaseScope, target: DatabaseScope, mode: CompareSyncMode) -> [CompareSyncProfile] {
let key = CompareSyncProfile.storageKey(source: source, target: target, mode: mode)
return allProfiles().filter { $0.storageKey == key }
}

internal func save(_ profile: CompareSyncProfile) {
var profiles = allProfiles()
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Compare/CompareSyncSession+Editing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal extension CompareSyncSession {
dataPlans[index].summary = nil
dataPlans[index].excludedRowKeys = []
dataPlans[index].unavailableReason = DataComparePlan.unavailableReason(for: dataPlans[index])
invalidateScript()
invalidateAnswer()
}

func toggleKeyColumn(_ column: String, for planId: String) {
Expand Down Expand Up @@ -73,7 +73,7 @@ internal extension CompareSyncSession {
for index in dataPlans.indices {
dataPlans[index].summary = nil
}
invalidateScript()
invalidateAnswer()
}

// MARK: - Row inclusion
Expand Down
Loading
Loading