diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b769e7bd..2ef5621633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Other-schema tables for Open Quickly and the sidebar filter read in one query on SQL Server. - Other-schema tables for Open Quickly and the sidebar filter read in one query on DuckDB files. - Tables and views from every schema in the MCP `search_schema` tool when no schema is named. (#3048) +- Sidebar filter on Oracle, Snowflake and BigQuery matching procedures, triggers and types only in schemas already read. ### Removed @@ -103,6 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stale column and MongoDB field suggestions when a refresh ran while they were loading. - 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. +- Hundreds of catalog queries from one keystroke in the sidebar filter on Oracle, Snowflake and BigQuery. +- Every schema a search had opened read again after each commit on Oracle, Snowflake and BigQuery. - 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/Concurrency/CatalogFreshness.swift b/TablePro/Core/Concurrency/CatalogFreshness.swift index c2b4384032..2a54545af5 100644 --- a/TablePro/Core/Concurrency/CatalogFreshness.swift +++ b/TablePro/Core/Concurrency/CatalogFreshness.swift @@ -15,6 +15,7 @@ import Foundation struct CatalogFreshness { private var revisions: [Key: Int] = [:] private var committed: [Key: Int] = [:] + private var started: [Key: Int] = [:] func revision(for key: Key) -> Int { revisions[key, default: 0] @@ -24,6 +25,22 @@ struct CatalogFreshness { committed[key] == revision(for: key) } + /// A read that finds nothing current asks for one fetch per revision. One that failed is not + /// asked for again until the next change, or every reader observing the failure would repeat it. + func needsFetch(_ key: Key) -> Bool { + !isCurrent(key) && started[key] != revision(for: key) + } + + mutating func noteFetchStarted(_ revision: Int, for key: Key) { + started[key] = revision + } + + /// A fetch cut short answered nothing, so the next read may ask again at the same revision. + mutating func noteFetchAbandoned(_ revision: Int, for key: Key) { + guard started[key] == revision else { return } + started.removeValue(forKey: key) + } + mutating func markChanged(_ key: Key) { revisions[key, default: 0] &+= 1 } @@ -39,5 +56,6 @@ struct CatalogFreshness { mutating func removeAll(where shouldRemove: (Key) -> Bool) { revisions = revisions.filter { !shouldRemove($0.key) } committed = committed.filter { !shouldRemove($0.key) } + started = started.filter { !shouldRemove($0.key) } } } diff --git a/TablePro/Core/Services/Query/CatalogEditAdoption.swift b/TablePro/Core/Services/Query/CatalogEditAdoption.swift index eff5b3591b..d71e2326e2 100644 --- a/TablePro/Core/Services/Query/CatalogEditAdoption.swift +++ b/TablePro/Core/Services/Query/CatalogEditAdoption.swift @@ -179,19 +179,24 @@ struct CatalogEditAdoption { let loadedScope = schemaService.loadedScope(for: connectionId) else { return nil } let browseDatabase = databaseManager.browseDatabaseName(for: session.connection) guard loadedScope.database == browseDatabase else { return nil } - var schemas = Set(schemaService.schemas(for: connectionId).filter { - schemaService.hasLoadedContent(for: connectionId, schema: $0) - }) - if let schema = loadedScope.schema { + var schemas = schemaService.schemasWithCurrentTables(for: connectionId) + if let schema = loadedScope.schema, holdsBrowsedSchemaInFlatList(session.connection.type) { schemas.insert(schema) } return LoadedBrowseCatalog( database: browseDatabase, schemas: schemas, - tables: schemaService.allLoadedTables(for: connectionId) + tables: schemaService.currentTables(for: connectionId) ) } + /// A schema-grouped engine's flat list is the browsed schema's, so that schema is answered for + /// even when it holds nothing. A hierarchical engine's flat list is empty, and its browsed schema + /// is answered for only by a per-schema list read since the last catalog change. + private func holdsBrowsedSchemaInFlatList(_ type: DatabaseType) -> Bool { + PluginManager.shared.databaseGroupingStrategy(for: type) != .hierarchicalSchema + } + /// Unstages queued operations whose object the freshly loaded catalog no longer has, judged by /// the object each one names rather than by a bare table name. func pruneStaleOperations(connectionId: UUID) { diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 278ba530fc..50c738dbe5 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -299,6 +299,7 @@ final class SchemaRefreshService { guard let scope = browseScope else { throw DatabaseError.notConnected } + let awaitedSchemas = schemasAwaitingJudgement(in: scope) try await metadataDriverProvider.withMetadataDriver( scope: scope, workload: .bulk @@ -309,7 +310,11 @@ final class SchemaRefreshService { connection: connection, scope: scope ) - await schemaService.refreshLoadedSchemaObjects(in: scope, driver: driver) + await schemaService.refreshLoadedSchemaObjects( + in: scope, + fetchingNow: awaitedSchemas, + driver: driver + ) } } catch is CancellationError { return @@ -329,6 +334,20 @@ final class SchemaRefreshService { } await syncAutocompleteProvider(connectionId: connectionId) } + + /// The schemas judged against the refreshed catalog as soon as it settles: the browsed one, and + /// every one holding a queued truncate or drop, which a catalog change prunes when it finishes. + private func schemasAwaitingJudgement(in scope: DatabaseScope) -> Set { + var schemas = Set([scope.schema].compactMap { $0 }) + guard let session = databaseManager?.session(for: scope.connectionId) else { return schemas } + for ref in session.pendingTruncates.union(session.pendingDeletes) { + guard (ref.database ?? scope.database) == scope.database, let schema = ref.qualifyingSchema else { + continue + } + schemas.insert(schema) + } + return schemas + } } /// The browse scope's object list, routines, triggers, types and schema list, which is everything diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index bb637f4283..9480eac4e3 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -42,10 +42,10 @@ final class SchemaService: ObservableObject { private let triggersDedup = OnceTask() private let typesDedup = OnceTask() private let schemasDedup = OnceTask() - private let perSchemaDedup = OnceTask() - private let perSchemaRoutinesDedup = OnceTask() - private let perSchemaTriggersDedup = OnceTask() - private let perSchemaTypesDedup = OnceTask() + private let perSchemaDedup = OnceTask() + private let perSchemaRoutinesDedup = OnceTask() + 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. @@ -55,6 +55,13 @@ final class SchemaService: ObservableObject { let schema: String } + /// A read after a catalog change starts a fetch of its own rather than joining one that began + /// before the change. + struct SchemaFetchKey: Hashable, Sendable { + let schemaKey: SchemaKey + let revision: Int + } + /// Two windows browsing the same scope share one fetch; two windows browsing different /// scopes must not, or the second stamps the first's tables with its own scope. Every /// object kind is keyed this way, because a routine, trigger, type or schema list fetched @@ -83,6 +90,7 @@ final class SchemaService: ObservableObject { private var loadGenerations: [UUID: Int] = [:] private var schemaLoadGenerations: [SchemaKey: Int] = [:] + private var schemaFreshness = CatalogFreshness() private var refreshWaiters: [UUID: [RefreshWaiter]] = [:] private var nextLoadGeneration = 0 nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") @@ -266,13 +274,39 @@ final class SchemaService: ObservableObject { }) } + /// The loaded schemas read since the last catalog change. A list read before it may still hold + /// a table that was dropped or lack one that was created, so it cannot say whether one exists. + func schemasWithCurrentTables(for connectionId: UUID) -> Set { + schemasWithLoadedTables(for: connectionId).filter { isSchemaCurrent(for: connectionId, schema: $0) } + } + + func isSchemaCurrent(for connectionId: UUID, schema: String) -> Bool { + schemaFreshness.isCurrent(catalogKey(connectionId, schema: schema)) + } + + /// Whether a reader showing this schema should fetch it: it was never read, or a catalog change + /// has overtaken what was, and no fetch has started since. + func schemaObjectsNeedFetch(for connectionId: UUID, schema: String) -> Bool { + schemaFreshness.needsFetch(catalogKey(connectionId, schema: schema)) + } + /// Flat tables plus the union of every loaded per-schema table list. For /// hierarchicalSchema plugins the flat list is empty and this is the only /// way to see tables across schemas (e.g. for autocomplete). func allLoadedTables(for connectionId: UUID) -> [TableInfo] { + loadedTables(for: connectionId) { _ in true } + } + + /// `allLoadedTables` without the lists a catalog change has overtaken, for a caller judging + /// whether an object still exists. + func currentTables(for connectionId: UUID) -> [TableInfo] { + loadedTables(for: connectionId) { self.isSchemaCurrent(for: connectionId, schema: $0) } + } + + private func loadedTables(for connectionId: UUID, inSchemas includes: (String) -> Bool) -> [TableInfo] { var result = tables(for: connectionId) var seen = Set(result.map(\.id)) - for state in catalogEntries(perSchemaStates, of: connectionId).values { + for (schema, state) in catalogEntries(perSchemaStates, of: connectionId) where includes(schema) { guard case .loaded(let schemaTables) = state else { continue } for table in schemaTables where seen.insert(table.id).inserted { result.append(table) @@ -287,7 +321,7 @@ final class SchemaService: ObservableObject { /// opened a pooled connection per schema, one for every schema a sidebar search walked. func loadSchemaObjects(connectionId: UUID, schema: String, database: String?) async { guard let scope = schemaRouteScope(connectionId: connectionId, database: database) else { return } - guard !hasLoadedContent(SchemaKey(scope: scope, schema: schema)) else { return } + guard !schemaFreshness.isCurrent(SchemaKey(scope: scope, schema: schema)) else { return } await withSchemaMetadataDriver(scope: scope, schema: schema) { driver in await self.loadSchemaObjects(schema: schema, in: scope, driver: driver) } @@ -304,57 +338,82 @@ final class SchemaService: ObservableObject { DatabaseManager.shared.resolvedScope(database: database, schema: nil, for: connectionId) } + /// The fetch counts as started from the moment it asks for a driver, so a reader redrawing while + /// the lease is pending does not ask for a second one. private func withSchemaMetadataDriver( scope: DatabaseScope, schema: String, _ body: @Sendable @escaping (DatabaseDriver) async -> Void ) async { + let key = SchemaKey(scope: scope, schema: schema) + let revision = schemaFreshness.revision(for: key) + schemaFreshness.noteFetchStarted(revision, for: key) do { try await DatabaseManager.shared.withMetadataDriver(scope: scope, workload: .bulk, body) } catch is CancellationError { - return + schemaFreshness.noteFetchAbandoned(revision, for: key) } catch { Self.logger.warning( "[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), key: SchemaKey(scope: scope, schema: schema)) + settleSchemaFailure(error.localizedDescription, key: key) } } /// `scope` names the database `driver` reads, which is the database the objects are kept under. + /// A schema already read since the last catalog change is not read again. func loadSchemaObjects(schema: String, in scope: DatabaseScope, driver: DatabaseDriver) async { let key = SchemaKey(scope: scope, schema: schema) - guard !hasLoadedContent(key) else { return } + guard !schemaFreshness.isCurrent(key) else { return } await runSchemaLoad(key, driver: driver) } func reloadSchemaObjects(schema: String, in scope: DatabaseScope, driver: DatabaseDriver) async { let key = SchemaKey(scope: scope, schema: schema) - schemaLoadGenerations.removeValue(forKey: key) - await cancelSchemaLoads { $0 == key } + schemaFreshness.markChanged(key) await runSchemaLoad(key, driver: driver) } - /// 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.compactMap { key, state -> String? in - guard key.connectionId == scope.connectionId, key.database == scope.database else { return nil } - switch state { - case .loaded, .loading: return key.schema - case .idle, .failed: return nil - } + /// Marks every schema the connection has loaded as overtaken by a catalog change, keeping what + /// each one shows, and reads again now only `fetchingNow`, the schemas something is about to + /// judge. Every other schema is read by its next reader: an expanded tree row, or a caller of + /// `loadSchemaObjects`. Reading them all here re-ran two to four queries per schema on every + /// COMMIT, for lists nobody was looking at. + func refreshLoadedSchemaObjects( + in scope: DatabaseScope, + fetchingNow schemas: Set, + driver: DatabaseDriver + ) async { + markLoadedSchemaObjectsStale(connectionId: scope.connectionId) + for schema in schemas.sorted() where holdsOrIsLoading(SchemaKey(scope: scope, schema: schema)) { + await loadSchemaObjects(schema: schema, in: scope, driver: driver) } - for schema in loadedSchemas.sorted() { - await reloadSchemaObjects(schema: schema, in: scope, driver: driver) + } + + /// A schema still loading is marked too: its fetch may have begun before the change, so what it + /// brings back is shown but not taken as current. + func markLoadedSchemaObjectsStale(connectionId: UUID) { + let marked = perSchemaStates.keys.filter { $0.connectionId == connectionId && holdsOrIsLoading($0) } + guard !marked.isEmpty else { return } + for key in marked { + schemaFreshness.markChanged(key) + } + bumpGeneration(connectionId) + } + + private func holdsOrIsLoading(_ key: SchemaKey) -> Bool { + switch perSchemaStates[key] { + case .loaded, .loading: return true + case .idle, .failed, nil: return false } } private func runSchemaLoad(_ key: SchemaKey, driver: DatabaseDriver) async { let connectionId = key.connectionId let schema = key.schema + let revision = schemaFreshness.revision(for: key) + let fetchKey = SchemaFetchKey(schemaKey: key, revision: revision) + schemaFreshness.noteFetchStarted(revision, for: key) nextLoadGeneration += 1 let generation = nextLoadGeneration schemaLoadGenerations[key] = generation @@ -365,12 +424,13 @@ final class SchemaService: ObservableObject { } updateSchemaSideObjects(key) { $0 = Self.enteringLoad($0, kinds: kinds) } bumpGeneration(connectionId) + await cancelSchemaLoads { $0.schemaKey == key && $0.revision != revision } - async let tablesTask: [TableInfo] = perSchemaDedup.execute(key: key) { + async let tablesTask: [TableInfo] = perSchemaDedup.execute(key: fetchKey) { try await driver.fetchTables(schema: schema) } async let routinesTask: MetadataFetchOutcome<[RoutineInfo]> = Self.fetchObjectsSafely( - key: key, + key: fetchKey, connectionId: connectionId, label: "schema routines", dedup: perSchemaRoutinesDedup, @@ -378,7 +438,7 @@ final class SchemaService: ObservableObject { ) async let triggersTask: MetadataFetchOutcome<[TriggerInfo]>? = kinds.triggers ? Self.fetchObjectsSafely( - key: key, + key: fetchKey, connectionId: connectionId, label: "schema triggers", dedup: perSchemaTriggersDedup, @@ -387,7 +447,7 @@ final class SchemaService: ObservableObject { : nil async let typesTask: MetadataFetchOutcome<[UserDefinedTypeInfo]>? = kinds.types ? Self.fetchObjectsSafely( - key: key, + key: fetchKey, connectionId: connectionId, label: "schema types", dedup: perSchemaTypesDedup, @@ -407,7 +467,7 @@ final class SchemaService: ObservableObject { tablesOutcome = .failed(error.localizedDescription) } guard schemaLoadGenerations[key] == generation else { return } - commitSchemaTables(tablesOutcome, key: key) + commitSchemaTables(tablesOutcome, key: key, revision: revision) let routinesOutcome = await routinesTask let triggersOutcome = await triggersTask @@ -426,25 +486,31 @@ final class SchemaService: ObservableObject { bumpGeneration(connectionId) } - private func commitSchemaTables(_ outcome: MetadataFetchOutcome<[TableInfo]>, key: SchemaKey) { + private func commitSchemaTables(_ outcome: MetadataFetchOutcome<[TableInfo]>, key: SchemaKey, revision: Int) { switch outcome { case .fetched(let tables): setPerSchemaState(.loaded(tables), key: key) + _ = schemaFreshness.commit(revision, for: key) case .failed(let message): - guard !hasLoadedContent(key) else { return } - setPerSchemaState(.failed(message), key: key) + settleSchemaFailure(message, key: key) case .cancelled: + schemaFreshness.noteFetchAbandoned(revision, for: key) guard case .loading = perSchemaStates[key] else { return } setPerSchemaState(.idle, key: key) } } + private func settleSchemaFailure(_ message: String, key: SchemaKey) { + guard !hasLoadedContent(key) else { return } + setPerSchemaState(.failed(message), key: key) + } + private func setPerSchemaState(_ state: SchemaState, key: SchemaKey) { perSchemaStates[key] = state bumpGeneration(key.connectionId) } - private func cancelSchemaLoads(where shouldCancel: @escaping @Sendable (SchemaKey) -> Bool) async { + private func cancelSchemaLoads(where shouldCancel: @escaping @Sendable (SchemaFetchKey) -> Bool) async { await perSchemaDedup.cancel(where: shouldCancel) await perSchemaRoutinesDedup.cancel(where: shouldCancel) await perSchemaTriggersDedup.cancel(where: shouldCancel) @@ -462,7 +528,8 @@ final class SchemaService: ObservableObject { 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) } + schemaFreshness.removeAll { discarded.contains($0) } + await cancelSchemaLoads { discarded.contains($0.schemaKey) } } private func updateSideObjects(_ connectionId: UUID, _ change: (inout SideObjects) -> Void) { @@ -627,16 +694,14 @@ final class SchemaService: ObservableObject { await triggersDedup.cancel { $0.connectionId == connectionId } await typesDedup.cancel { $0.connectionId == connectionId } await schemasDedup.cancel { $0.connectionId == connectionId } - await perSchemaDedup.cancel { $0.connectionId == connectionId } - await perSchemaRoutinesDedup.cancel { $0.connectionId == connectionId } - await perSchemaTriggersDedup.cancel { $0.connectionId == connectionId } - await perSchemaTypesDedup.cancel { $0.connectionId == connectionId } + await cancelSchemaLoads { $0.schemaKey.connectionId == connectionId } } func invalidate(connectionId: UUID) async { await cancelInFlightLoads(connectionId: connectionId) loadGenerations.removeValue(forKey: connectionId) schemaLoadGenerations = schemaLoadGenerations.filter { $0.key.connectionId != connectionId } + schemaFreshness.removeAll { $0.connectionId == connectionId } refreshingConnections.remove(connectionId) states.removeValue(forKey: connectionId) sideObjects.removeValue(forKey: connectionId) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 884a4c052b..8af1628b2d 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -422,11 +422,14 @@ internal final class QuickSwitcherViewModel: ObservableObject { return DatabaseTreeMetadataService.shared .allSchemaTablesLoadState(connectionId: connectionId, database: database).value?.tables } - let loadedScope = services.schemaService.loadedScope(for: connectionId) + let schemaService = services.schemaService + let loadedScope = schemaService.loadedScope(for: connectionId) let tables = Self.mergedTables( - local: services.schemaService.allLoadedTables(for: connectionId), + local: schemaService.allLoadedTables(for: connectionId), loadedFrom: loadedScope?.database, coveredSchemas: coveredSchemas(loadedScope: loadedScope, grouping: tableSource.grouping), + staleSchemas: schemaService.schemasWithLoadedTables(for: connectionId) + .subtracting(schemaService.schemasWithCurrentTables(for: connectionId)), listing: listing, browsing: tableSource.database ) @@ -645,16 +648,23 @@ 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. + /// + /// `staleSchemas` are the per-schema lists a catalog change has overtaken and nothing has read + /// again, because only an expanded tree row reads one again. The listing, which this panel asks + /// for again after every change, answers for them once it has arrived. nonisolated static func mergedTables( local loaded: [TableInfo], loadedFrom loadedDatabase: String?, coveredSchemas: Set, + staleSchemas: Set, listing: [TableInfo]?, browsing database: String? ) -> [TableInfo] { let isCurrent = loadedDatabase == database - let local = isCurrent ? loaded : [] - let authoritative = (isCurrent ? coveredSchemas : []).union(local.map { $0.schema ?? "" }) + let overtaken = listing == nil ? [] : staleSchemas + let local = isCurrent ? loaded.filter { !overtaken.contains($0.schema ?? "") } : [] + let authoritative = (isCurrent ? coveredSchemas.subtracting(overtaken) : []) + .union(local.map { $0.schema ?? "" }) var seen: Set = [] return (local + (listing ?? []).filter { !authoritative.contains($0.schema ?? "") }) .filter { seen.insert(TableIdentity(schema: $0.schema ?? "", name: $0.name)).inserted } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index f96378c398..c169331f85 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -519,21 +519,19 @@ final class SidebarViewModel: ObservableObject { /// A search has to judge schemas nobody has opened, and the all-schema listing is what answers /// for them. It is asked for here rather than by the outline, because a flat list with no local /// match shows "No Results" in place of the outline, which then never sees the search at all. - /// Asked for the browsed database and for every database whose schemas the tree already shows, - /// never for one the user has not opened. private func loadAllSchemaTablesForSearch() { - guard !filterQuery.isEmpty, - PluginManager.shared.databaseGroupingStrategy(for: databaseType) == .bySchema else { return } + guard !filterQuery.isEmpty else { return } let service = DatabaseTreeMetadataService.shared let connectionId = connectionId - var databases = Set( - service.schemaList.compactMap { key, state in - key.connectionId == connectionId && state.value != nil ? key.database : nil - } + let databases = Self.databasesListedForSearch( + grouping: PluginManager.shared.databaseGroupingStrategy(for: databaseType), + browsedDatabase: browsedDatabase, + databasesWithSchemaLists: Set( + service.schemaList.compactMap { key, state in + key.connectionId == connectionId && state.value != nil ? key.database : nil + } + ) ) - if let browsedDatabase, !browsedDatabase.isEmpty { - databases.insert(browsedDatabase) - } let isConnected = DatabaseManager.shared.session(for: connectionId)?.status == .connected for database in databases { listingDemand.requestIfNeeded( @@ -545,6 +543,25 @@ final class SidebarViewModel: ObservableObject { } } + /// A schema-grouped tree asks for the browsed database and every database whose schemas it + /// already shows, never one the user has not opened. A hierarchical tree shows the schemas of + /// the browsed database alone, and an engine connected with no database name still has one. + nonisolated static func databasesListedForSearch( + grouping: GroupingStrategy, + browsedDatabase: String?, + databasesWithSchemaLists: Set + ) -> Set { + switch grouping { + case .bySchema: + guard let browsedDatabase, !browsedDatabase.isEmpty else { return databasesWithSchemaLists } + return databasesWithSchemaLists.union([browsedDatabase]) + case .hierarchicalSchema: + return Set([browsedDatabase].compactMap { $0 }) + case .flat, .byDatabase: + return [] + } + } + private func rebuildKindBuckets(from tables: [TableInfo]) { var buckets: [SidebarObjectKind: [TableInfo]] = [:] for kind in SidebarObjectKind.allCases { diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index 686150f71e..4623c71de6 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -132,34 +132,82 @@ enum DatabaseTreeFilter { ) } - /// A schema whose objects have not loaded yet cannot be judged, so it stays visible. Reading an - /// unloaded schema as an empty one hides it for the whole life of the filter and blanks the - /// pane while the search-driven load is still running. A match on a procedure, trigger or type - /// keeps the schema as surely as a match on a table. + /// What a hierarchical tree has read of one schema, filtered by the search. + struct LoadedSchemaContent { + let buckets: DatabaseTreeObjectBuckets + /// Every kind has answered. One that failed or is still coming may hold the match. + let isSettled: Bool + /// Read since the last catalog change, which may have created or dropped anything in it. + let isCurrent: Bool + } + + /// A hierarchical tree lists every schema of the database and judges each one without reading + /// it. A schema's own lists answer first while they are current. Otherwise the database's + /// all-schema listing answers for its tables, which is all the listing holds: a procedure, + /// function, trigger or type is found only in a schema whose objects have been read, and one + /// read before the last catalog change still counts, since the match expands the schema and the + /// expansion reads it again. A schema nothing can answer for yet stays on screen, collapsed. /// /// `database` is the one being browsed, when the engine has one, so a search that names it - /// (`shop.hr.employees`) can match. - static func hierarchicalSchemaIsVisible( - _ schema: String, + /// (`shop.hr.employees`) can match. `listingCoversSchema` is false for a schema the listing + /// leaves out on purpose, a system schema. + static func hierarchicalSchemaSearchVerdict( + schema: String, + database: String?, searchText: String, - isLoaded: Bool, - tables: [TableInfo], - routines: [RoutineInfo], - triggers: [TriggerInfo], - userTypes: [UserDefinedTypeInfo], - database: String? = nil - ) -> Bool { + loadedContent: LoadedSchemaContent?, + listingMatches: SchemaListingMatches?, + listingCoversSchema: Bool + ) -> SchemaSearchVerdict { let search = SidebarSearch(searchText) - if search.matchesContainer(database: database, schema: schema) { return true } - guard isLoaded else { return search.admits(database: database, schema: schema) } - return !objectBuckets( - tables: tables, - routines: routines, - triggers: triggers, - userTypes: userTypes, - searchText: searchText, - database: database - ).isEmpty + if search.matchesContainer(database: database, schema: schema) { return .match } + if let loadedContent { + if !loadedContent.buckets.isEmpty { return .match } + if loadedContent.isSettled, loadedContent.isCurrent { return .noMatch } + } + let unanswered: SchemaSearchVerdict = search.admits(database: database, schema: schema) ? .unknown : .noMatch + let listingAnswers = listingCoversSchema && listingMatches?.unlisted.contains(schema) == false + if listingAnswers, listingMatches?.matched.contains(schema) == true { return .match } + if let loadedContent { return loadedContent.isSettled ? .noMatch : unanswered } + return listingAnswers || !listingCoversSchema ? .noMatch : unanswered + } + + @MainActor + static func hierarchicalLoadedContent( + in service: SchemaService, + connectionId: UUID, + schema: String, + searchText: String, + database: String? + ) -> LoadedSchemaContent? { + guard service.hasLoadedContent(for: connectionId, schema: schema) else { return nil } + return LoadedSchemaContent( + buckets: objectBuckets( + tables: service.tables(for: connectionId, schema: schema), + routines: service.routines(for: connectionId, schema: schema), + triggers: service.triggers(for: connectionId, schema: schema), + userTypes: service.userDefinedTypes(for: connectionId, schema: schema), + searchText: searchText, + database: database + ), + isSettled: service.isSchemaSettled(for: connectionId, schema: schema), + isCurrent: service.isSchemaCurrent(for: connectionId, schema: schema) + ) + } + + /// The listing of the database the hierarchical tree's schemas were read from, which during a + /// database switch is still the one being left. + @MainActor + static func hierarchicalListingMatches( + in treeMetadata: DatabaseTreeMetadataService, + schemaService: SchemaService, + connectionId: UUID, + searchText: String + ) -> SchemaListingMatches? { + guard let database = schemaService.loadedScope(for: connectionId)?.database, + let listing = treeMetadata.allSchemaTablesLoadState(connectionId: connectionId, database: database).value + else { return nil } + return SchemaListingMatches(listing: listing, database: database, searchText: searchText) } /// A schema the search matched by name shows everything inside it. Filtering its objects by the @@ -215,9 +263,9 @@ enum DatabaseTreeFilter { searching ? matchCount > 0 : stored } - /// What a search can say about one schema of a database-grouped tree. `unknown` is a schema - /// whose objects neither the tree nor the all-schema listing can answer for yet, which stays on - /// screen collapsed, for the reason `hierarchicalSchemaIsVisible` keeps an unloaded schema. + /// What a search can say about one schema of a tree. `unknown` is a schema whose objects neither + /// the tree nor the all-schema listing can answer for yet, which stays on screen collapsed: + /// reading it as empty would hide it for the whole life of the filter. enum SchemaSearchVerdict: Equatable { case match case noMatch diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift index 37edf3c2d7..7c6efeca54 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -33,7 +33,7 @@ extension DatabaseTreeOutlineCoordinator { } case .hierarchicalSchemaSection(let schema): let want = searching - ? hierarchicalSchemaMatches(schema) + ? hierarchicalSchemaVerdict(schema) == .match : windowState?.expandedTreeSchemas.contains(schema) ?? false setExpanded(sectionNode, want) if outlineView.isItemExpanded(sectionNode) { @@ -228,8 +228,10 @@ extension DatabaseTreeOutlineCoordinator { } } + /// An expanded schema is the reader a catalog change waits for: it reads a schema never read, + /// and one a change has overtaken, once per change. private func loadHierarchicalSchemaObjects(_ schema: String) { - guard case .idle = schemaService.schemaState(for: connectionId, schema: schema) else { return } + guard schemaService.schemaObjectsNeedFetch(for: connectionId, schema: schema) else { return } let connectionId = connectionId let database = browsingDatabase Task { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index 26faf593f1..36c2483438 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -269,23 +269,27 @@ extension DatabaseTreeOutlineCoordinator { showsSystem: showSystemContainers ) nodes += browsable - .filter { searchText.isEmpty || hierarchicalSchemaMatches($0) } + .filter { searchText.isEmpty || hierarchicalSchemaVerdict($0).isVisible } .map { node(id: DatabaseTreeNode.hierarchicalSchemaSectionId($0), kind: .hierarchicalSchemaSection(schema: $0)) } return nodes } - internal func hierarchicalSchemaMatches(_ schema: String) -> Bool { - DatabaseTreeFilter.hierarchicalSchemaIsVisible( - schema, + internal func hierarchicalSchemaVerdict(_ schema: String) -> DatabaseTreeFilter.SchemaSearchVerdict { + DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: browsingDatabase, searchText: searchText, - isLoaded: schemaService.isSchemaSettled(for: connectionId, schema: schema), - tables: schemaService.tables(for: connectionId, schema: schema), - routines: schemaService.routines(for: connectionId, schema: schema), - triggers: schemaService.triggers(for: connectionId, schema: schema), - userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), - database: browsingDatabase + loadedContent: DatabaseTreeFilter.hierarchicalLoadedContent( + in: schemaService, + connectionId: connectionId, + schema: schema, + searchText: searchText, + database: browsingDatabase + ), + listingMatches: schemaService.loadedScope(for: connectionId).flatMap { listingMatches(database: $0.database) }, + listingCoversSchema: !systemSchemas.contains(schema) ) } diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index d55e497437..f9f42fde59 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -4,6 +4,7 @@ import TableProPluginKit struct SidebarTreeView: View { @ObservedObject private var databaseManager = DatabaseManager.shared @ObservedObject private var schemaService = SchemaService.shared + @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared let connectionId: UUID @ObservedObject var viewModel: SidebarViewModel @@ -14,7 +15,6 @@ struct SidebarTreeView: View { weak var coordinator: MainContentCoordinator? @ObservedObject private var settingsManager = AppSettingsManager.shared - @State private var searchLoadTask: Task? private var activeDatabase: String? { let name = coordinator?.browseDatabaseName ?? "" @@ -42,9 +42,32 @@ struct SidebarTreeView: View { viewModel.filterQuery } + /// The same verdict the outline applies, so the empty state and the rows can never disagree about + /// whether a schema survived the filter. private var visibleSchemas: [String] { guard !searchText.isEmpty else { return schemas } - return schemas.filter { schemaIsVisibleDuringSearch($0) } + let listingMatches = DatabaseTreeFilter.hierarchicalListingMatches( + in: treeMetadata, + schemaService: schemaService, + connectionId: connectionId, + searchText: searchText + ) + return schemas.filter { schema in + DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: activeDatabase, + searchText: searchText, + loadedContent: DatabaseTreeFilter.hierarchicalLoadedContent( + in: schemaService, + connectionId: connectionId, + schema: schema, + searchText: searchText, + database: activeDatabase + ), + listingMatches: listingMatches, + listingCoversSchema: !systemSchemas.contains(schema) + ).isVisible + } } var body: some View { @@ -57,9 +80,6 @@ struct SidebarTreeView: View { treeList } } - .onChange(of: searchText) { newValue in - scheduleSearchLoad(searchText: newValue) - } } /// Same outline the other two sidebar shapes use. See `SidebarView.tableList` for why a SwiftUI @@ -103,42 +123,4 @@ struct SidebarTreeView: View { UnavailableStateView.search(text: searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } - - /// The same rule the outline applies, so the empty state and the rows can never disagree about - /// whether a schema survived the filter. - private func schemaIsVisibleDuringSearch(_ schema: String) -> Bool { - DatabaseTreeFilter.hierarchicalSchemaIsVisible( - schema, - searchText: searchText, - isLoaded: schemaService.isSchemaSettled(for: connectionId, schema: schema), - tables: schemaService.tables(for: connectionId, schema: schema), - routines: schemaService.routines(for: connectionId, schema: schema), - triggers: schemaService.triggers(for: connectionId, schema: schema), - userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), - database: activeDatabase - ) - } - - private func loadObjects(for schema: String) { - let database = activeDatabase - Task { - await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, database: database) - } - } - - private func scheduleSearchLoad(searchText: String) { - searchLoadTask?.cancel() - guard !searchText.isEmpty else { return } - let schemasSnapshot = schemas - searchLoadTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled else { return } - for schema in schemasSnapshot { - if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { - continue - } - loadObjects(for: schema) - } - } - } } diff --git a/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift b/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift index 19c1c26d52..c422ea0398 100644 --- a/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift +++ b/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift @@ -72,4 +72,55 @@ struct CatalogFreshnessTests { freshness.removeAll { $0 == "shop" } #expect(!freshness.isCurrent("shop")) } + + @Test("A key never fetched needs a fetch, and only until one starts") + func needsFetchUntilOneStarts() { + var freshness = CatalogFreshness() + #expect(freshness.needsFetch("shop")) + freshness.noteFetchStarted(freshness.revision(for: "shop"), for: "shop") + #expect(!freshness.needsFetch("shop")) + } + + @Test("A current key needs no fetch until a change overtakes it") + func currentKeyNeedsNoFetch() { + var freshness = CatalogFreshness() + let revision = freshness.revision(for: "shop") + freshness.noteFetchStarted(revision, for: "shop") + _ = freshness.commit(revision, for: "shop") + #expect(!freshness.needsFetch("shop")) + freshness.markChanged("shop") + #expect(freshness.needsFetch("shop")) + } + + /// Every reader that observes a failed fetch would otherwise ask for it again, and a failure + /// publishes a change every reader observes. + @Test("A fetch that failed is not asked for again until the next change") + func failedFetchWaitsForTheNextChange() { + var freshness = CatalogFreshness() + freshness.markChanged("shop") + freshness.noteFetchStarted(freshness.revision(for: "shop"), for: "shop") + #expect(!freshness.needsFetch("shop")) + freshness.markChanged("shop") + #expect(freshness.needsFetch("shop")) + } + + @Test("A fetch cut short is asked for again at the same revision") + func abandonedFetchIsAskedForAgain() { + var freshness = CatalogFreshness() + let revision = freshness.revision(for: "shop") + freshness.noteFetchStarted(revision, for: "shop") + freshness.noteFetchAbandoned(revision, for: "shop") + #expect(freshness.needsFetch("shop")) + } + + @Test("Abandoning an older fetch leaves a newer one standing") + func abandoningAnOlderFetch() { + var freshness = CatalogFreshness() + let older = freshness.revision(for: "shop") + freshness.noteFetchStarted(older, for: "shop") + freshness.markChanged("shop") + freshness.noteFetchStarted(freshness.revision(for: "shop"), for: "shop") + freshness.noteFetchAbandoned(older, for: "shop") + #expect(!freshness.needsFetch("shop")) + } } diff --git a/TableProTests/Services/SchemaRefreshCommitCostTests.swift b/TableProTests/Services/SchemaRefreshCommitCostTests.swift new file mode 100644 index 0000000000..729f642aea --- /dev/null +++ b/TableProTests/Services/SchemaRefreshCommitCostTests.swift @@ -0,0 +1,254 @@ +// +// SchemaRefreshCommitCostTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Records every catalog read it answers, so a test can count the queries a refresh costs. +/// +/// A schema load reads tables, routines, triggers and types concurrently, so the read log and the +/// pause gate are locked rather than isolated: an unlocked append from those reads raced and +/// crashed the test host. +final class CatalogReadCountingDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var schemasToReturn: [String] = [] + var tablesBySchema: [String: [TableInfo]] = [:] + var tablesError: Error? + var allSchemaTables: [TableInfo]? + + private let lock = NSLock() + private var readLog: [String] = [] + private var pauseNextTableFetch = false + private var tableFetchPausedHandler: (@Sendable () -> Void)? + private var tableFetchGate: CheckedContinuation? + + init(connection: DatabaseConnection) { + self.connection = connection + } + + var reads: [String] { + lock.withLock { readLog } + } + + var pausesNextTableFetch: Bool { + get { lock.withLock { pauseNextTableFetch } } + set { lock.withLock { pauseNextTableFetch = newValue } } + } + + var onTableFetchPaused: (@Sendable () -> Void)? { + get { lock.withLock { tableFetchPausedHandler } } + set { lock.withLock { tableFetchPausedHandler = newValue } } + } + + func resumeTableFetch() { + let gate = lock.withLock { + let gate = tableFetchGate + tableFetchGate = nil + return gate + } + gate?.resume() + } + + func forgetReads() { + lock.withLock { readLog.removeAll() } + } + + private func record(_ read: String) { + lock.withLock { readLog.append(read) } + } + + private func takeTableFetchPause() -> Bool { + lock.withLock { + let pauses = pauseNextTableFetch + pauseNextTableFetch = false + return pauses + } + } + + private func park(_ continuation: CheckedContinuation) { + let handler = lock.withLock { + tableFetchGate = continuation + return tableFetchPausedHandler + } + handler?() + } + + func reads(ofSchema schema: String) -> [String] { + reads.filter { $0.hasSuffix(":\(schema)") } + } + + var perSchemaReads: [String] { + reads.filter { $0.contains(":") } + } + + 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] { + record("schemas") + return schemasToReturn + } + + func fetchTables() async throws -> [TableInfo] { + record("tables") + return [] + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let schema = schema ?? "" + record("tables:\(schema)") + if let tablesError { throw tablesError } + let snapshot = tablesBySchema[schema] ?? [] + if takeTableFetchPause() { + await withCheckedContinuation { continuation in + park(continuation) + } + try Task.checkCancellation() + } + return snapshot + } + + func fetchTablesInAllSchemas() async throws -> [TableInfo]? { + record("allSchemaTables") + return allSchemaTables + } + + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { + record(schema.map { "routines:\($0)" } ?? "routines") + return [] + } + + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { + record(schema.map { "triggers:\($0)" } ?? "triggers") + return [] + } + + func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] { + record(schema.map { "types:\($0)" } ?? "types") + return [] + } + + 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 {} +} + +@MainActor +final class SingleDriverMetadataProvider: ScopedMetadataProviding { + let driver: CatalogReadCountingDriver + let scope: DatabaseScope + + init(driver: CatalogReadCountingDriver, scope: DatabaseScope) { + self.driver = driver + self.scope = scope + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { + scope + } +} + +/// Oracle lists its objects one schema at a time, and a sidebar search used to leave every schema +/// of the database loaded. +@Suite("SchemaRefreshService commit cost") +@MainActor +struct SchemaRefreshCommitCostTests { + private let connectionId = UUID() + + private var connection: DatabaseConnection { + TestFixtures.makeConnection(id: connectionId, type: .oracle) + } + + private var scope: DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "ORCL", schema: "S0") + } + + private func driver(schemas: [String]) -> CatalogReadCountingDriver { + let driver = CatalogReadCountingDriver(connection: connection) + driver.schemasToReturn = schemas + for schema in schemas { + driver.tablesBySchema[schema] = [TableInfo(name: "\(schema)_ORDERS", type: .table, rowCount: nil, schema: schema)] + } + return driver + } + + /// The measured case: a search on the old sidebar left every schema of the database loaded, and + /// each COMMIT then read all of them again, one after another. + @Test("A COMMIT on a connection with 200 loaded schemas reads the browsed schema alone") + func commitReadsTheBrowsedSchemaAlone() async { + let schemas = (0..<200).map { "S\($0)" } + let driver = driver(schemas: schemas) + let schemaService = SchemaService() + let refreshService = SchemaRefreshService( + schemaService: schemaService, + providerRegistry: SchemaProviderRegistry(), + metadataDriverProvider: SingleDriverMetadataProvider(driver: driver, scope: scope), + databaseManager: nil + ) + await refreshService.refresh(connection: connection) + for schema in schemas { + await schemaService.loadSchemaObjects(schema: schema, in: scope, driver: driver) + } + driver.forgetReads() + + await refreshService.refreshAfterWrite(connection: connection) + + #expect(Set(driver.perSchemaReads) == ["tables:S0", "routines:S0"]) + #expect(driver.perSchemaReads.count == 2) + #expect(schemaService.tables(for: connectionId, schema: "S199").map(\.name) == ["S199_ORDERS"]) + } +} diff --git a/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift b/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift index d53329c53c..342c57951a 100644 --- a/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift +++ b/TableProTests/Services/SchemaServiceDatabaseSwitchTests.swift @@ -140,7 +140,7 @@ struct SchemaServiceDatabaseSwitchTests { } private func refreshObjects(_ service: SchemaService, database: String, driver: DatabaseCatalogDriver) async { - await service.refreshLoadedSchemaObjects(in: scope(database), driver: driver) + await service.refreshLoadedSchemaObjects(in: scope(database), fetchingNow: ["PUBLIC", "LEDGER"], driver: driver) } private func sales() -> DatabaseCatalogDriver { diff --git a/TableProTests/Services/SchemaServiceRefreshTests.swift b/TableProTests/Services/SchemaServiceRefreshTests.swift index a5b656c757..4a4f6d1cc3 100644 --- a/TableProTests/Services/SchemaServiceRefreshTests.swift +++ b/TableProTests/Services/SchemaServiceRefreshTests.swift @@ -350,8 +350,8 @@ struct SchemaServiceRefreshTests { #expect(service.tables(for: connectionId, schema: "sales").map(\.name) == ["orders"]) } - @Test("refreshLoadedSchemaObjects refetches only the schemas already expanded") - func refreshLoadedSchemaObjectsRefetchesExpandedSchemas() async { + @Test("refreshLoadedSchemaObjects rereads a loaded schema it is asked for now, and loads nothing else") + func refreshLoadedSchemaObjectsRereadsNamedLoadedSchema() async { let connectionId = UUID() let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) let driver = RefreshMockDriver(connection: connection) @@ -368,7 +368,7 @@ struct SchemaServiceRefreshTests { TableInfo(name: "orders", type: .table, rowCount: 0, schema: "sales"), TableInfo(name: "refunds", type: .table, rowCount: 0, schema: "sales") ] - await service.refreshLoadedSchemaObjects(in: scope, driver: driver) + await service.refreshLoadedSchemaObjects(in: scope, fetchingNow: ["sales", "hr"], 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/SchemaServiceStaleSchemaTests.swift b/TableProTests/Services/SchemaServiceStaleSchemaTests.swift new file mode 100644 index 0000000000..a38303b1c9 --- /dev/null +++ b/TableProTests/Services/SchemaServiceStaleSchemaTests.swift @@ -0,0 +1,213 @@ +// +// SchemaServiceStaleSchemaTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Oracle lists its objects one schema at a time, and every COMMIT reports a catalog change. +@Suite("SchemaService stale schemas") +@MainActor +struct SchemaServiceStaleSchemaTests { + private let connectionId = UUID() + private let boom = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) + + private var connection: DatabaseConnection { + TestFixtures.makeConnection(id: connectionId, type: .oracle) + } + + private var scope: DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "ORCL", schema: "S0") + } + + private func schemaNames(_ count: Int) -> [String] { + (0.. CatalogReadCountingDriver { + let driver = CatalogReadCountingDriver(connection: connection) + driver.schemasToReturn = schemas + for schema in schemas { + driver.tablesBySchema[schema] = [TableInfo(name: "\(schema)_ORDERS", type: .table, rowCount: nil, schema: schema)] + } + return driver + } + + private func loaded(_ schemas: [String], driver: CatalogReadCountingDriver) async -> SchemaService { + let service = SchemaService() + await service.reload(connectionId: connectionId, driver: driver, connection: connection, scope: scope) + for schema in schemas { + await service.loadSchemaObjects(schema: schema, in: scope, driver: driver) + } + driver.forgetReads() + return service + } + + private func tableReads(_ driver: CatalogReadCountingDriver, schema: String) -> Int { + driver.reads(ofSchema: schema).filter { $0.hasPrefix("tables") }.count + } + + @Test("A catalog change reads no schema again, and every one keeps what it shows") + func changeReadsNothing() async { + let schemas = schemaNames(50) + let driver = driver(schemas: schemas) + let service = await loaded(schemas, driver: driver) + + await service.refreshLoadedSchemaObjects(in: scope, fetchingNow: [], driver: driver) + + #expect(driver.reads.isEmpty) + #expect(service.tables(for: connectionId, schema: "S7").map(\.name) == ["S7_ORDERS"]) + #expect(!service.isSchemaCurrent(for: connectionId, schema: "S7")) + #expect(service.schemaObjectsNeedFetch(for: connectionId, schema: "S7")) + #expect(service.schemasWithCurrentTables(for: connectionId).isEmpty) + } + + @Test("A schema something is about to judge is read at once, and only that one") + func awaitedSchemaIsRead() async { + let schemas = schemaNames(50) + let driver = driver(schemas: schemas) + let service = await loaded(schemas, driver: driver) + + await service.refreshLoadedSchemaObjects(in: scope, fetchingNow: ["S0", "NEVER_LOADED"], driver: driver) + + #expect(Set(driver.perSchemaReads) == ["tables:S0", "routines:S0"]) + #expect(service.schemasWithCurrentTables(for: connectionId) == ["S0"]) + #expect(!service.schemaObjectsNeedFetch(for: connectionId, schema: "S0")) + #expect(service.schemaObjectsNeedFetch(for: connectionId, schema: "S1")) + } + + @Test("A reader reads a schema a change overtook once") + func staleSchemaIsReadOnce() async { + let driver = driver(schemas: ["S1"]) + let service = await loaded(["S1"], driver: driver) + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.tablesBySchema["S1"] = [TableInfo(name: "S1_REFUNDS", type: .table, rowCount: nil, schema: "S1")] + + await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) + await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) + + #expect(tableReads(driver, schema: "S1") == 1) + #expect(service.tables(for: connectionId, schema: "S1").map(\.name) == ["S1_REFUNDS"]) + #expect(service.isSchemaCurrent(for: connectionId, schema: "S1")) + } + + @Test("A read that fails after a change keeps the rows and waits for the next change") + func failedReadWaitsForTheNextChange() async { + let driver = driver(schemas: ["S1"]) + let service = await loaded(["S1"], driver: driver) + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.tablesError = boom + + await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) + + #expect(service.tables(for: connectionId, schema: "S1").map(\.name) == ["S1_ORDERS"]) + #expect(!service.schemaObjectsNeedFetch(for: connectionId, schema: "S1")) + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + #expect(service.schemaObjectsNeedFetch(for: connectionId, schema: "S1")) + } + + @Test("A fetch that began before a change shows its rows, and the next read fetches again") + func fetchOvertakenByAChangeIsNotCurrent() async { + let driver = driver(schemas: ["S1"]) + let service = await loaded([], driver: driver) + driver.pausesNextTableFetch = true + var first: Task? + await withCheckedContinuation { (paused: CheckedContinuation) in + driver.onTableFetchPaused = { paused.resume() } + first = Task { await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) } + } + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.resumeTableFetch() + await first?.value + + #expect(service.tables(for: connectionId, schema: "S1").map(\.name) == ["S1_ORDERS"]) + #expect(!service.isSchemaCurrent(for: connectionId, schema: "S1")) + #expect(service.schemaObjectsNeedFetch(for: connectionId, schema: "S1")) + + await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) + #expect(tableReads(driver, schema: "S1") == 2) + #expect(service.isSchemaCurrent(for: connectionId, schema: "S1")) + } + + @Test("A read after a change starts its own fetch instead of joining the one before it") + func readAfterAChangeDoesNotJoinTheEarlierFetch() async { + let driver = driver(schemas: ["S1"]) + let service = await loaded(["S1"], driver: driver) + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.pausesNextTableFetch = true + var earlier: Task? + await withCheckedContinuation { (paused: CheckedContinuation) in + driver.onTableFetchPaused = { paused.resume() } + earlier = Task { await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) } + } + + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.tablesBySchema["S1"] = [TableInfo(name: "S1_REFUNDS", type: .table, rowCount: nil, schema: "S1")] + await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) + driver.resumeTableFetch() + await earlier?.value + + #expect(tableReads(driver, schema: "S1") == 2) + #expect(service.tables(for: connectionId, schema: "S1").map(\.name) == ["S1_REFUNDS"]) + #expect(service.isSchemaCurrent(for: connectionId, schema: "S1")) + } + + /// A queued truncate or drop is pruned when its table is missing from the refreshed catalog. A + /// list read before the last change lacks every table created since, so it cannot say one is gone. + @Test("Only a schema read since the last change can say a queued table is gone") + func staleListsJudgeNoQueuedTable() async throws { + let driver = driver(schemas: ["S0", "S1"]) + let service = await loaded(["S0", "S1"], driver: driver) + let databaseManager = DatabaseManager() + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseDatabase = scope.database + session.browseSchema = scope.schema + databaseManager.injectSession(session, for: connectionId) + defer { databaseManager.removeSession(for: connectionId) } + let adoption = CatalogEditAdoption(databaseManager: databaseManager, schemaService: service) + let createdSinceRead = DatabaseTreeTableRef( + database: scope.database, + schema: "S1", + table: TableInfo(name: "S1_REFUNDS", type: .table, rowCount: nil, schema: "S1") + ) + let dropped = DatabaseTreeTableRef( + database: scope.database, + schema: "S0", + table: TableInfo(name: "S0_GONE", type: .table, rowCount: nil, schema: "S0") + ) + + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + let stale = try #require(adoption.loadedBrowseCatalog(connectionId: connectionId)) + #expect(stale.schemas.isEmpty) + #expect(stale.staleRefs(in: [createdSinceRead, dropped]).isEmpty) + + await service.loadSchemaObjects(schema: "S0", in: scope, driver: driver) + let refreshed = try #require(adoption.loadedBrowseCatalog(connectionId: connectionId)) + #expect(refreshed.schemas == ["S0"]) + #expect(refreshed.staleRefs(in: [createdSinceRead, dropped]) == [dropped]) + } + + @Test("A read cancelled by a reload is asked for again") + func cancelledReadIsAskedForAgain() async { + let driver = driver(schemas: ["S1"]) + let service = await loaded(["S1"], driver: driver) + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + driver.pausesNextTableFetch = true + var read: Task? + await withCheckedContinuation { (paused: CheckedContinuation) in + driver.onTableFetchPaused = { paused.resume() } + read = Task { await service.loadSchemaObjects(schema: "S1", in: scope, driver: driver) } + } + + await service.prepareForReload(connectionId: connectionId) + driver.resumeTableFetch() + await read?.value + + #expect(service.tables(for: connectionId, schema: "S1").map(\.name) == ["S1_ORDERS"]) + #expect(service.schemaObjectsNeedFetch(for: connectionId, schema: "S1")) + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index be4f1034e0..d70567ecd4 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -105,7 +105,8 @@ 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" + local: local, loadedFrom: "shop", coveredSchemas: ["public"], staleSchemas: [], + listing: allSchemas, browsing: "shop" ) #expect(merged.map(\.name) == ["users", "timesheet"]) @@ -118,7 +119,8 @@ struct QuickSwitcherCrossSchemaTests { let allSchemas = [table("users", "public"), table("timesheet", "attendance")] let merged = QuickSwitcherViewModel.mergedTables( - local: [], loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop" + local: [], loadedFrom: "shop", coveredSchemas: ["public"], staleSchemas: [], + listing: allSchemas, browsing: "shop" ) #expect(merged.map(\.name) == ["timesheet"]) @@ -129,7 +131,8 @@ struct QuickSwitcherCrossSchemaTests { let allSchemas = [table("timesheet", "attendance"), table("timesheet", "attendance")] let merged = QuickSwitcherViewModel.mergedTables( - local: [], loadedFrom: "shop", coveredSchemas: [], listing: allSchemas, browsing: "shop" + local: [], loadedFrom: "shop", coveredSchemas: [], staleSchemas: [], + listing: allSchemas, browsing: "shop" ) #expect(merged.count == 1) @@ -143,6 +146,7 @@ struct QuickSwitcherCrossSchemaTests { local: [table("invoices", "public")], loadedFrom: "billing", coveredSchemas: ["public"], + staleSchemas: [], listing: [table("orders", "public"), table("timesheet", "attendance")], browsing: "shop" ) @@ -158,6 +162,7 @@ struct QuickSwitcherCrossSchemaTests { local: [table("ENTRIES", "LEDGER")], loadedFrom: "SALES", coveredSchemas: ["PUBLIC", "LEDGER"], + staleSchemas: [], listing: [table("ORDERS", "PUBLIC"), table("ENTRIES", "LEDGER"), table("RATES", "FX")], browsing: "SALES" ) @@ -171,6 +176,36 @@ struct QuickSwitcherCrossSchemaTests { local: [table("ORDERS", "PUBLIC")], loadedFrom: "SALES", coveredSchemas: ["PUBLIC"], + staleSchemas: [], + listing: nil, + browsing: "SALES" + ) + + #expect(merged.map(\.name) == ["ORDERS"]) + } + + /// `REFUNDS` was created after the last read of `PUBLIC`, which nobody has expanded since. + @Test("A schema a catalog change overtook yields to the listing once it arrives") + func staleSchemaYieldsToTheListing() { + let merged = QuickSwitcherViewModel.mergedTables( + local: [table("ORDERS", "PUBLIC"), table("ENTRIES", "LEDGER")], + loadedFrom: "SALES", + coveredSchemas: ["PUBLIC", "LEDGER"], + staleSchemas: ["PUBLIC"], + listing: [table("ORDERS", "PUBLIC"), table("REFUNDS", "PUBLIC"), table("STALE", "LEDGER")], + browsing: "SALES" + ) + + #expect(merged.map(\.name) == ["ENTRIES", "ORDERS", "REFUNDS"]) + } + + @Test("A schema a catalog change overtook still stands in until the listing arrives") + func staleSchemaStandsInWithoutAListing() { + let merged = QuickSwitcherViewModel.mergedTables( + local: [table("ORDERS", "PUBLIC")], + loadedFrom: "SALES", + coveredSchemas: ["PUBLIC"], + staleSchemas: ["PUBLIC"], listing: nil, browsing: "SALES" ) diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift index dd58ac42ff..f0aa548b59 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift @@ -212,33 +212,42 @@ struct DatabaseTreeFilterQualifiedSearchTests { // MARK: - Hierarchical shape + private func hierarchicalVerdict( + _ schema: String, + searchText: String, + loaded tables: [TableInfo]? = nil, + database: String? = nil + ) -> DatabaseTreeFilter.SchemaSearchVerdict { + let content = tables.map { tables in + DatabaseTreeFilter.LoadedSchemaContent( + buckets: DatabaseTreeFilter.objectBuckets( + tables: tables, routines: [], triggers: [], searchText: searchText, database: database + ), + isSettled: true, + isCurrent: true + ) + } + return DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: database, + searchText: searchText, + loadedContent: content, + listingMatches: nil, + listingCoversSchema: true + ) + } + @Test("A qualified search hides the hierarchical schemas it does not name") func hierarchicalQualified() { - let other = DatabaseTreeFilter.hierarchicalSchemaIsVisible( - "HR", searchText: "SALES.ORDERS", isLoaded: false, - tables: [], routines: [], triggers: [], userTypes: [] - ) - let named = DatabaseTreeFilter.hierarchicalSchemaIsVisible( - "SALES", searchText: "SALES.ORDERS", isLoaded: false, - tables: [], routines: [], triggers: [], userTypes: [] - ) - #expect(!other) - #expect(named) + #expect(hierarchicalVerdict("HR", searchText: "SALES.ORDERS") == .noMatch) + #expect(hierarchicalVerdict("SALES", searchText: "SALES.ORDERS") == .unknown) } @Test("A hierarchical search can name the browsed database, and only that one") func hierarchicalThreeParts() { let loaded = [table("EMPLOYEES", schema: "HR")] - let browsed = DatabaseTreeFilter.hierarchicalSchemaIsVisible( - "HR", searchText: "SHOP.HR.EMP", isLoaded: true, - tables: loaded, routines: [], triggers: [], userTypes: [], database: "SHOP" - ) - let other = DatabaseTreeFilter.hierarchicalSchemaIsVisible( - "HR", searchText: "BLOG.HR.EMP", isLoaded: true, - tables: loaded, routines: [], triggers: [], userTypes: [], database: "SHOP" - ) - #expect(browsed) - #expect(!other) + #expect(hierarchicalVerdict("HR", searchText: "SHOP.HR.EMP", loaded: loaded, database: "SHOP") == .match) + #expect(hierarchicalVerdict("HR", searchText: "BLOG.HR.EMP", loaded: loaded, database: "SHOP") == .noMatch) } @Test("A trailing dot shows everything in the hierarchical schema it names") diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index 08dd07f890..bdc4a30067 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -166,15 +166,26 @@ struct DatabaseTreeFilterTests { routines: [RoutineInfo] = [], triggers: [TriggerInfo] = [] ) -> Bool { - DatabaseTreeFilter.hierarchicalSchemaIsVisible( - schema, + let content = isLoaded + ? DatabaseTreeFilter.LoadedSchemaContent( + buckets: DatabaseTreeFilter.objectBuckets( + tables: tables, + routines: routines, + triggers: triggers, + searchText: searchText + ), + isSettled: true, + isCurrent: true + ) + : nil + return DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: nil, searchText: searchText, - isLoaded: isLoaded, - tables: tables, - routines: routines, - triggers: triggers, - userTypes: [] - ) + loadedContent: content, + listingMatches: nil, + listingCoversSchema: true + ).isVisible } private func buckets( @@ -193,7 +204,7 @@ struct DatabaseTreeFilterTests { ) } - /// A search fires a per-schema load, and the pane must not blank out while it runs. + /// Nothing has read it and no listing has answered for it, so hiding it would hide the match. @Test("An unloaded schema stays visible during a search") func unloadedSchemaStaysVisible() { #expect(isVisible("analytics", searchText: "invoice", isLoaded: false)) diff --git a/TableProTests/Views/Sidebar/HierarchicalSchemaSearchTests.swift b/TableProTests/Views/Sidebar/HierarchicalSchemaSearchTests.swift new file mode 100644 index 0000000000..0acdc6e99e --- /dev/null +++ b/TableProTests/Views/Sidebar/HierarchicalSchemaSearchTests.swift @@ -0,0 +1,284 @@ +// +// HierarchicalSchemaSearchTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Oracle, Snowflake, BigQuery and the other engines grouped by hierarchical schema list every +/// schema of the database, and the sidebar filter judges each one without reading it. +@Suite("Hierarchical schema search") +struct HierarchicalSchemaSearchTests { + private func table(_ name: String, _ schema: String) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + private func procedure(_ name: String, _ schema: String) -> RoutineInfo { + RoutineInfo(name: name, kind: .procedure, schema: schema) + } + + private func listing( + _ tables: [TableInfo], + unlisted: Set = [], + searchText: String + ) -> DatabaseTreeFilter.SchemaListingMatches { + DatabaseTreeFilter.SchemaListingMatches( + listing: CatalogTableListing.Result(tables: tables, unlistedSchemas: unlisted), + database: "", + searchText: searchText + ) + } + + private func content( + tables: [TableInfo] = [], + routines: [RoutineInfo] = [], + settled: Bool = true, + current: Bool, + searchText: String + ) -> DatabaseTreeFilter.LoadedSchemaContent { + DatabaseTreeFilter.LoadedSchemaContent( + buckets: DatabaseTreeFilter.objectBuckets( + tables: tables, + routines: routines, + triggers: [], + searchText: searchText + ), + isSettled: settled, + isCurrent: current + ) + } + + private func verdict( + _ schema: String, + searchText: String, + content: DatabaseTreeFilter.LoadedSchemaContent? = nil, + listing: DatabaseTreeFilter.SchemaListingMatches? = nil, + coveredByListing: Bool = true + ) -> DatabaseTreeFilter.SchemaSearchVerdict { + DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: nil, + searchText: searchText, + loadedContent: content, + listingMatches: listing, + listingCoversSchema: coveredByListing + ) + } + + @Test("An unread schema whose tables the listing shows match nothing is hidden") + func listedWithoutAMatchIsHidden() { + let matches = listing([table("EMPLOYEES", "HR")], searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", listing: matches) == .noMatch) + } + + @Test("An unread schema holding a matching table in the listing is a match") + func listedWithAMatchIsAMatch() { + let matches = listing([table("INVOICES", "BILLING")], searchText: "invoice") + #expect(verdict("BILLING", searchText: "invoice", listing: matches) == .match) + } + + @Test("An unread schema stays on screen, collapsed, until the listing arrives") + func noListingYetIsUnknown() { + #expect(verdict("HR", searchText: "invoice") == .unknown) + } + + @Test("A schema the listing could not read stays on screen") + func unlistedSchemaIsUnknown() { + let matches = listing([table("INVOICES", "BILLING")], unlisted: ["HR"], searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", listing: matches) == .unknown) + } + + @Test("An unread schema with no tables in the listing is hidden") + func emptySchemaIsHidden() { + let matches = listing([table("INVOICES", "BILLING")], searchText: "invoice") + #expect(verdict("SCRATCH", searchText: "invoice", listing: matches) == .noMatch) + } + + /// The listing holds tables alone, so a procedure is found only in a schema whose objects have + /// been read. Reading every schema to find one is the load the listing replaced. + @Test("A procedure in a schema nothing has read is not found") + func procedureInAnUnreadSchemaIsNotFound() { + let matches = listing([table("PAYROLL", "HR")], searchText: "raise_salary") + #expect(verdict("HR", searchText: "raise_salary", listing: matches) == .noMatch) + } + + @Test("A procedure in a schema that was read is found") + func procedureInAReadSchemaIsFound() { + let read = content(routines: [procedure("RAISE_SALARY", "HR")], current: true, searchText: "raise_salary") + let matches = listing([table("PAYROLL", "HR")], searchText: "raise_salary") + #expect(verdict("HR", searchText: "raise_salary", content: read, listing: matches) == .match) + } + + @Test("A schema read since the last change answers for itself over the listing") + func currentReadAnswersFirst() { + let read = content(tables: [table("PAYROLL", "HR")], current: true, searchText: "invoice") + let matches = listing([table("INVOICES", "HR")], searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", content: read, listing: matches) == .noMatch) + } + + /// `INVOICES` was created after the last read of `BILLING`, and the listing was asked for again. + @Test("A schema read before the last change yields to the listing for its tables") + func staleReadYieldsToTheListing() { + let read = content(tables: [table("PAYROLL", "BILLING")], current: false, searchText: "invoice") + let matches = listing([table("PAYROLL", "BILLING"), table("INVOICES", "BILLING")], searchText: "invoice") + #expect(verdict("BILLING", searchText: "invoice", content: read, listing: matches) == .match) + } + + @Test("A schema read before the last change keeps its procedure match") + func staleReadKeepsAProcedureMatch() { + let read = content(routines: [procedure("CLOSE_INVOICE", "BILLING")], current: false, searchText: "invoice") + let matches = listing([table("PAYROLL", "BILLING")], searchText: "invoice") + #expect(verdict("BILLING", searchText: "invoice", content: read, listing: matches) == .match) + } + + @Test("A schema read before the last change that nothing matches is hidden") + func staleReadWithoutAMatchIsHidden() { + let read = content(tables: [table("PAYROLL", "HR")], current: false, searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", content: read) == .noMatch) + let matches = listing([table("PAYROLL", "HR")], searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", content: read, listing: matches) == .noMatch) + } + + /// A kind whose fetch failed has not answered, and the object searched for may be the one it + /// could not list. + @Test("A schema whose procedures failed to load stays on screen when its tables do not match") + func unsettledReadIsUnknown() { + let read = content(tables: [table("PAYROLL", "HR")], settled: false, current: true, searchText: "invoice") + let matches = listing([table("PAYROLL", "HR")], searchText: "invoice") + #expect(verdict("HR", searchText: "invoice", content: read, listing: matches) == .unknown) + } + + @Test("A system schema nothing has read is hidden") + func unreadSystemSchemaIsHidden() { + let matches = listing([table("INVOICES", "BILLING")], searchText: "invoice") + #expect(verdict("SYS", searchText: "invoice", listing: matches, coveredByListing: false) == .noMatch) + } + + @Test("A schema whose own name matches is a match with nothing read") + func schemaNameMatch() { + let matches = listing([table("PAYROLL", "HR")], searchText: "hr") + #expect(verdict("HR", searchText: "hr", listing: matches) == .match) + } + + @Test("A hierarchical search asks for the browsed database's listing, named or not") + func hierarchicalSearchAsksForTheBrowsedDatabase() { + let named = SidebarViewModel.databasesListedForSearch( + grouping: .hierarchicalSchema, browsedDatabase: "SALES", databasesWithSchemaLists: ["OTHER"] + ) + let unnamed = SidebarViewModel.databasesListedForSearch( + grouping: .hierarchicalSchema, browsedDatabase: "", databasesWithSchemaLists: [] + ) + let disconnected = SidebarViewModel.databasesListedForSearch( + grouping: .hierarchicalSchema, browsedDatabase: nil, databasesWithSchemaLists: [] + ) + #expect(named == ["SALES"]) + #expect(unnamed == [""]) + #expect(disconnected.isEmpty) + } + + @Test("A schema-grouped search asks for the browsed database and every one the tree shows") + func schemaGroupedSearchAsksForShownDatabases() { + let requested = SidebarViewModel.databasesListedForSearch( + grouping: .bySchema, browsedDatabase: "shop", databasesWithSchemaLists: ["blog"] + ) + let unnamed = SidebarViewModel.databasesListedForSearch( + grouping: .bySchema, browsedDatabase: "", databasesWithSchemaLists: ["blog"] + ) + let byDatabase = SidebarViewModel.databasesListedForSearch( + grouping: .byDatabase, browsedDatabase: "shop", databasesWithSchemaLists: ["blog"] + ) + #expect(requested == ["shop", "blog"]) + #expect(unnamed == ["blog"]) + #expect(byDatabase.isEmpty) + } +} + +/// The measured case behind the change: a search over 200 schemas, three of which hold a match. +/// Before it, the first keystroke loaded every schema, two queries each here and three on Oracle. +@Suite("Hierarchical schema search cost") +@MainActor +struct HierarchicalSchemaSearchCostTests { + private let connectionId = UUID() + private let searchText = "invoice" + + private var connection: DatabaseConnection { + TestFixtures.makeConnection(id: connectionId, type: .oracle) + } + + private var scope: DatabaseScope { + DatabaseScope(connectionId: connectionId, database: "ORCL", schema: "S0") + } + + private func catalog(listsInOneCall: Bool) -> (schemas: [String], driver: CatalogReadCountingDriver) { + let schemas = (0..<200).map { "S\($0)" } + let driver = CatalogReadCountingDriver(connection: connection) + driver.schemasToReturn = schemas + var all: [TableInfo] = [] + for (index, schema) in schemas.enumerated() { + var tables = [TableInfo(name: "\(schema)_ORDERS", type: .table, rowCount: nil, schema: schema)] + if index % 70 == 1 { + tables.append(TableInfo(name: "\(schema)_INVOICES", type: .table, rowCount: nil, schema: schema)) + } + driver.tablesBySchema[schema] = tables + all += tables + } + driver.allSchemaTables = listsInOneCall ? all : nil + return (schemas, driver) + } + + private func search(_ schemas: [String], driver: CatalogReadCountingDriver) async throws -> [String] { + let listing = try await CatalogTableListing.tables( + in: scope, + excludingSchemas: [], + metadata: SingleDriverMetadataProvider(driver: driver, scope: scope) + ) + let listingMatches = DatabaseTreeFilter.SchemaListingMatches( + listing: listing, + database: scope.database, + searchText: searchText + ) + let matched = schemas.filter { schema in + DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( + schema: schema, + database: scope.database, + searchText: searchText, + loadedContent: nil, + listingMatches: listingMatches, + listingCoversSchema: true + ) == .match + } + let service = SchemaService() + for schema in matched { + await service.loadSchemaObjects(schema: schema, in: scope, driver: driver) + } + return matched + } + + @Test("An engine that lists every table in one call costs one query plus the matches") + func singleCallListing() async throws { + let (schemas, driver) = catalog(listsInOneCall: true) + + let matched = try await search(schemas, driver: driver) + + #expect(matched == ["S1", "S71", "S141"]) + #expect(driver.reads.filter { $0 == "allSchemaTables" }.count == 1) + let matchReads = 3 * 2 + #expect(driver.reads.count == 1 + matchReads) + } + + @Test("An engine listed schema by schema costs one table read per schema plus the matches") + func perSchemaListing() async throws { + let (schemas, driver) = catalog(listsInOneCall: false) + + let matched = try await search(schemas, driver: driver) + + let listingReads = 1 + 1 + 200 + let matchReads = 3 * 2 + #expect(matched == ["S1", "S71", "S141"]) + #expect(driver.reads.filter { $0.hasPrefix("routines") }.count == 3) + #expect(driver.reads.count == listingReads + matchReads) + } +} diff --git a/docs/features/connection-window.mdx b/docs/features/connection-window.mdx index 9edfb15843..80d0584309 100644 --- a/docs/features/connection-window.mdx +++ b/docs/features/connection-window.mdx @@ -95,7 +95,9 @@ The filter field matches any part of a name, so `sheet` finds `timesheet`. How f | **View > Sidebar as List** | The browsed schema, then every other schema holding a match, listed below it and opened to show the matches | | **View > Sidebar as Tree** | Every schema of every database whose schemas the tree has shown, expanded or not | -A schema whose tables have not been read yet stays in the tree, collapsed, until they arrive. +A schema whose tables have not been read yet stays in the tree, collapsed, until they arrive. Oracle, Snowflake, BigQuery and the other engines whose sidebar lists schemas with no database above them show that tree in both layouts, and the filter checks the tables of every schema in it. + +Until the sidebar has read a schema's objects, only its tables and views match. Expand the schema to read them, and its procedures, functions, triggers and types match too. Sidebar with timesheet in public at the top, and attendance and payroll below it, each opened to its timesheet table