From c19e2b0416f5dd2122d33e12ca6c35a5814fe8c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 22:19:42 +0700 Subject: [PATCH] fix(sidebar): key per-schema object lists by database --- CHANGELOG.md | 1 + .../Services/Query/SchemaRefreshService.swift | 7 +- .../Core/Services/Query/SchemaService.swift | 205 ++++++++------ .../ViewModels/QuickSwitcherViewModel.swift | 14 +- .../Services/Query/SchemaServiceTests.swift | 16 +- .../SchemaServiceDatabaseSwitchTests.swift | 264 ++++++++++++++++++ .../SchemaServiceHierarchicalTests.swift | 16 +- .../Services/SchemaServiceRefreshTests.swift | 10 +- .../SchemaServiceSideObjectsTests.swift | 16 +- .../QuickSwitcherCrossSchemaTests.swift | 38 ++- 10 files changed, 449 insertions(+), 138 deletions(-) create mode 100644 TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e28b1357..a4c8d1ac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Autocomplete offering another schema's tables without their schema once that schema was completed or expanded. - Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh. +- Tables from the previous database listed under a schema after switching database on Snowflake or Trino. - Schemas missing from Open Quickly on every reopen after one failed to load. - Unexpanded schemas hidden by the sidebar filter in the Tree layout. - Empty object sections opened as "No items" under every match while filtering the sidebar tree. diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 26b9b042dc..278ba530fc 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -165,7 +165,7 @@ final class SchemaRefreshService { scope: scope, workload: .bulk ) { [schemaService] driver in - await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, driver: driver) + await schemaService.loadSchemaObjects(schema: schema, in: scope, driver: driver) } } catch { Self.logger.warning( @@ -309,10 +309,7 @@ final class SchemaRefreshService { connection: connection, scope: scope ) - await schemaService.refreshLoadedSchemaObjects( - connectionId: connectionId, - driver: driver - ) + await schemaService.refreshLoadedSchemaObjects(in: scope, driver: driver) } } catch is CancellationError { return diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 9750d96364..bb637f4283 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -23,8 +23,8 @@ final class SchemaService: ObservableObject { @Published private(set) var states: [UUID: SchemaState] = [:] @Published private(set) var sideObjects: [UUID: SideObjects] = [:] @Published private(set) var schemasInOrder: [UUID: [String]] = [:] - @Published private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] - @Published private(set) var perSchemaSideObjects: [UUID: [String: SideObjects]] = [:] + @Published private(set) var perSchemaStates: [SchemaKey: SchemaState] = [:] + @Published private(set) var perSchemaSideObjects: [SchemaKey: SideObjects] = [:] @Published private(set) var generations: [UUID: Int] = [:] @Published private(set) var refreshingConnections: Set = [] @Published private(set) var loadedScopes: [UUID: DatabaseScope] = [:] @@ -47,8 +47,11 @@ final class SchemaService: ObservableObject { private let perSchemaTriggersDedup = OnceTask() private let perSchemaTypesDedup = OnceTask() + /// A schema is named inside a database, and an engine that changes database on a live + /// connection reaches a `PUBLIC` in every one of them. struct SchemaKey: Hashable, Sendable { let connectionId: UUID + let database: String let schema: String } @@ -104,7 +107,8 @@ final class SchemaService: ObservableObject { /// this the recorded scope keeps naming the schema the session left, and the next reader /// compares the two and runs the full reload the caller just avoided. func noteScopeCovered(_ scope: DatabaseScope, for connectionId: UUID) { - guard case .loaded = state(for: connectionId) else { return } + guard case .loaded = state(for: connectionId), + loadedScopes[connectionId]?.database == scope.database else { return } loadedScopes[connectionId] = scope } @@ -134,7 +138,11 @@ final class SchemaService: ObservableObject { } func hasLoadedContent(for connectionId: UUID, schema: String) -> Bool { - if case .loaded = schemaState(for: connectionId, schema: schema) { return true } + hasLoadedContent(catalogKey(connectionId, schema: schema)) + } + + private func hasLoadedContent(_ key: SchemaKey) -> Bool { + if case .loaded = perSchemaStates[key] { return true } return false } @@ -182,7 +190,26 @@ final class SchemaService: ObservableObject { } func schemaState(for connectionId: UUID, schema: String) -> SchemaState { - perSchemaStates[connectionId]?[schema] ?? .idle + perSchemaStates[catalogKey(connectionId, schema: schema)] ?? .idle + } + + /// Every per-schema read answers for the database `schemas(for:)` was listed from, so the + /// schema rows and the objects under them describe one database even while a switch settles. + private func catalogKey(_ connectionId: UUID, schema: String) -> SchemaKey { + SchemaKey(connectionId: connectionId, database: catalogDatabase(connectionId), schema: schema) + } + + private func catalogDatabase(_ connectionId: UUID) -> String { + loadedScopes[connectionId]?.database ?? "" + } + + private func catalogEntries(_ entries: [SchemaKey: Value], of connectionId: UUID) -> [String: Value] { + let database = catalogDatabase(connectionId) + var result: [String: Value] = [:] + for (key, value) in entries where key.connectionId == connectionId && key.database == database { + result[key.schema] = value + } + return result } func tables(for connectionId: UUID, schema: String) -> [TableInfo] { @@ -208,31 +235,32 @@ final class SchemaService: ObservableObject { /// before it may drop the schema for matching nothing. A kind whose fetch failed has not /// answered: the object the search wants may be exactly the one it could not list. func isSchemaSettled(for connectionId: UUID, schema: String) -> Bool { - guard hasLoadedContent(for: connectionId, schema: schema) else { return false } - let side = perSchemaSideObjects[connectionId]?[schema] ?? SideObjects() + let key = catalogKey(connectionId, schema: schema) + guard hasLoadedContent(key) else { return false } + let side = perSchemaSideObjects[key] ?? SideObjects() return [side.routines.erased, side.triggers.erased, side.userDefinedTypes.erased] .allSatisfy { $0 == .loaded || $0 == .idle } } func routinesLoadState(for connectionId: UUID, schema: String) -> MetadataLoadState<[RoutineInfo]> { - perSchemaSideObjects[connectionId]?[schema]?.routines ?? .idle + perSchemaSideObjects[catalogKey(connectionId, schema: schema)]?.routines ?? .idle } func triggersLoadState(for connectionId: UUID, schema: String) -> MetadataLoadState<[TriggerInfo]> { - perSchemaSideObjects[connectionId]?[schema]?.triggers ?? .idle + perSchemaSideObjects[catalogKey(connectionId, schema: schema)]?.triggers ?? .idle } func userDefinedTypesLoadState( for connectionId: UUID, schema: String ) -> MetadataLoadState<[UserDefinedTypeInfo]> { - perSchemaSideObjects[connectionId]?[schema]?.userDefinedTypes ?? .idle + perSchemaSideObjects[catalogKey(connectionId, schema: schema)]?.userDefinedTypes ?? .idle } /// The schemas whose own table list is loaded, empty ones included, which is what a caller /// merging another source needs: an empty schema here is an answer, not a gap. func schemasWithLoadedTables(for connectionId: UUID) -> Set { - Set((perSchemaStates[connectionId] ?? [:]).compactMap { schema, state in + Set(catalogEntries(perSchemaStates, of: connectionId).compactMap { schema, state in guard case .loaded = state else { return nil } return schema }) @@ -244,7 +272,7 @@ final class SchemaService: ObservableObject { func allLoadedTables(for connectionId: UUID) -> [TableInfo] { var result = tables(for: connectionId) var seen = Set(result.map(\.id)) - for state in (perSchemaStates[connectionId] ?? [:]).values { + for state in catalogEntries(perSchemaStates, of: connectionId).values { guard case .loaded(let schemaTables) = state else { continue } for table in schemaTables where seen.insert(table.id).inserted { result.append(table) @@ -258,83 +286,84 @@ final class SchemaService: ObservableObject { /// is the database's, not the schema's: every fetch names its schema, and a scope per schema /// opened a pooled connection per schema, one for every schema a sidebar search walked. func loadSchemaObjects(connectionId: UUID, schema: String, database: String?) async { - if case .loaded = schemaState(for: connectionId, schema: schema) { return } - await withSchemaMetadataDriver(connectionId: connectionId, schema: schema, database: database) { driver in - await self.loadSchemaObjects(connectionId: connectionId, schema: schema, driver: driver) + guard let scope = schemaRouteScope(connectionId: connectionId, database: database) else { return } + guard !hasLoadedContent(SchemaKey(scope: scope, schema: schema)) else { return } + await withSchemaMetadataDriver(scope: scope, schema: schema) { driver in + await self.loadSchemaObjects(schema: schema, in: scope, driver: driver) } } func reloadSchemaObjects(connectionId: UUID, schema: String, database: String?) async { - await withSchemaMetadataDriver(connectionId: connectionId, schema: schema, database: database) { driver in - await self.reloadSchemaObjects(connectionId: connectionId, schema: schema, driver: driver) + guard let scope = schemaRouteScope(connectionId: connectionId, database: database) else { return } + await withSchemaMetadataDriver(scope: scope, schema: schema) { driver in + await self.reloadSchemaObjects(schema: schema, in: scope, driver: driver) } } + private func schemaRouteScope(connectionId: UUID, database: String?) -> DatabaseScope? { + DatabaseManager.shared.resolvedScope(database: database, schema: nil, for: connectionId) + } + private func withSchemaMetadataDriver( - connectionId: UUID, + scope: DatabaseScope, schema: String, - database: String?, _ body: @Sendable @escaping (DatabaseDriver) async -> Void ) async { - guard let scope = DatabaseManager.shared.resolvedScope( - database: database, - schema: nil, - for: connectionId - ) else { return } do { try await DatabaseManager.shared.withMetadataDriver(scope: scope, workload: .bulk, body) } catch is CancellationError { return } catch { Self.logger.warning( - "[schema] per-schema route failed connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .private(mask: .hash)) error=\(error.publicLogShape, privacy: .public)" + "[schema] per-schema route failed connId=\(scope.connectionId, privacy: .public) schema=\(schema, privacy: .private(mask: .hash)) error=\(error.publicLogShape, privacy: .public)" ) - commitSchemaTables(.failed(error.localizedDescription), connectionId: connectionId, schema: schema) + commitSchemaTables(.failed(error.localizedDescription), key: SchemaKey(scope: scope, schema: schema)) } } - func loadSchemaObjects(connectionId: UUID, schema: String, driver: DatabaseDriver) async { - if case .loaded = schemaState(for: connectionId, schema: schema) { return } - await runSchemaLoad(connectionId: connectionId, schema: schema, driver: driver) + /// `scope` names the database `driver` reads, which is the database the objects are kept under. + func loadSchemaObjects(schema: String, in scope: DatabaseScope, driver: DatabaseDriver) async { + let key = SchemaKey(scope: scope, schema: schema) + guard !hasLoadedContent(key) else { return } + await runSchemaLoad(key, driver: driver) } - func reloadSchemaObjects(connectionId: UUID, schema: String, driver: DatabaseDriver) async { - let key = SchemaKey(connectionId: connectionId, schema: schema) + func reloadSchemaObjects(schema: String, in scope: DatabaseScope, driver: DatabaseDriver) async { + let key = SchemaKey(scope: scope, schema: schema) schemaLoadGenerations.removeValue(forKey: key) - await perSchemaDedup.cancel(key: key) - await perSchemaRoutinesDedup.cancel(key: key) - await perSchemaTriggersDedup.cancel(key: key) - await perSchemaTypesDedup.cancel(key: key) - await runSchemaLoad(connectionId: connectionId, schema: schema, driver: driver) + await cancelSchemaLoads { $0 == key } + await runSchemaLoad(key, driver: driver) } - /// Re-fetches every schema the user has already expanded, in place. Without this a - /// non-destructive refresh would leave those lists showing pre-refresh contents. - func refreshLoadedSchemaObjects(connectionId: UUID, driver: DatabaseDriver) async { + /// Re-fetches every schema of `scope`'s database the user has already expanded, in place. + /// Without this a non-destructive refresh would leave those lists showing pre-refresh contents. + func refreshLoadedSchemaObjects(in scope: DatabaseScope, driver: DatabaseDriver) async { /// A schema still loading is reloaded too. Its fetch may have begun before the change this /// refresh answers, and reloading moves its generation so that fetch cannot commit. - let loadedSchemas = (perSchemaStates[connectionId] ?? [:]).compactMap { schema, state -> String? in + let loadedSchemas = perSchemaStates.compactMap { key, state -> String? in + guard key.connectionId == scope.connectionId, key.database == scope.database else { return nil } switch state { - case .loaded, .loading: return schema + case .loaded, .loading: return key.schema case .idle, .failed: return nil } } - for schema in loadedSchemas { - await reloadSchemaObjects(connectionId: connectionId, schema: schema, driver: driver) + for schema in loadedSchemas.sorted() { + await reloadSchemaObjects(schema: schema, in: scope, driver: driver) } } - private func runSchemaLoad(connectionId: UUID, schema: String, driver: DatabaseDriver) async { - let key = SchemaKey(connectionId: connectionId, schema: schema) + private func runSchemaLoad(_ key: SchemaKey, driver: DatabaseDriver) async { + let connectionId = key.connectionId + let schema = key.schema nextLoadGeneration += 1 let generation = nextLoadGeneration schemaLoadGenerations[key] = generation let kinds = SideKinds(driver.connection.type) - if !hasLoadedContent(for: connectionId, schema: schema) { - setPerSchemaState(.loading, connectionId: connectionId, schema: schema) + if !hasLoadedContent(key) { + setPerSchemaState(.loading, key: key) } - updateSchemaSideObjects(connectionId, schema: schema) { $0 = Self.enteringLoad($0, kinds: kinds) } + updateSchemaSideObjects(key) { $0 = Self.enteringLoad($0, kinds: kinds) } bumpGeneration(connectionId) async let tablesTask: [TableInfo] = perSchemaDedup.execute(key: key) { @@ -378,14 +407,14 @@ final class SchemaService: ObservableObject { tablesOutcome = .failed(error.localizedDescription) } guard schemaLoadGenerations[key] == generation else { return } - commitSchemaTables(tablesOutcome, connectionId: connectionId, schema: schema) + commitSchemaTables(tablesOutcome, key: key) let routinesOutcome = await routinesTask let triggersOutcome = await triggersTask let typesOutcome = await typesTask guard schemaLoadGenerations[key] == generation else { return } schemaLoadGenerations.removeValue(forKey: key) - updateSchemaSideObjects(connectionId, schema: schema) { side in + updateSchemaSideObjects(key) { side in side = Self.settled( side, routines: routinesOutcome, @@ -397,28 +426,43 @@ final class SchemaService: ObservableObject { bumpGeneration(connectionId) } - private func commitSchemaTables( - _ outcome: MetadataFetchOutcome<[TableInfo]>, - connectionId: UUID, - schema: String - ) { + private func commitSchemaTables(_ outcome: MetadataFetchOutcome<[TableInfo]>, key: SchemaKey) { switch outcome { case .fetched(let tables): - setPerSchemaState(.loaded(tables), connectionId: connectionId, schema: schema) + setPerSchemaState(.loaded(tables), key: key) case .failed(let message): - guard !hasLoadedContent(for: connectionId, schema: schema) else { return } - setPerSchemaState(.failed(message), connectionId: connectionId, schema: schema) + guard !hasLoadedContent(key) else { return } + setPerSchemaState(.failed(message), key: key) case .cancelled: - guard case .loading = schemaState(for: connectionId, schema: schema) else { return } - setPerSchemaState(.idle, connectionId: connectionId, schema: schema) + guard case .loading = perSchemaStates[key] else { return } + setPerSchemaState(.idle, key: key) } } - private func setPerSchemaState(_ state: SchemaState, connectionId: UUID, schema: String) { - var inner = perSchemaStates[connectionId] ?? [:] - inner[schema] = state - perSchemaStates[connectionId] = inner - bumpGeneration(connectionId) + private func setPerSchemaState(_ state: SchemaState, key: SchemaKey) { + perSchemaStates[key] = state + bumpGeneration(key.connectionId) + } + + private func cancelSchemaLoads(where shouldCancel: @escaping @Sendable (SchemaKey) -> Bool) async { + await perSchemaDedup.cancel(where: shouldCancel) + await perSchemaRoutinesDedup.cancel(where: shouldCancel) + await perSchemaTriggersDedup.cancel(where: shouldCancel) + await perSchemaTypesDedup.cancel(where: shouldCancel) + } + + /// The per-schema lists of a database the connection has moved off describe nothing it shows, + /// and a fetch still running for one of them finds its generation gone and commits nothing. + private func discardSchemaObjects(of connectionId: UUID, outside database: String) async { + let isOutside: (SchemaKey) -> Bool = { $0.connectionId == connectionId && $0.database != database } + let discarded = Set(perSchemaStates.keys.filter(isOutside)) + .union(perSchemaSideObjects.keys.filter(isOutside)) + .union(schemaLoadGenerations.keys.filter(isOutside)) + guard !discarded.isEmpty else { return } + perSchemaStates = perSchemaStates.filter { !discarded.contains($0.key) } + perSchemaSideObjects = perSchemaSideObjects.filter { !discarded.contains($0.key) } + schemaLoadGenerations = schemaLoadGenerations.filter { !discarded.contains($0.key) } + await cancelSchemaLoads { discarded.contains($0) } } private func updateSideObjects(_ connectionId: UUID, _ change: (inout SideObjects) -> Void) { @@ -427,16 +471,10 @@ final class SchemaService: ObservableObject { sideObjects[connectionId] = side } - private func updateSchemaSideObjects( - _ connectionId: UUID, - schema: String, - _ change: (inout SideObjects) -> Void - ) { - var inner = perSchemaSideObjects[connectionId] ?? [:] - var side = inner[schema] ?? SideObjects() + private func updateSchemaSideObjects(_ key: SchemaKey, _ change: (inout SideObjects) -> Void) { + var side = perSchemaSideObjects[key] ?? SideObjects() change(&side) - inner[schema] = side - perSchemaSideObjects[connectionId] = inner + perSchemaSideObjects[key] = side } private func commitSideObjects( @@ -603,8 +641,8 @@ final class SchemaService: ObservableObject { states.removeValue(forKey: connectionId) sideObjects.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) - perSchemaStates.removeValue(forKey: connectionId) - perSchemaSideObjects.removeValue(forKey: connectionId) + perSchemaStates = perSchemaStates.filter { $0.key.connectionId != connectionId } + perSchemaSideObjects = perSchemaSideObjects.filter { $0.key.connectionId != connectionId } generations.removeValue(forKey: connectionId) loadedScopes.removeValue(forKey: connectionId) resumeRefreshWaiters(connectionId) @@ -810,11 +848,16 @@ final class SchemaService: ObservableObject { schemasInOrder[connectionId] = loadedSchemas } if tablesLoaded, let scope { - loadedScopes[connectionId] = scope + await adoptLoadedScope(scope) } bumpGeneration(connectionId) } + private func adoptLoadedScope(_ scope: DatabaseScope) async { + loadedScopes[scope.connectionId] = scope + await discardSchemaObjects(of: scope.connectionId, outside: scope.database) + } + private func runHierarchicalLoad( loadKey: LoadKey, driver: DatabaseDriver, @@ -895,7 +938,7 @@ final class SchemaService: ObservableObject { ) states[connectionId] = .loaded([]) if let scope { - loadedScopes[connectionId] = scope + await adoptLoadedScope(scope) } bumpGeneration(connectionId) } @@ -993,3 +1036,9 @@ final class SchemaService: ObservableObject { } } } + +extension SchemaService.SchemaKey { + init(scope: DatabaseScope, schema: String) { + self.init(connectionId: scope.connectionId, database: scope.database, schema: schema) + } +} diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4482e22ccf..884a4c052b 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -428,8 +428,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { loadedFrom: loadedScope?.database, coveredSchemas: coveredSchemas(loadedScope: loadedScope, grouping: tableSource.grouping), listing: listing, - browsing: tableSource.database, - grouping: tableSource.grouping + browsing: tableSource.database ) return Self.makeTableItems( tables, @@ -646,21 +645,14 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// `coveredSchemas` names the schemas the schema service answers for even when it found them /// empty. Judged from its rows alone, a schema whose last table was dropped would have no rows, /// so no say, and the listing's stale copy of that table would come back. - /// - /// A hierarchical engine is the exception. Its per-schema lists are keyed by schema alone and - /// keep the rows of a database the connection has just switched away from until each one - /// reloads, so once the listing, which is keyed by database, has arrived it answers for every - /// schema, and the schema service only stands in until then. nonisolated static func mergedTables( local loaded: [TableInfo], loadedFrom loadedDatabase: String?, coveredSchemas: Set, listing: [TableInfo]?, - browsing database: String?, - grouping: GroupingStrategy + browsing database: String? ) -> [TableInfo] { - let listingAnswersAll = grouping == .hierarchicalSchema && listing != nil - let isCurrent = loadedDatabase == database && !listingAnswersAll + let isCurrent = loadedDatabase == database let local = isCurrent ? loaded : [] let authoritative = (isCurrent ? coveredSchemas : []).union(local.map { $0.schema ?? "" }) var seen: Set = [] diff --git a/TableProTests/Core/Services/Query/SchemaServiceTests.swift b/TableProTests/Core/Services/Query/SchemaServiceTests.swift index e3646c226d..54bcdf1e5f 100644 --- a/TableProTests/Core/Services/Query/SchemaServiceTests.swift +++ b/TableProTests/Core/Services/Query/SchemaServiceTests.swift @@ -13,6 +13,10 @@ import Testing @Suite("SchemaService") @MainActor struct SchemaServiceTests { + private func unnamedDatabase(_ connectionId: UUID) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "", schema: nil) + } + @Test("allLoadedTables unions tables across loaded per-schema lists") func allLoadedTablesUnionsPerSchema() async { let connectionId = UUID() @@ -28,8 +32,8 @@ struct SchemaServiceTests { ] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver) - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "sales", in: unnamedDatabase(connectionId), driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) let names = Set(service.allLoadedTables(for: connectionId).map(\.name)) #expect(names == ["orders", "leads", "employees"]) @@ -46,8 +50,8 @@ struct SchemaServiceTests { ] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver) - await service.loadSchemaObjects(connectionId: connectionId, schema: "mirror", driver: driver) + await service.loadSchemaObjects(schema: "sales", in: unnamedDatabase(connectionId), driver: driver) + await service.loadSchemaObjects(schema: "mirror", in: unnamedDatabase(connectionId), driver: driver) let matching = service.allLoadedTables(for: connectionId).filter { $0.id == shared.id } #expect(matching.count == 1) @@ -63,8 +67,8 @@ struct SchemaServiceTests { ] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "a", driver: driver) - await service.loadSchemaObjects(connectionId: connectionId, schema: "a.b", driver: driver) + await service.loadSchemaObjects(schema: "a", in: unnamedDatabase(connectionId), driver: driver) + await service.loadSchemaObjects(schema: "a.b", in: unnamedDatabase(connectionId), driver: driver) let loaded = service.allLoadedTables(for: connectionId) #expect(loaded.count == 2) diff --git a/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift b/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift new file mode 100644 index 0000000000..d53329c53c --- /dev/null +++ b/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift @@ -0,0 +1,264 @@ +// +// SchemaServiceDatabaseSwitchTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class DatabaseCatalogDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var schemasToReturn: [String] = [] + var tablesBySchema: [String: [TableInfo]] = [:] + var tablesError: Error? + private(set) var tableFetches: [String] = [] + + var pausesNextTableFetch = false + var onTableFetchPaused: (@Sendable () -> Void)? + private var tableFetchGate: CheckedContinuation? + + init(connection: DatabaseConnection) { + self.connection = connection + } + + func resumeTableFetch() { + tableFetchGate?.resume() + tableFetchGate = nil + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchSchemas() async throws -> [String] { + schemasToReturn + } + + func fetchTables() async throws -> [TableInfo] { [] } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let schema = schema ?? "" + tableFetches.append(schema) + if let tablesError { throw tablesError } + let snapshot = tablesBySchema[schema] ?? [] + if pausesNextTableFetch { + pausesNextTableFetch = false + await withCheckedContinuation { continuation in + tableFetchGate = continuation + onTableFetchPaused?() + } + } + return snapshot + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} + +/// Snowflake and Trino change database on a live connection, and a schema name such as `PUBLIC` +/// exists in every database they reach. +@Suite("SchemaService database switch") +@MainActor +struct SchemaServiceDatabaseSwitchTests { + private let connectionId = UUID() + private let boom = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) + + private var connection: DatabaseConnection { + TestFixtures.makeConnection(id: connectionId, type: .snowflake) + } + + private func scope(_ database: String) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: database, schema: nil) + } + + private func table(_ name: String, _ schema: String) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + private func driver(schemas: [String], tables: [String: [TableInfo]] = [:]) -> DatabaseCatalogDriver { + let driver = DatabaseCatalogDriver(connection: connection) + driver.schemasToReturn = schemas + driver.tablesBySchema = tables + return driver + } + + private func browse(_ service: SchemaService, database: String, driver: DatabaseCatalogDriver) async { + await service.reload(connectionId: connectionId, driver: driver, connection: connection, scope: scope(database)) + } + + private func loadObjects( + _ service: SchemaService, + schema: String, + database: String, + driver: DatabaseCatalogDriver + ) async { + await service.loadSchemaObjects(schema: schema, in: scope(database), driver: driver) + } + + private func refreshObjects(_ service: SchemaService, database: String, driver: DatabaseCatalogDriver) async { + await service.refreshLoadedSchemaObjects(in: scope(database), driver: driver) + } + + private func sales() -> DatabaseCatalogDriver { + driver(schemas: ["PUBLIC", "LEDGER"], tables: [ + "PUBLIC": [table("ORDERS", "PUBLIC")], + "LEDGER": [table("ENTRIES", "LEDGER")] + ]) + } + + private func marketing() -> DatabaseCatalogDriver { + driver(schemas: ["PUBLIC"], tables: ["PUBLIC": [table("CAMPAIGNS", "PUBLIC")]]) + } + + @Test("A schema of the database switched to never shows the tables of the one left") + func switchDoesNotCarryTablesAcross() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + #expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"]) + + await browse(service, database: "MARKETING", driver: marketing()) + + #expect(service.schemas(for: connectionId) == ["PUBLIC"]) + #expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty) + #expect(service.schemaState(for: connectionId, schema: "PUBLIC") == .idle) + #expect(service.routinesLoadState(for: connectionId, schema: "PUBLIC") == .idle) + #expect(service.allLoadedTables(for: connectionId).isEmpty) + #expect(service.schemasWithLoadedTables(for: connectionId).isEmpty) + } + + @Test("A schema whose load fails after a switch reports the failure, not the old database") + func failedLoadAfterSwitchShowsNoOldTables() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + let marketingDriver = marketing() + await browse(service, database: "MARKETING", driver: marketingDriver) + + marketingDriver.tablesError = boom + await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver) + + #expect(marketingDriver.tableFetches == ["PUBLIC"]) + #expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty) + #expect(service.schemaState(for: connectionId, schema: "PUBLIC") == .failed("boom")) + } + + @Test("Refreshing after a switch reads nothing on behalf of the database left") + func refreshAfterSwitchReadsNothingForTheOldDatabase() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "LEDGER", database: "SALES", driver: salesDriver) + let marketingDriver = marketing() + await browse(service, database: "MARKETING", driver: marketingDriver) + + await refreshObjects(service, database: "MARKETING", driver: marketingDriver) + + #expect(marketingDriver.tableFetches.isEmpty) + #expect(service.tables(for: connectionId, schema: "LEDGER").isEmpty) + } + + @Test("A load for the database left that finishes after the switch is not shown") + func lateLoadFromTheOldDatabaseIsDiscarded() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + + salesDriver.pausesNextTableFetch = true + var late: Task? + await withCheckedContinuation { (paused: CheckedContinuation) in + salesDriver.onTableFetchPaused = { paused.resume() } + late = Task { await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) } + } + await browse(service, database: "MARKETING", driver: marketing()) + salesDriver.resumeTableFetch() + await late?.value + + #expect(service.tables(for: connectionId, schema: "PUBLIC").isEmpty) + #expect(service.allLoadedTables(for: connectionId).isEmpty) + } + + /// The sidebar reads the database being switched to as soon as the switch is made, while the + /// schema list of the one being left is still on screen. + @Test("Objects loaded for the new database before its schema list arrives are kept") + func loadForTheNewDatabaseDuringTheSwitchIsKept() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + let marketingDriver = marketing() + + await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver) + #expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"]) + + await browse(service, database: "MARKETING", driver: marketingDriver) + await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver) + + #expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["CAMPAIGNS"]) + #expect(marketingDriver.tableFetches == ["PUBLIC"]) + } + + @Test("Switching back lists the first database's objects again once they are loaded") + func switchingBackReadsTheFirstDatabase() async { + let service = SchemaService() + let salesDriver = sales() + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + let marketingDriver = marketing() + await browse(service, database: "MARKETING", driver: marketingDriver) + await loadObjects(service, schema: "PUBLIC", database: "MARKETING", driver: marketingDriver) + + await browse(service, database: "SALES", driver: salesDriver) + await loadObjects(service, schema: "PUBLIC", database: "SALES", driver: salesDriver) + + #expect(service.tables(for: connectionId, schema: "PUBLIC").map(\.name) == ["ORDERS"]) + #expect(salesDriver.tableFetches == ["PUBLIC", "PUBLIC"]) + } +} diff --git a/TableProTests/Services/SchemaServiceHierarchicalTests.swift b/TableProTests/Services/SchemaServiceHierarchicalTests.swift index 39437283c8..4aaaac7750 100644 --- a/TableProTests/Services/SchemaServiceHierarchicalTests.swift +++ b/TableProTests/Services/SchemaServiceHierarchicalTests.swift @@ -88,6 +88,10 @@ struct SchemaServiceHierarchicalTests { TableInfo(name: name, type: .table, rowCount: nil, schema: schema) } + private func unnamedDatabase(_ connectionId: UUID) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "", schema: nil) + } + @Test("BigQuery resolves to hierarchicalSchema grouping while Postgres stays bySchema") func groupingStrategyResolution() { #expect(PluginManager.shared.databaseGroupingStrategy(for: .bigQuery) == .hierarchicalSchema) @@ -104,7 +108,7 @@ struct SchemaServiceHierarchicalTests { "marketing": [bigQueryTable("campaigns", schema: "marketing")] ] - await service.loadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) + await service.loadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) #expect(service.tables(for: connectionId, schema: "analytics").map(\.name) == ["events", "sessions"]) #expect(service.tables(for: connectionId, schema: "marketing").isEmpty) @@ -119,8 +123,8 @@ struct SchemaServiceHierarchicalTests { let driver = HierarchicalMockDriver() driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] - await service.loadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) - await service.loadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) + await service.loadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) + await service.loadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) #expect(driver.fetchTablesCallCount["analytics"] == 1) } @@ -132,12 +136,12 @@ struct SchemaServiceHierarchicalTests { let driver = HierarchicalMockDriver() driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] - await service.loadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) + await service.loadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) driver.tablesBySchema["analytics"] = [ bigQueryTable("events", schema: "analytics"), bigQueryTable("clicks", schema: "analytics") ] - await service.reloadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) + await service.reloadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) #expect(driver.fetchTablesCallCount["analytics"] == 2) #expect(service.tables(for: connectionId, schema: "analytics").map(\.name) == ["events", "clicks"]) @@ -169,7 +173,7 @@ struct SchemaServiceHierarchicalTests { let driver = HierarchicalMockDriver() driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] - await service.loadSchemaObjects(connectionId: connectionId, schema: "analytics", driver: driver) + await service.loadSchemaObjects(schema: "analytics", in: unnamedDatabase(connectionId), driver: driver) #expect(!service.tables(for: connectionId, schema: "analytics").isEmpty) await service.invalidate(connectionId: connectionId) diff --git a/TableProTests/Services/SchemaServiceRefreshTests.swift b/TableProTests/Services/SchemaServiceRefreshTests.swift index 6655724ddb..a5b656c757 100644 --- a/TableProTests/Services/SchemaServiceRefreshTests.swift +++ b/TableProTests/Services/SchemaServiceRefreshTests.swift @@ -340,11 +340,12 @@ struct SchemaServiceRefreshTests { driver.schemaTablesToReturn = ["sales": [TableInfo(name: "orders", type: .table, rowCount: 0, schema: "sales")]] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver) + let scope = DatabaseScope(connectionId: connectionId, database: "", schema: nil) + await service.loadSchemaObjects(schema: "sales", in: scope, driver: driver) #expect(service.tables(for: connectionId, schema: "sales").map(\.name) == ["orders"]) driver.schemaTablesError = DatabaseError.notConnected - await service.reloadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver) + await service.reloadSchemaObjects(schema: "sales", in: scope, driver: driver) #expect(service.tables(for: connectionId, schema: "sales").map(\.name) == ["orders"]) } @@ -360,13 +361,14 @@ struct SchemaServiceRefreshTests { ] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "sales", driver: driver) + let scope = DatabaseScope(connectionId: connectionId, database: "", schema: nil) + await service.loadSchemaObjects(schema: "sales", in: scope, driver: driver) driver.schemaTablesToReturn["sales"] = [ TableInfo(name: "orders", type: .table, rowCount: 0, schema: "sales"), TableInfo(name: "refunds", type: .table, rowCount: 0, schema: "sales") ] - await service.refreshLoadedSchemaObjects(connectionId: connectionId, driver: driver) + await service.refreshLoadedSchemaObjects(in: scope, driver: driver) #expect(service.tables(for: connectionId, schema: "sales").map(\.name) == ["orders", "refunds"]) #expect(service.tables(for: connectionId, schema: "hr").isEmpty) diff --git a/TableProTests/Services/SchemaServiceSideObjectsTests.swift b/TableProTests/Services/SchemaServiceSideObjectsTests.swift index fb813e8acc..9a41752c95 100644 --- a/TableProTests/Services/SchemaServiceSideObjectsTests.swift +++ b/TableProTests/Services/SchemaServiceSideObjectsTests.swift @@ -106,6 +106,10 @@ struct SchemaServiceSideObjectsTests { return (connectionId, connection, SideObjectsMockDriver(connection: connection)) } + private func unnamedDatabase(_ connectionId: UUID) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "", schema: nil) + } + private func procedure(_ name: String, schema: String = "public") -> RoutineInfo { RoutineInfo(name: name, kind: .procedure, schema: schema) } @@ -248,7 +252,7 @@ struct SchemaServiceSideObjectsTests { ] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) #expect(service.tables(for: connectionId, schema: "hr").isEmpty) #expect(service.routines(for: connectionId, schema: "hr").map(\.name) == ["raise_salary"]) @@ -267,7 +271,7 @@ struct SchemaServiceSideObjectsTests { driver.routinesError = boom let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) #expect(service.tables(for: connectionId, schema: "hr").map(\.name) == ["pay"]) #expect(service.routinesLoadState(for: connectionId, schema: "hr") == .failed("boom")) @@ -281,7 +285,7 @@ struct SchemaServiceSideObjectsTests { driver.routinesError = boom let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) #expect(service.hasLoadedContent(for: connectionId, schema: "hr")) #expect(!service.isSchemaSettled(for: connectionId, schema: "hr")) @@ -292,10 +296,10 @@ struct SchemaServiceSideObjectsTests { let (connectionId, _, driver) = makeDriver() driver.routinesBySchema["hr"] = [procedure("raise_salary", schema: "hr")] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) driver.routinesError = boom - await service.reloadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.reloadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) #expect(service.routines(for: connectionId, schema: "hr").map(\.name) == ["raise_salary"]) } @@ -305,7 +309,7 @@ struct SchemaServiceSideObjectsTests { let (connectionId, _, driver) = makeDriver() driver.routinesBySchema["hr"] = [procedure("raise_salary", schema: "hr")] let service = SchemaService() - await service.loadSchemaObjects(connectionId: connectionId, schema: "hr", driver: driver) + await service.loadSchemaObjects(schema: "hr", in: unnamedDatabase(connectionId), driver: driver) await service.invalidate(connectionId: connectionId) diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index df157b77e5..be4f1034e0 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -105,8 +105,7 @@ struct QuickSwitcherCrossSchemaTests { let allSchemas = [table("users", "public"), table("dropped", "public"), table("timesheet", "attendance")] let merged = QuickSwitcherViewModel.mergedTables( - local: local, loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop", - grouping: .bySchema + local: local, loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop" ) #expect(merged.map(\.name) == ["users", "timesheet"]) @@ -119,8 +118,7 @@ struct QuickSwitcherCrossSchemaTests { let allSchemas = [table("users", "public"), table("timesheet", "attendance")] let merged = QuickSwitcherViewModel.mergedTables( - local: [], loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop", - grouping: .bySchema + local: [], loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop" ) #expect(merged.map(\.name) == ["timesheet"]) @@ -131,8 +129,7 @@ struct QuickSwitcherCrossSchemaTests { let allSchemas = [table("timesheet", "attendance"), table("timesheet", "attendance")] let merged = QuickSwitcherViewModel.mergedTables( - local: [], loadedFrom: "shop", coveredSchemas: [], listing: allSchemas, browsing: "shop", - grouping: .bySchema + local: [], loadedFrom: "shop", coveredSchemas: [], listing: allSchemas, browsing: "shop" ) #expect(merged.count == 1) @@ -147,38 +144,35 @@ struct QuickSwitcherCrossSchemaTests { loadedFrom: "billing", coveredSchemas: ["public"], listing: [table("orders", "public"), table("timesheet", "attendance")], - browsing: "shop", - grouping: .bySchema + browsing: "shop" ) #expect(merged.map(\.name) == ["orders", "timesheet"]) } - /// A hierarchical engine keys its per-schema lists by schema alone, so after a database switch - /// they can still hold the database the connection left while the listing names the new one. - @Test("On a hierarchical engine the listing answers for every schema once it has arrived") - func hierarchicalListingWins() { + /// Snowflake's `PUBLIC` lost its last table: the schema service reloaded it as empty, and the + /// listing still holds the table until it is next asked. + @Test("A schema a hierarchical engine loaded keeps the listing's stale rows out") + func hierarchicalCoveredSchemaKeepsStaleRowsOut() { let merged = QuickSwitcherViewModel.mergedTables( - local: [table("OLD_ORDERS", "PUBLIC")], + local: [table("ENTRIES", "LEDGER")], loadedFrom: "SALES", - coveredSchemas: ["PUBLIC"], - listing: [table("ORDERS", "PUBLIC")], - browsing: "SALES", - grouping: .hierarchicalSchema + coveredSchemas: ["PUBLIC", "LEDGER"], + listing: [table("ORDERS", "PUBLIC"), table("ENTRIES", "LEDGER"), table("RATES", "FX")], + browsing: "SALES" ) - #expect(merged.map(\.name) == ["ORDERS"]) + #expect(merged.map(\.name) == ["ENTRIES", "RATES"]) } - @Test("On a hierarchical engine the schema service stands in until the listing arrives") - func hierarchicalBeforeListing() { + @Test("The schema service stands in until the listing arrives") + func schemaServiceBeforeListing() { let merged = QuickSwitcherViewModel.mergedTables( local: [table("ORDERS", "PUBLIC")], loadedFrom: "SALES", coveredSchemas: ["PUBLIC"], listing: nil, - browsing: "SALES", - grouping: .hierarchicalSchema + browsing: "SALES" ) #expect(merged.map(\.name) == ["ORDERS"])