diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d6324242..fca82f2408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **File > Session**, with the agent session commands and the assistant's conversation commands. - Eight more rebindable commands in **Settings > Keyboard**, among them the sidebar's lists and the session commands. - **Global** on a saved query folder's menu, for a folder every connection shows. +- Tables from every schema in Open Quickly and the sidebar filter, and `schema.table` searches in both. (#3048) - Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524) - **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502) @@ -74,6 +75,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh. +- 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. +- **Drop View** offered in Recent for a sequence or materialized view opened from Open Quickly. - Unresponsive app and a dropped keystroke when typing in the row inspector's JSON field. (#3051) - Raw Oracle driver error in the schema switch failure dialog. (#3053) - Oracle health check closing a connection a statement was still running on. (#3053) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7a4e86f3bc..e571277e77 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -166,10 +166,17 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - let schemaName = schema ?? core.currentSchema + try await listTables(in: .schema(schema ?? core.currentSchema)) + } + + func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? { + try await listTables(in: .allSchemas) + } + + private func listTables(in listing: PostgreSQLTableListingScope) async throws -> [PluginTableInfo] { func query(_ attempt: PostgreSQLTableListingAttempt) -> String { PostgreSQLSchemaQueries.fetchTables( - schema: schemaName, + in: listing, includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(), includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(), includeComments: attempt.includeComments, @@ -203,7 +210,13 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } let comment = row[safe: 2]?.asText?.nilIfEmpty let partitionCount = row[safe: 3]?.asText.flatMap(Int.init) - return PluginTableInfo(name: name, type: type, comment: comment, partitionCount: partitionCount) + return PluginTableInfo( + name: name, + type: type, + schema: row[safe: 4]?.asText, + comment: comment, + partitionCount: partitionCount + ) } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 3996634c53..4cb314d931 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -15,6 +15,11 @@ enum PostgreSQLSchemaProbe: Equatable { case failed } +enum PostgreSQLTableListingScope: Sendable, Equatable { + case schema(String) + case allSchemas +} + enum PostgreSQLSchemaQueries { /// Returns the first schema on the effective search path, or SQL NULL /// when the path is empty (neither `$user` nor `public` exists). @@ -110,7 +115,38 @@ enum PostgreSQLSchemaQueries { includeComments: Bool = true, includePartitionAwareness: Bool = true ) -> String { - let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + fetchTables( + in: .schema(schema), + includeMaterializedViews: includeMaterializedViews, + includeForeignTables: includeForeignTables, + includeComments: includeComments, + includePartitionAwareness: includePartitionAwareness + ) + } + + /// The same listing over one schema or over every schema `listSchemas` returns. The second + /// filters by that query itself rather than restating its predicate, so a table is listed here + /// exactly when its schema is listed there, and projects each row's schema, which the + /// one-schema listing leaves to the caller. + static func fetchTables( + in listing: PostgreSQLTableListingScope, + includeMaterializedViews: Bool, + includeForeignTables: Bool, + includeComments: Bool = true, + includePartitionAwareness: Bool = true + ) -> String { + func schemaFilter(_ column: String) -> String { + switch listing { + case .schema(let schema): + return "\(column) = \(PostgreSQLObjectQueries.quoteLiteral(schema))" + case .allSchemas: + return "\(column) IN (\n\(listSchemas)\n)" + } + } + func schemaColumn(_ column: String) -> String { + listing == .allSchemas ? ",\n \(column) AS schema_name" : "" + } + let orderBy = listing == .allSchemas ? "ORDER BY schema_name, table_name" : "ORDER BY table_name" func commentColumn(_ oidExpression: String) -> String { includeComments ? "obj_description(\(oidExpression), 'pg_class')" : "NULL::text" } @@ -140,9 +176,9 @@ enum PostgreSQLSchemaQueries { """ SELECT t.table_name, \(tableTypeColumn) AS table_type, \(commentColumn("pc.oid")) AS table_comment, - \(partitionCountColumn) AS partition_count + \(partitionCountColumn) AS partition_count\(schemaColumn("t.table_schema")) FROM information_schema.tables t\(classJoin) - WHERE t.table_schema = \(schemaLiteral) + WHERE \(schemaFilter("t.table_schema")) AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter) """ ] @@ -157,9 +193,9 @@ enum PostgreSQLSchemaQueries { """ SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, \(commentColumn("mc.oid")) AS table_comment, - NULL::bigint AS partition_count + NULL::bigint AS partition_count\(schemaColumn("m.schemaname")) FROM pg_matviews m\(matviewJoin) - WHERE m.schemaname = \(schemaLiteral) + WHERE \(schemaFilter("m.schemaname")) """ ) } @@ -172,16 +208,16 @@ enum PostgreSQLSchemaQueries { """ SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, \(commentColumn("c.oid")) AS table_comment, - NULL::bigint AS partition_count + NULL::bigint AS partition_count\(schemaColumn("n.nspname")) FROM pg_foreign_table ft JOIN pg_class c ON c.oid = ft.ftrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = \(schemaLiteral)\(foreignPartitionFilter) + WHERE \(schemaFilter("n.nspname"))\(foreignPartitionFilter) """ ) } - return unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name" + return unions.joined(separator: "\nUNION ALL\n") + "\n" + orderBy } /// The predicate that keeps a partition out of a flat listing. A foreign diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d6b2a0308e..dc6ce2314a 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -93,6 +93,11 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? func fetchTables(schema: String?) async throws -> [PluginTableInfo] + + /// What `fetchTables(schema:)` lists for every schema `fetchSchemas()` lists, in one call, each + /// row carrying its own schema. Nil means the engine has no single call for it, and the host + /// asks each schema in turn instead. + func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] /// The same partitions as `fetchPartitions`, with the bound, the ordinal position and the row @@ -641,6 +646,8 @@ public extension PluginDatabaseDriver { func fetchSchemas() async throws -> [String] { [] } + func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? { nil } + /// Schemas whose objects live in a catalog outside the database itself, such /// as Redshift external schemas backed by Glue, Hive, or a federated source. /// Engines without that concept keep the empty default. diff --git a/TablePro/Core/Concurrency/CatalogFreshness.swift b/TablePro/Core/Concurrency/CatalogFreshness.swift new file mode 100644 index 0000000000..c2b4384032 --- /dev/null +++ b/TablePro/Core/Concurrency/CatalogFreshness.swift @@ -0,0 +1,43 @@ +// +// CatalogFreshness.swift +// TablePro +// + +import Foundation + +/// Which cached lists a catalog change has overtaken, for a cache that refetches when it is next +/// read rather than the moment the catalog changes. +/// +/// A change moves its key's revision, and a fetch carries the revision it started under. A fetch +/// that was already running when the change landed still delivers its rows, but it cannot make +/// the key current again, so the next read fetches once more. A key whose fetch failed was never +/// committed, which leaves it stale and retried on the next read too. +struct CatalogFreshness { + private var revisions: [Key: Int] = [:] + private var committed: [Key: Int] = [:] + + func revision(for key: Key) -> Int { + revisions[key, default: 0] + } + + func isCurrent(_ key: Key) -> Bool { + committed[key] == revision(for: key) + } + + mutating func markChanged(_ key: Key) { + revisions[key, default: 0] &+= 1 + } + + /// False when a fetch that started later has already committed, so an older fetch finishing + /// last cannot put its rows back over newer ones. + mutating func commit(_ revision: Int, for key: Key) -> Bool { + if let current = committed[key], current > revision { return false } + committed[key] = revision + return true + } + + mutating func removeAll(where shouldRemove: (Key) -> Bool) { + revisions = revisions.filter { !shouldRemove($0.key) } + committed = committed.filter { !shouldRemove($0.key) } + } +} diff --git a/TablePro/Core/Database/BackupScopeLoader.swift b/TablePro/Core/Database/BackupScopeLoader.swift index 15931fbb13..1675b4fd42 100644 --- a/TablePro/Core/Database/BackupScopeLoader.swift +++ b/TablePro/Core/Database/BackupScopeLoader.swift @@ -92,32 +92,26 @@ enum BackupScopeLoader { } } + /// A schema that could not be listed fails the whole list, as it did when each schema was read + /// in turn: a picker that silently lacks a schema would back up less than the user chose. @MainActor private static func schemaQualifiedObjects(scope: DatabaseScope) async throws -> [NativeDumpObject] { - let schemas = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try await driver.fetchSchemas() - } - var objects: [NativeDumpObject] = [] - for schema in schemas { - let qualified = DatabaseScope( - connectionId: scope.connectionId, database: scope.database, schema: schema - ) - let tables = try await DatabaseManager.shared.withMetadataDriver( - scope: qualified, workload: .bulk - ) { driver in - try await driver.fetchTables(schema: schema) + let listing = try await CatalogTableListing.tables(in: scope, excludingSchemas: []) + guard listing.unlistedSchemas.isEmpty else { throw BackupScopeLoadError.schemasNotListed } + return listing.tables + .filter(\.type.isBackupSelectable) + .compactMap { table in + guard let schema = table.schema else { return nil } + return NativeDumpObject( + name: table.name, + schema: schema, + isPartitionedParent: table.type == .partitionedTable + ) } - objects += tables - .filter(\.type.isBackupSelectable) - .map { - NativeDumpObject( - name: $0.name, - schema: schema, - isPartitionedParent: $0.type == .partitionedTable - ) - } - } - return objects + } + + private enum BackupScopeLoadError: Error { + case schemasNotListed } /// Everything the dump tool has to be told about to reproduce the chosen objects. diff --git a/TablePro/Core/Database/CatalogTableListing.swift b/TablePro/Core/Database/CatalogTableListing.swift new file mode 100644 index 0000000000..a4476fde1d --- /dev/null +++ b/TablePro/Core/Database/CatalogTableListing.swift @@ -0,0 +1,111 @@ +// +// CatalogTableListing.swift +// TablePro +// + +import Foundation +import os + +/// Every table one database holds, across all of its schemas. +/// +/// An engine that answers `fetchTablesInAllSchemas()` is asked once. Any other is asked schema by +/// schema, and each of those reads queues on the metadata lane by itself, so a sidebar expansion +/// that arrives in the middle waits behind one schema rather than behind all of them. Every read +/// goes through the one scope the caller names: a scope per schema would open a pooled connection +/// per schema. +@MainActor +internal enum CatalogTableListing { + /// A schema whose own read failed is named rather than dropped. Read as empty, it would tell a + /// search that nothing in it matches, and hide exactly the table the search was looking for. + internal struct Result: Sendable, Equatable { + internal let tables: [TableInfo] + internal let unlistedSchemas: Set + + /// This listing with another read of some of its unlisted schemas folded in. A schema the + /// read listed replaces what was known of it; one it still could not list keeps its rows + /// and stays unlisted. + internal func merging(_ retry: Result, retried schemas: Set) -> Result { + let listedNow = schemas.subtracting(retry.unlistedSchemas) + let kept = tables.filter { table in + guard let schema = table.schema else { return true } + return !listedNow.contains(schema) + } + return Result( + tables: kept + retry.tables, + unlistedSchemas: unlistedSchemas.subtracting(schemas).union(retry.unlistedSchemas) + ) + } + + /// A refresh that could not read a schema says nothing new about it, so the rows an earlier + /// listing had for that schema are carried over rather than dropped. + internal func keepingRows(from previous: Result?) -> Result { + guard let previous, !unlistedSchemas.isEmpty else { return self } + let carried = previous.tables.filter { table in + guard let schema = table.schema else { return false } + return unlistedSchemas.contains(schema) + } + return Result(tables: tables + carried, unlistedSchemas: unlistedSchemas) + } + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "CatalogTableListing") + + internal static func tables( + in scope: DatabaseScope, + excludingSchemas excluded: Set, + metadata: ScopedMetadataProviding = DatabaseManager.shared + ) async throws -> Result { + let listed = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + try await driver.fetchTablesInAllSchemas() + } + if let listed { + let tables = listed.filter { table in + guard let schema = table.schema else { return true } + return !excluded.contains(schema) + } + return Result(tables: tables, unlistedSchemas: []) + } + let schemas = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + try await driver.fetchSchemas() + } + return try await tables(inSchemas: schemas.filter { !excluded.contains($0) }, scope: scope, metadata: metadata) + } + + /// The named schemas one by one, which is also how a listing asks again for the schemas it + /// could not read the first time. + /// + /// Only a failure that belongs to one schema is recorded against it. A lost connection fails + /// every schema the same way, and recording that as a listing of nothing would read as a + /// database with no tables, so it fails the whole read instead, as does every schema failing. + internal static func tables( + inSchemas schemas: [String], + scope: DatabaseScope, + metadata: ScopedMetadataProviding = DatabaseManager.shared + ) async throws -> Result { + var tables: [TableInfo] = [] + var unlisted: Set = [] + var lastError: Error? + for schema in schemas { + try Task.checkCancellation() + do { + tables += try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + try await driver.fetchTables(schema: schema) + } + } catch is CancellationError { + throw CancellationError() + } catch let error as DatabaseError { + throw error + } catch { + logger.warning( + "[catalog] schema not listed schema=\(schema, privacy: .private(mask: .hash)) error=\(error.publicLogShape, privacy: .public)" + ) + unlisted.insert(schema) + lastError = error + } + } + if let lastError, !schemas.isEmpty, unlisted.count == schemas.count { + throw lastError + } + return Result(tables: tables, unlistedSchemas: unlisted) + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 879b9ce023..b32f5bf909 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -99,6 +99,10 @@ protocol DatabaseDriver: AnyObject, Sendable { func fetchTables(schema: String?) async throws -> [TableInfo] + /// Every schema's tables in one call, or nil when the engine has no such call and the caller + /// has to ask each schema itself. `CatalogTableListing` is the caller that does. + func fetchTablesInAllSchemas() async throws -> [TableInfo]? + /// Fetch the direct partitions of one partitioned table, with each one's bound, position and /// row estimate. A partition is not a table on every engine, so this cannot answer `TableInfo`: /// a MySQL or Oracle partition name is unique only within its own table. @@ -704,6 +708,8 @@ extension DatabaseDriver { try await fetchTables() } + func fetchTablesInAllSchemas() async throws -> [TableInfo]? { nil } + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { [] } func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 97df2999f9..0cb62ee45e 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -234,6 +234,11 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } } + func fetchTablesInAllSchemas() async throws -> [TableInfo]? { + guard let pluginTables = try await pluginDriver.fetchTablesInAllSchemas() else { return nil } + return pluginTables.map { mapPluginTable($0, schemaFallback: nil) } + } + func fetchPartitionDetails(table: String, schema: String?) async throws -> [PartitionInfo] { let resolvedSchema = schema ?? pluginDriver.currentSchema let partitions = try await pluginDriver.fetchPartitionDetails(table: table, schema: resolvedSchema) diff --git a/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift b/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift new file mode 100644 index 0000000000..47d5e8e877 --- /dev/null +++ b/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift @@ -0,0 +1,49 @@ +// +// AllSchemaTablesDemand.swift +// TablePro +// + +import Foundation + +/// When a reader holding a database's all-schema listing on screen should ask for it again. +/// +/// A reader asks when nothing is held, and otherwise once the listing's revision has moved past +/// the one it last asked at: after a catalog change, a reconnect or a database switch. It never +/// asks twice for the same revision, or a read that failed would be retried on every change the +/// reader observes. And it never asks while the session is not connected, because the load would +/// return without starting and the revision would count as asked for all the same, which left a +/// search stale after every reconnect. +@MainActor +internal struct AllSchemaTablesDemand { + private var requested: [String: Int] = [:] + + internal mutating func reset() { + requested.removeAll() + } + + internal mutating func noteRequested(connectionId: UUID, database: String, service: DatabaseTreeMetadataService) { + requested[database] = service.allSchemaTablesRevision(connectionId: connectionId, database: database) + } + + internal mutating func requestIfNeeded( + connectionId: UUID, + database: String, + isConnected: Bool, + service: DatabaseTreeMetadataService + ) { + guard isConnected, needsRequest(connectionId: connectionId, database: database, service: service) else { return } + noteRequested(connectionId: connectionId, database: database, service: service) + Task { await service.loadAllSchemaTables(connectionId: connectionId, database: database) } + } + + internal func needsRequest(connectionId: UUID, database: String, service: DatabaseTreeMetadataService) -> Bool { + switch service.allSchemaTablesLoadState(connectionId: connectionId, database: database) { + case .loading: + return false + case .idle: + return true + case .loaded, .failed: + return requested[database] != service.allSchemaTablesRevision(connectionId: connectionId, database: database) + } + } +} diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService+CatalogChange.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService+CatalogChange.swift index 5a4054c375..0b86f6fd9e 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService+CatalogChange.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService+CatalogChange.swift @@ -20,10 +20,12 @@ struct CatalogTreeRefreshPlan: Equatable, Sendable { var routines: Set = [] var triggers: Set = [] var types: Set = [] + /// Marked stale rather than refetched, for the reason `loadAllSchemaTables` gives. + var allSchemaTables: Set = [] var isEmpty: Bool { !refreshesDatabaseList && schemaLists.isEmpty && tables.isEmpty && partitions.isEmpty - && routines.isEmpty && triggers.isEmpty && types.isEmpty + && routines.isEmpty && triggers.isEmpty && types.isEmpty && allSchemaTables.isEmpty } } @@ -41,9 +43,11 @@ extension DatabaseTreeMetadataService { partitionKeys: partitionsState.keys, routineKeys: routinesState.keys, triggerKeys: triggersState.keys, - typeKeys: typesState.keys + typeKeys: typesState.keys, + allSchemaTableKeys: allSchemaTablesState.keys ) guard !plan.isEmpty else { return } + markAllSchemaTablesChanged(plan.allSchemaTables) let databaseType = DatabaseManager.shared.session(for: change.connectionId)?.connection.type await withTaskGroup(of: Void.self) { group in if plan.refreshesDatabaseList, let databaseType { @@ -88,7 +92,8 @@ extension DatabaseTreeMetadataService { partitionKeys: some Sequence = EmptyCollection(), routineKeys: some Sequence, triggerKeys: some Sequence, - typeKeys: some Sequence + typeKeys: some Sequence, + allSchemaTableKeys: some Sequence = EmptyCollection() ) -> CatalogTreeRefreshPlan { func reached(_ key: ObjectsKey) -> Bool { key.connectionId == change.connectionId && change.reaches(database: key.database, schema: key.schema) @@ -117,6 +122,11 @@ extension DatabaseTreeMetadataService { plan.routines = objectKeys(routineKeys, for: .routines) plan.triggers = objectKeys(triggerKeys, for: .triggers) plan.types = objectKeys(typeKeys, for: .types) + if !change.kinds.isDisjoint(with: [.tables, .schemas]) { + plan.allSchemaTables = Set(allSchemaTableKeys.filter { key in + key.connectionId == change.connectionId && change.reaches(database: key.database) + }) + } return plan } } diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index fcac01c8e6..3fa78e87a5 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -30,6 +30,15 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { let table: String } + /// Keyed by the revision it was started under as well as the database, so a read that arrives + /// after a catalog change starts its own fetch instead of joining one that began before it. + /// `retrying` names the schemas a second read asks for, and is empty for a whole listing. + struct AllSchemaTablesLoadKey: Hashable, Sendable { + let database: DatabaseKey + let revision: Int + let retrying: Set + } + @Published private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] @Published private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] @Published private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] @@ -37,6 +46,7 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { @Published private(set) var triggersState: [ObjectsKey: MetadataLoadState<[TriggerInfo]>] = [:] @Published private(set) var typesState: [ObjectsKey: MetadataLoadState<[UserDefinedTypeInfo]>] = [:] @Published private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[PartitionInfo]>] = [:] + @Published private(set) var allSchemaTablesState: [DatabaseKey: MetadataLoadState] = [:] private let databaseDedup = OnceTask() private let schemaDedup = OnceTask() @@ -45,6 +55,7 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { private let triggersDedup = OnceTask() private let typesDedup = OnceTask() private let partitionsDedup = OnceTask() + private let allSchemaTablesDedup = OnceTask() private var databaseListFence = CommitFence() private var schemaListFence = CommitFence() @@ -53,6 +64,8 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { private var triggersFence = CommitFence() private var typesFence = CommitFence() private var partitionsFence = CommitFence() + private var allSchemaTablesFence = CommitFence() + private var allSchemaTablesFreshness = CatalogFreshness() nonisolated private static let logger = Logger( subsystem: "com.TablePro", category: "SidebarTree" @@ -119,6 +132,128 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { return partitionsState[key] ?? .idle } + func allSchemaTablesLoadState( + connectionId: UUID, database: String + ) -> MetadataLoadState { + allSchemaTablesState[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle + } + + /// Engines whose tables live in schemas the sidebar does not list until they are opened. The + /// rest list a whole database in the one table list they already load. + nonisolated static func listsTablesPerSchema(_ strategy: GroupingStrategy) -> Bool { + switch strategy { + case .bySchema, .hierarchicalSchema: return true + case .flat, .byDatabase: return false + } + } + + // MARK: - All-schema tables + + /// Every schema's tables in one database, for the searches that have to judge a schema nobody + /// has expanded. Unlike the lists the tree draws, it refreshes when it is next read rather than + /// on every catalog change: a COMMIT reports a catalog change too, and relisting every schema + /// of the database on each one would pay for a listing nobody asked for. + func loadAllSchemaTables(connectionId: UUID, database: String) async { + guard isConnected(connectionId) else { return } + let key = DatabaseKey(connectionId: connectionId, database: database) + if allSchemaTablesFreshness.isCurrent(key) { + await retryUnlistedSchemas(key) + return + } + let revision = allSchemaTablesFreshness.revision(for: key) + allSchemaTablesState[key] = (allSchemaTablesState[key] ?? .idle).enteringLoad + let token = allSchemaTablesFence.token(for: key) + let outcome: MetadataFetchOutcome + do { + let listing = try await allSchemaTablesDedup.execute( + key: AllSchemaTablesLoadKey(database: key, revision: revision, retrying: []) + ) { [self] in + try await fetchAllSchemaTables(key) + } + outcome = .fetched(listing) + } catch is CancellationError { + outcome = .cancelled + } catch { + outcome = .failed(error.localizedDescription) + Self.logger.warning( + "all-schema tables load failed db=\(database, privacy: .private(mask: .hash)) error=\(error.publicLogShape, privacy: .public)" + ) + } + guard allSchemaTablesFence.isCurrent(token, for: key) else { return } + let current = allSchemaTablesState[key] ?? .idle + guard case .fetched(let listing) = outcome else { + allSchemaTablesState[key] = current.settled(by: outcome, discardingValue: false) + return + } + guard allSchemaTablesFreshness.commit(revision, for: key) else { return } + allSchemaTablesState[key] = .loaded(listing.keepingRows(from: current.value)) + } + + /// A listing that could not read some schemas stays current for the rest, and only those are + /// asked for again on the next read. Relisting the whole database each time would repeat a + /// schema-by-schema listing for the one schema the role may never be able to read. + private func retryUnlistedSchemas(_ key: DatabaseKey) async { + guard let listing = allSchemaTablesState[key]?.value, !listing.unlistedSchemas.isEmpty else { return } + let schemas = listing.unlistedSchemas + let revision = allSchemaTablesFreshness.revision(for: key) + let token = allSchemaTablesFence.token(for: key) + let retry: CatalogTableListing.Result + do { + retry = try await allSchemaTablesDedup.execute( + key: AllSchemaTablesLoadKey(database: key, revision: revision, retrying: schemas) + ) { [self] in + try await fetchSchemaTables(key, schemas: schemas) + } + } catch { + return + } + guard allSchemaTablesFence.isCurrent(token, for: key), + allSchemaTablesFreshness.revision(for: key) == revision, + let current = allSchemaTablesState[key]?.value else { return } + allSchemaTablesState[key] = .loaded(current.merging(retry, retried: schemas)) + } + + /// Announced, so a search holding the listing on screen can ask for it again rather than keep + /// matching against rows a catalog change has overtaken. + func markAllSchemaTablesChanged(_ keys: some Sequence) { + var changed = false + for key in keys { + allSchemaTablesFreshness.markChanged(key) + changed = true + } + if changed { + objectWillChange.send() + } + } + + /// Moves with every catalog change that reaches the database, so a caller that asked for the + /// listing at one revision knows to ask again at the next and not before. + func allSchemaTablesRevision(connectionId: UUID, database: String) -> Int { + allSchemaTablesFreshness.revision(for: DatabaseKey(connectionId: connectionId, database: database)) + } + + /// System schemas stay out, as they stay out of the tree until Show System is on. + private func fetchAllSchemaTables(_ key: DatabaseKey) async throws -> CatalogTableListing.Result { + guard let session = DatabaseManager.shared.session(for: key.connectionId) else { + throw DatabaseError.notConnected + } + let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: session.connection.type)) + return try await CatalogTableListing.tables(in: try listingScope(key), excludingSchemas: systemSchemas) + } + + private func fetchSchemaTables(_ key: DatabaseKey, schemas: Set) async throws -> CatalogTableListing.Result { + try await CatalogTableListing.tables(inSchemas: schemas.sorted(), scope: try listingScope(key)) + } + + private func listingScope(_ key: DatabaseKey) throws -> DatabaseScope { + guard let scope = DatabaseManager.shared.resolvedScope( + database: key.database, schema: nil, for: key.connectionId + ) else { + throw DatabaseError.notConnected + } + return scope + } + // MARK: - Loads func loadDatabases(connectionId: UUID, databaseType: DatabaseType) async { @@ -658,6 +793,7 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { func handleReconnect(connectionId: UUID) async { supersedeEveryKey(of: connectionId) SchemaForeignKeyStore.shared.invalidate(connectionId: connectionId) + markAllSchemaTablesChanged(allSchemaTablesState.keys.filter { $0.connectionId == connectionId }) await resetPending(connectionId: connectionId) } @@ -684,6 +820,9 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { for key in connectionPartitionKeys(connectionId) { await partitionsDedup.cancel(key: key) } + await allSchemaTablesDedup.cancel { $0.database.connectionId == connectionId } + allSchemaTablesFreshness.removeAll { $0.connectionId == connectionId } + allSchemaTablesState = allSchemaTablesState.filter { $0.key.connectionId != connectionId } databaseList.removeValue(forKey: connectionId) schemaList = schemaList.filter { $0.key.connectionId != connectionId } tablesState = tablesState.filter { $0.key.connectionId != connectionId } @@ -716,6 +855,9 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { for key in connectionPartitionKeys(connectionId) { partitionsFence.supersede(key) } + for key in allSchemaTablesState.keys where key.connectionId == connectionId { + allSchemaTablesFence.supersede(key) + } } private func resetPending(connectionId: UUID) async { @@ -744,6 +886,9 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { for key in partitionKeys where isPending(partitionsState[key]) { await partitionsDedup.cancel(key: key) } + let allSchemaKeys = allSchemaTablesState.keys.filter { $0.connectionId == connectionId } + await allSchemaTablesDedup.cancel { $0.database.connectionId == connectionId } + for key in allSchemaKeys where isPending(allSchemaTablesState[key]) { allSchemaTablesState[key] = .idle } if isPending(databaseList[connectionId]) { databaseList[connectionId] = .idle } for key in schemaKeys where isPending(schemaList[key]) { schemaList[key] = .idle } diff --git a/TablePro/Core/Services/Query/QuickSwitcherCatalogStore.swift b/TablePro/Core/Services/Query/QuickSwitcherCatalogStore.swift index 80548632f5..74fa7e9fc5 100644 --- a/TablePro/Core/Services/Query/QuickSwitcherCatalogStore.swift +++ b/TablePro/Core/Services/Query/QuickSwitcherCatalogStore.swift @@ -93,9 +93,9 @@ internal final class QuickSwitcherCatalogStore { return items[connectionId] } - /// Stored without any open-tab state. Which tables have a tab changes between presentations - /// while none of the version's inputs move, so a cached `isOpenInTab` would badge a table the - /// user has since closed. + /// The panel keeps no tables here. They change without any of the version's inputs moving, a + /// schema listed after the panel opened or a tab opened since, so it merges them in live. Open + /// state is stripped all the same, so nothing stored can badge a tab the user has closed. internal func store(_ catalog: [QuickSwitcherItem], for connectionId: UUID, version: Version) { items[connectionId] = catalog.map { item in var stored = item diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 1a7864dd41..9750d96364 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -229,6 +229,15 @@ final class SchemaService: ObservableObject { perSchemaSideObjects[connectionId]?[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 + guard case .loaded = state else { return nil } + return 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). diff --git a/TablePro/Core/Utilities/UI/QualifiedSearchQuery.swift b/TablePro/Core/Utilities/UI/QualifiedSearchQuery.swift new file mode 100644 index 0000000000..6b231002a9 --- /dev/null +++ b/TablePro/Core/Utilities/UI/QualifiedSearchQuery.swift @@ -0,0 +1,85 @@ +// +// QualifiedSearchQuery.swift +// TablePro +// + +import Foundation + +/// A search that says where an object lives as well as what it is called: `attendance.timesheet`, +/// `attendance.` for everything in one schema, or `shop.attendance.timesheet`. +/// +/// It parses text that is still being typed rather than finished SQL, so it is lenient where an +/// identifier parser would refuse: spaces around a dot are ignored, an unterminated quote runs to +/// the end of the text, and the quotes of every engine the app speaks are accepted, `"x"`, `[x]` +/// and `` `x` ``, each with its doubled closing character as an escape. A dot inside quotes belongs +/// to the name. Text with no unquoted dot is not qualified, and neither is text with an empty +/// container such as `.orders` or `a..b`, so both stay ordinary searches. +internal struct QualifiedSearchQuery: Equatable, Sendable { + /// Outermost first: `["shop", "attendance"]` for `shop.attendance.timesheet`. + internal let containers: [String] + /// Empty when the text ends in a dot, which asks for everything in the container. + internal let name: String + + internal init?(_ text: String) { + let segments = Self.segments(of: text) + guard segments.count > 1, let name = segments.last else { return nil } + let containers = Array(segments.dropLast()) + guard !containers.contains(where: \.isEmpty) else { return nil } + self.containers = containers + self.name = name + } + + /// Pairs each container the query names with the part of `location` it has to match, right to + /// left, so `attendance.timesheet` reaches the schema of `[database, schema]` and + /// `shop.attendance.timesheet` reaches both. Nil when the query names more containers than the + /// location has, which no match can satisfy. + internal func containerPairs(with location: [String]) -> [(query: String, candidate: String)]? { + guard containers.count <= location.count else { return nil } + return Array(zip(containers, location.suffix(containers.count))) + } + + /// Where an object lives, outermost first, as a qualified query addresses it. Both levels stay + /// even when they share a name: a PostgreSQL database `shop` can hold a schema `shop`, and + /// `shop.shop.orders` has to reach it. An engine that reports its database as the schema of a + /// schema-less object still matches `shop.orders`, since the query lines up from the right. + internal static func location(database: String?, schema: String?) -> [String] { + [database, schema].compactMap { part in + guard let part, !part.isEmpty else { return nil } + return part + } + } + + private static let closingQuotes: [Character: Character] = ["\"": "\"", "`": "`", "[": "]"] + + private static func segments(of text: String) -> [String] { + var segments: [String] = [] + var current = "" + var closingQuote: Character? + var characters = Array(text)[...] + while let character = characters.popFirst() { + if let closing = closingQuote { + guard character == closing else { + current.append(character) + continue + } + if characters.first == closing { + current.append(closing) + characters.removeFirst() + } else { + closingQuote = nil + } + continue + } + if let closing = closingQuotes[character] { + closingQuote = closing + } else if character == "." { + segments.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + } else { + current.append(character) + } + } + segments.append(current.trimmingCharacters(in: .whitespaces)) + return segments + } +} diff --git a/TablePro/Core/Utilities/UI/SidebarSearch.swift b/TablePro/Core/Utilities/UI/SidebarSearch.swift new file mode 100644 index 0000000000..e562bb8200 --- /dev/null +++ b/TablePro/Core/Utilities/UI/SidebarSearch.swift @@ -0,0 +1,62 @@ +// +// SidebarSearch.swift +// TablePro +// + +import Foundation + +/// The sidebar filter's text, read either as a name or as a qualified path. +/// +/// A plain query matches object names, and a container whose own name matches it shows everything +/// inside. A qualified one, `attendance.timesheet`, splits the two: its containers decide which +/// schemas and databases may hold a match, and only its last part is matched against object names, +/// so `attendance.` lists everything in `attendance` and nothing anywhere else. The text still +/// matches as a plain name too, or a table actually named `audit.events` could no longer be found +/// by typing its name. Matching is the same substring match the plain filter uses, never fuzzy. +internal struct SidebarSearch: Equatable, Sendable { + internal let text: String + internal let qualified: QualifiedSearchQuery? + + internal init(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespaces) + self.text = trimmed + self.qualified = QualifiedSearchQuery(trimmed) + } + + internal var isEmpty: Bool { + text.isEmpty + } + + /// What object names are matched against. Empty for `attendance.`, where every object in an + /// admitted container matches. + internal var nameQuery: String { + qualified?.name ?? text + } + + internal func matchesName(_ name: String) -> Bool { + SidebarNameFilter.matches(query: nameQuery, candidate: name) + } + + /// An object matches as a path, or by holding the whole text in its own name. + internal func matchesObject(named name: String, database: String?, schema: String?) -> Bool { + if SidebarNameFilter.matches(query: text, candidate: name) { return true } + guard qualified != nil else { return false } + return admits(database: database, schema: schema) && matchesName(name) + } + + /// Whether a container at this location may hold a match. Every container may for a plain + /// query, which matches on object names alone. + internal func admits(database: String?, schema: String?) -> Bool { + guard let qualified else { return true } + let location = QualifiedSearchQuery.location(database: database, schema: schema) + guard let pairs = qualified.containerPairs(with: location) else { return false } + return pairs.allSatisfy { SidebarNameFilter.matches(query: $0.query, candidate: $0.candidate) } + } + + /// Whether the container itself answers the search: its name matches a plain query, or a + /// qualified query admits it and asks for everything inside. + internal func matchesContainer(database: String?, schema: String) -> Bool { + guard let qualified else { return SidebarNameFilter.matches(query: text, candidate: schema) } + return qualified.name.isEmpty && admits(database: database, schema: schema) + } +} diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index b37a3d67e0..03891a2e56 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -114,6 +114,23 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { /// Set on a routine or trigger row, which opens its source rather than a table tab. var objectRef: DatabaseObjectRef? var target: QuickSwitcherTarget? + /// The database of an object in the connection that opened the panel, which a query like + /// `shop.public.orders` names. `target` carries it for a result in another connection. + var databaseName: String? + /// Carried to the tab so Recent remembers what the object is. A sequence opened as a plain + /// view came back from Recent offering Drop View. + var tableType: TableInfo.TableType? + /// A table in a schema other than the one being browsed, whose row names that schema even + /// while selected, because two such rows can differ in nothing else. + var isOutsideBrowsedSchema: Bool = false + + /// Where the object lives, outermost first, for a qualified query to match. + var searchLocation: [String] { + guard let target else { + return QualifiedSearchQuery.location(database: databaseName, schema: schemaName) + } + return QualifiedSearchQuery.location(database: target.databaseName, schema: target.schemaName) + } /// The frecency identity of a table, produced identically by the two places that record one: /// the quick switcher, which knows the object's `TableInfo.TableType`, and the tab open @@ -126,9 +143,20 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { /// and those objects could never reach the Recent section or earn a frecency boost no matter /// how often they were opened. A name and a schema identify one object in a database whatever /// its type, so the type buys nothing here. + /// + /// A dot or backslash inside a name is escaped, or schema `a` with table `b.c` and schema `a.b` + /// with table `c` would share one id, one row selection and one Recent entry. A name with + /// neither keeps the id it always had, so no Recent history is lost. static func tableItemId(name: String, schema: String?) -> String { - guard let schema, !schema.isEmpty else { return "table_\(name)" } - return "table_\(schema).\(name)" + guard let schema, !schema.isEmpty else { return "table_\(escapedIdComponent(name))" } + return "table_\(escapedIdComponent(schema)).\(escapedIdComponent(name))" + } + + private static func escapedIdComponent(_ component: String) -> String { + guard component.contains(where: { $0 == "." || $0 == "\\" }) else { return component } + return component + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: ".", with: "\\.") } /// SF Symbol name for this item's icon diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 849dc33d6a..9b2c2e882b 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -14,6 +14,8 @@ private enum QuickSwitcherRanking { static let keywordMatchWeight = 1.0 static let frecencyBoost = 0.5 static let openTabBoost = 1.2 + static let containerMatchWeight = 0.5 + static let otherSchemaWeight = 0.97 } @MainActor @@ -46,6 +48,18 @@ internal final class QuickSwitcherViewModel: ObservableObject { let items: [QuickSwitcherItem] } + /// What the panel's own connection is browsing, which is what its table rows are built for. + struct TableSource { + let database: String? + let browseSchema: String? + let openTables: Set + let grouping: GroupingStrategy + + var listsTablesPerSchema: Bool { + DatabaseTreeMetadataService.listsTablesPerSchema(grouping) + } + } + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") private static let recentLimit = 10 private static let filterDebounceNanoseconds: UInt64 = 40_000_000 @@ -71,6 +85,11 @@ internal final class QuickSwitcherViewModel: ObservableObject { internal var crossConnectionQueryItems: [QuickSwitcherItem] = [] { didSet { scheduleFilter(debounced: false) } } + private var baseItems: [QuickSwitcherItem]? + private var tableItems: [QuickSwitcherItem] = [] + private var tableSource: TableSource? + private var tableSourceObservations: [AnyCancellable] = [] + private var listingDemand = AllSchemaTablesDemand() private var filterTask: Task? private var selectionQuery: String? private var selectionScope: QuickSwitcherScope? @@ -86,6 +105,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// for a beat after the catalog arrives. Without this the panel calls that emptiness "no /// results" and says so, for the whole first sort. @Published private(set) var isFiltering = false + /// The tables of schemas other than the browsed one arrive after the rest of the catalog, so a + /// search that finds nothing yet is still loading rather than a miss. + @Published private(set) var isLoadingTables = false @Published private(set) var isLoadingCrossConnections = false @Published private(set) var isLoadingCrossConnectionQueries = false @Published private(set) var crossConnectionQueryContentRevision = 0 @@ -129,7 +151,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { return isLoadingCrossConnectionQueries } guard scope != .all || !trimmedSearchText.isEmpty else { return false } - return isLoading || isFiltering + let awaitsTables = isLoadingTables && scope.includedKinds.map { $0.contains(.table) } ?? true + return isLoading || isFiltering || awaitsTables } private var trimmedSearchText: String { @@ -172,8 +195,10 @@ internal final class QuickSwitcherViewModel: ObservableObject { self.init(connectionId: connectionId, services: .live) } + /// Tables are not part of the cached catalog. They are merged in from the services that own + /// them, every time either one changes while the panel is open, so a list the sidebar loads or + /// the all-schema listing arriving after the panel opened still reaches it. func loadItems( - schemaProvider: SQLSchemaProvider, databaseType: DatabaseType, openTables: Set = [], browseSchema: String? = nil @@ -183,40 +208,45 @@ internal final class QuickSwitcherViewModel: ObservableObject { let loadId = UUID() activeLoadId = loadId + let tableSource = TableSource( + database: services.databaseManager.browseScope(for: connectionId)?.database, + browseSchema: browseSchema, + openTables: openTables, + grouping: services.pluginManager.databaseGroupingStrategy(for: databaseType) + ) + self.tableSource = tableSource + observeTableSources() + async let tablesLoaded: Void = loadTables(from: tableSource, loadId: loadId) + /// Read once and used both to key the catalog and to build it. Reading it again after the /// awaits below let the two disagree: the items were filtered by whatever the sidebar held /// when the fetches finished, and stored under whatever it held when they started. let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected let catalogVersion = self.catalogVersion(databaseFilter: databaseFilter) if let cached = catalogStore.catalog(for: connectionId, version: catalogVersion) { - allItems = Self.applyingOpenState(to: cached, openTables: openTables, browsing: browseSchema) - return + baseItems = cached + } else { + let catalog = await loadBaseCatalog(databaseType: databaseType, databaseFilter: databaseFilter) + guard activeLoadId == loadId, !Task.isCancelled else { return } + if catalog.isComplete { + catalogStore.store(catalog.items, for: connectionId, version: catalogVersion) + } + baseItems = catalog.items } + tableItems = currentTableItems() + publishItems() + await tablesLoaded + } + /// Everything but the tables, which is what the catalog store keeps. Incomplete when a fetch + /// failed, and then it is not stored: a cached catalog is served without fetching anything, so + /// a schema list that timed out once would have stayed missing on every reopen. + private func loadBaseCatalog( + databaseType: DatabaseType, + databaseFilter: Set + ) async -> (items: [QuickSwitcherItem], isComplete: Bool) { var items: [QuickSwitcherItem] = [] - - if let scope = services.databaseManager.browseScope(for: connectionId) { - await SchemaProviderRegistry.shared.prepare( - for: scope, - connection: services.databaseManager.session(for: connectionId)?.connection - ) - } - - let tables = await schemaProvider.getTables() - for table in tables { - let presentation = Self.tablePresentation(for: table.type) - items.append(QuickSwitcherItem( - id: QuickSwitcherItem.tableItemId(name: table.name, schema: table.schema), - name: table.name, - kind: presentation.kind, - subtitle: presentation.subtitle, - isOpenInTab: openTables.contains( - QuickSwitcherOpenTable(schema: table.schema, name: table.name, browsing: browseSchema) - ), - isReadOnly: !table.type.allowsRowEditing, - schemaName: table.schema - )) - } + var isComplete = true let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) let activeDatabase = services.databaseManager.session(for: connectionId) @@ -249,6 +279,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { )) } } catch { + isComplete = false Self.logger.warning("Failed to fetch databases: \(error.publicLogShape, privacy: .public)") } } @@ -270,6 +301,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { )) } } catch { + isComplete = false Self.logger.warning("Failed to fetch schemas: \(error.publicLogShape, privacy: .public)") } } @@ -304,10 +336,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { )) } - guard activeLoadId == loadId, !Task.isCancelled else { return } - - catalogStore.store(items, for: connectionId, version: catalogVersion) - allItems = items + return (items, isComplete) } /// The catalog is a function of these, so a presentation that finds them unchanged can serve @@ -338,21 +367,84 @@ internal final class QuickSwitcherViewModel: ObservableObject { return names } - /// Which tables have a tab is not part of the catalog's version, so it is applied on the way - /// out rather than stored. A cached flag would badge a table the user has since closed. - nonisolated static func applyingOpenState( - to catalog: [QuickSwitcherItem], - openTables: Set, - browsing browseSchema: String? - ) -> [QuickSwitcherItem] { - catalog.map { item in - guard item.kind == .table || item.kind == .view || item.kind == .systemTable else { return item } - var updated = item - updated.isOpenInTab = openTables.contains( - QuickSwitcherOpenTable(schema: item.schemaName, name: item.name, browsing: browseSchema) - ) - return updated + /// The browse schema's tables come from the schema service, which the sidebar loaded on + /// connect; every other schema's come from the all-schema listing, which is asked for here. + private func loadTables(from source: TableSource, loadId: UUID) async { + isLoadingTables = true + defer { + if activeLoadId == loadId { isLoadingTables = false } + } + _ = await services.schemaRefreshService.loadBrowseCatalogs(connectionIds: [connectionId]) + guard activeLoadId == loadId else { return } + mergeTableItems() + guard source.listsTablesPerSchema, let database = source.database else { return } + let service = DatabaseTreeMetadataService.shared + listingDemand.noteRequested(connectionId: connectionId, database: database, service: service) + await service.loadAllSchemaTables(connectionId: connectionId, database: database) + guard activeLoadId == loadId else { return } + mergeTableItems() + } + + /// A change to either source while the panel is open reaches it. A change that leaves the + /// tables as they were costs a comparison and no refilter. A catalog change or a reconnect + /// while it is open asks for the listing again. + private func observeTableSources() { + tableSourceObservations = [ + services.schemaService.onMainActorChange { [weak self] in self?.mergeTableItems() }, + DatabaseTreeMetadataService.shared.onMainActorChange { [weak self] in + self?.requestListingIfStale() + self?.mergeTableItems() + }, + services.databaseManager.onMainActorChange { [weak self] in self?.requestListingIfStale() } + ] + } + + private func requestListingIfStale() { + guard let tableSource, tableSource.listsTablesPerSchema, let database = tableSource.database else { return } + listingDemand.requestIfNeeded( + connectionId: connectionId, + database: database, + isConnected: services.databaseManager.session(for: connectionId)?.status == .connected, + service: DatabaseTreeMetadataService.shared + ) + } + + private func mergeTableItems() { + let items = currentTableItems() + guard items != tableItems else { return } + tableItems = items + publishItems() + } + + private func currentTableItems() -> [QuickSwitcherItem] { + guard let tableSource else { return [] } + let listing = tableSource.database.flatMap { database -> [TableInfo]? in + guard tableSource.listsTablesPerSchema else { return nil } + return DatabaseTreeMetadataService.shared + .allSchemaTablesLoadState(connectionId: connectionId, database: database).value?.tables } + let loadedScope = services.schemaService.loadedScope(for: connectionId) + let tables = Self.mergedTables( + local: services.schemaService.allLoadedTables(for: connectionId), + loadedFrom: loadedScope?.database, + coveredSchemas: coveredSchemas(loadedScope: loadedScope, grouping: tableSource.grouping), + listing: listing, + browsing: tableSource.database, + grouping: tableSource.grouping + ) + return Self.makeTableItems( + tables, + database: tableSource.database, + browseSchema: tableSource.browseSchema, + openTables: tableSource.openTables + ) + } + + /// Nothing is shown before the rest of the catalog has arrived, as before tables were merged + /// separately: assigning `allItems` is what ends the load. + private func publishItems() { + guard let baseItems else { return } + allItems = tableItems + baseItems } /// Loading is keyed on a version of the world, so it must always record the version it @@ -559,6 +651,88 @@ internal final class QuickSwitcherViewModel: ObservableObject { ) } + /// The schema service answers for every schema it holds a list of, because it refreshes that + /// list the moment the catalog changes. The all-schema listing fills in the schemas it does not + /// hold, and may be a read behind for those until it is next asked. + /// + /// The schema service keeps its lists until a reload replaces them, so while a database switch + /// settles it still holds the old database's tables, which would open against the new one. + /// They count only once the database they were loaded from is the one being browsed. + /// + /// `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 + ) -> [TableInfo] { + let listingAnswersAll = grouping == .hierarchicalSchema && listing != nil + let isCurrent = loadedDatabase == database && !listingAnswersAll + let local = isCurrent ? loaded : [] + let authoritative = (isCurrent ? coveredSchemas : []).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 } + } + + /// The schemas the schema service holds an answer for. On a schema-grouped engine its flat list + /// is the browsed schema's; on a hierarchical one each schema keeps a list of its own, and the + /// flat list is empty whatever the browsed schema holds. + private func coveredSchemas(loadedScope: DatabaseScope?, grouping: GroupingStrategy) -> Set { + var covered = services.schemaService.schemasWithLoadedTables(for: connectionId) + if grouping != .hierarchicalSchema, + services.schemaService.hasLoadedContent(for: connectionId), + let schema = loadedScope?.schema { + covered.insert(schema) + } + return covered + } + + private struct TableIdentity: Hashable { + let schema: String + let name: String + } + + /// A table outside the browsed schema names its schema, and one inside it does not, the way + /// SQL written against the browsed schema would spell them. + nonisolated static func makeTableItems( + _ tables: [TableInfo], + database: String?, + browseSchema: String?, + openTables: Set + ) -> [QuickSwitcherItem] { + tables.map { table in + let presentation = tablePresentation(for: table.type) + let otherSchema = SchemaQualifiedName.explicitSchema(table.schema, implicitSchemaName: browseSchema) + let subtitle = [otherSchema, presentation.subtitle] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: " · ") + return QuickSwitcherItem( + id: QuickSwitcherItem.tableItemId(name: table.name, schema: table.schema), + name: table.name, + kind: presentation.kind, + subtitle: subtitle, + isOpenInTab: openTables.contains( + QuickSwitcherOpenTable(schema: table.schema, name: table.name, browsing: browseSchema) + ), + isReadOnly: !table.type.allowsRowEditing, + schemaName: table.schema, + databaseName: database, + tableType: table.type, + isOutsideBrowsedSchema: otherSchema != nil + ) + } + } + nonisolated static func makeCrossConnectionItems( tables: [TableInfo], target: QuickSwitcherTarget @@ -579,7 +753,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { kind: presentation.kind, subtitle: connectionPath(for: resolvedTarget), isReadOnly: !table.type.allowsRowEditing, - target: resolvedTarget + target: resolvedTarget, + tableType: table.type ) } } @@ -794,7 +969,10 @@ internal final class QuickSwitcherViewModel: ObservableObject { for kind in QuickSwitcherItemKind.displayOrder { let kindItems = items .filter { $0.kind == kind && !recentIdSet.contains($0.id) } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .sorted { lhs, rhs in + if lhs.isOutsideBrowsedSchema != rhs.isOutsideBrowsedSchema { return rhs.isOutsideBrowsedSchema } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } guard !kindItems.isEmpty else { continue } result.append(Group( id: "kind-\(kind.rawValue)", @@ -845,13 +1023,18 @@ internal final class QuickSwitcherViewModel: ObservableObject { query: String, frecencyScores: [String: Double] ) async -> [Group] { + let qualified = QualifiedSearchQuery(query) + let prefersShorterNames = qualified.map { !$0.name.isEmpty } ?? true var ranked = items.compactMap { item -> (item: QuickSwitcherItem, rank: Double)? in - guard let (matchScore, matchedIndices) = bestMatch(for: item, query: query) else { return nil } + guard let (matchScore, matchedIndices) = bestMatch(for: item, query: query, qualified: qualified) else { + return nil + } var matched = item matched.matchedIndices = matchedIndices let frecency = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost let openBoost = item.isOpenInTab ? QuickSwitcherRanking.openTabBoost : 1 - return (matched, matchScore * item.kind.rankWeight * frecency * openBoost) + let location = item.isOutsideBrowsedSchema ? QuickSwitcherRanking.otherSchemaWeight : 1 + return (matched, matchScore * item.kind.rankWeight * frecency * openBoost * location) } ranked.sort { lhs, rhs in if lhs.rank != rhs.rank { return lhs.rank > rhs.rank } @@ -860,7 +1043,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { if lhsOrder != rhsOrder { return lhsOrder < rhsOrder } let lhsLength = (lhs.item.name as NSString).length let rhsLength = (rhs.item.name as NSString).length - if lhsLength != rhsLength { return lhsLength < rhsLength } + if prefersShorterNames, lhsLength != rhsLength { return lhsLength < rhsLength } return lhs.item.name.localizedStandardCompare(rhs.item.name) == .orderedAscending } let items = Array(ranked.prefix(QuickSwitcherRanking.maxResults).map(\.item)) @@ -868,7 +1051,39 @@ internal final class QuickSwitcherViewModel: ObservableObject { return [Group(id: "results", header: nil, items: items)] } + /// A dotted query is read both ways and the better reading wins: as a path, and as plain text, + /// which is what still finds a table literally named `b.c` and a connection path in a subtitle. nonisolated private static func bestMatch( + for item: QuickSwitcherItem, + query: String, + qualified: QualifiedSearchQuery? + ) -> (score: Double, matchedIndices: [Int])? { + let plain = plainMatch(for: item, query: query) + guard let qualified, let path = pathMatch(for: item, query: qualified) else { return plain } + guard let plain, plain.score >= path.score else { return path } + return plain + } + + /// Each container the query names has to match the part of the item's location it lines up + /// with, and the name has to match the item's name. An empty name, `attendance.`, takes every + /// item in the matched container. + nonisolated static func pathMatch( + for item: QuickSwitcherItem, + query: QualifiedSearchQuery + ) -> (score: Double, matchedIndices: [Int])? { + guard let pairs = query.containerPairs(with: item.searchLocation) else { return nil } + var containerScore = 0.0 + for pair in pairs { + guard let match = FuzzyMatcher.match(query: pair.query, candidate: pair.candidate) else { return nil } + containerScore += Double(match.score) + } + let weightedContainers = containerScore * QuickSwitcherRanking.containerMatchWeight + guard !query.name.isEmpty else { return (weightedContainers, []) } + guard let nameMatch = FuzzyMatcher.match(query: query.name, candidate: item.name) else { return nil } + return (Double(nameMatch.score) + weightedContainers, nameMatch.matchedIndices) + } + + nonisolated private static func plainMatch( for item: QuickSwitcherItem, query: String ) -> (score: Double, matchedIndices: [Int])? { @@ -953,7 +1168,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { kind: routine.kind == .procedure ? .procedure : .function, subtitle: routine.schema ?? database ?? "", schemaName: routine.schema, - objectRef: DatabaseObjectRef(routine: routine, database: database ?? "") + objectRef: DatabaseObjectRef(routine: routine, database: database ?? ""), + databaseName: database ) } } @@ -966,7 +1182,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { kind: .trigger, subtitle: trigger.table ?? trigger.schema ?? database ?? "", schemaName: trigger.schema, - objectRef: DatabaseObjectRef(trigger: trigger, database: database ?? "") + objectRef: DatabaseObjectRef(trigger: trigger, database: database ?? ""), + databaseName: database ) } } @@ -979,7 +1196,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { kind: .userType, subtitle: type.schema ?? database ?? "", schemaName: type.schema, - objectRef: DatabaseObjectRef(userType: type, database: database ?? "") + objectRef: DatabaseObjectRef(userType: type, database: database ?? ""), + databaseName: database ) } } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 55dd8dfb92..f96378c398 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -6,6 +6,7 @@ import Combine import os import SwiftUI +import TableProPluginKit @MainActor final class SidebarViewModel: ObservableObject { @@ -103,9 +104,16 @@ final class SidebarViewModel: ObservableObject { } @Published private(set) var filterQuery = "" { - didSet { invalidateFilterCaches() } + didSet { + invalidateFilterCaches() + listingDemand.reset() + loadAllSchemaTablesForSearch() + } } + private var listingDemand = AllSchemaTablesDemand() + private var listingDemandObservations: [AnyCancellable] = [] + private var filterDebounceTask: Task? @Published var expanded: ExpansionState { @@ -205,6 +213,14 @@ final class SidebarViewModel: ObservableObject { /// initializer instead ran on every view-graph pass. self.filterQuery = self.sharedState.searchText observeSearchText() + observeListingDemand() + } + + private func observeListingDemand() { + listingDemandObservations = [ + DatabaseTreeMetadataService.shared.onMainActorChange { [weak self] in self?.loadAllSchemaTablesForSearch() }, + DatabaseManager.shared.onMainActorChange { [weak self] in self?.loadAllSchemaTablesForSearch() } + ] } private static func loadInitialExpansion(connectionId: UUID) -> ExpansionState { @@ -426,9 +442,10 @@ final class SidebarViewModel: ObservableObject { } func filteredRecentTables(_ tables: [TableInfo]) -> [TableInfo] { - let query = filterQuery - guard !query.isEmpty else { return tables } - return tables.filter { SidebarNameFilter.matches(query: query, candidate: $0.name) } + let search = SidebarSearch(filterQuery) + guard !search.isEmpty else { return tables } + let database = browsedDatabase + return tables.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } } func filteredRoutines(of kind: SidebarObjectKind, from routines: [RoutineInfo]) -> [RoutineInfo] { @@ -452,7 +469,7 @@ final class SidebarViewModel: ObservableObject { if cachedFilteredTriggersFingerprint?.count != fingerprint.count || cachedFilteredTriggersFingerprint?.generation != fingerprint.generation || cachedFilteredTriggersFingerprint?.query != fingerprint.query { - cachedFilteredTriggers = DatabaseTreeFilter.filteredTriggers(triggers, searchText: query) + cachedFilteredTriggers = DatabaseTreeFilter.filteredTriggers(triggers, searchText: query, database: browsedDatabase) cachedFilteredTriggersFingerprint = fingerprint } return cachedFilteredTriggers @@ -464,7 +481,9 @@ final class SidebarViewModel: ObservableObject { if cachedFilteredUserTypesFingerprint?.count != fingerprint.count || cachedFilteredUserTypesFingerprint?.generation != fingerprint.generation || cachedFilteredUserTypesFingerprint?.query != fingerprint.query { - cachedFilteredUserTypes = DatabaseTreeFilter.filteredUserTypes(types, searchText: query) + cachedFilteredUserTypes = DatabaseTreeFilter.filteredUserTypes( + types, searchText: query, database: browsedDatabase + ) cachedFilteredUserTypesFingerprint = fingerprint } return cachedFilteredUserTypes @@ -475,8 +494,13 @@ final class SidebarViewModel: ObservableObject { return expanded[kind] } + /// A qualified search reaches this list only when it names the schema being browsed: the flat + /// list holds that schema alone, and the other schemas it names are listed below it. private func applyQuery(_ query: String, to tables: [TableInfo]) -> [TableInfo] { - SidebarNameFilter.ranked(tables, query: query, name: { $0.name }) + let search = SidebarSearch(query) + let database = browsedDatabase + let admitted = tables.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } + return SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) } /// Goes through DatabaseTreeFilter so the flat root and the tree share one dedup owner. The @@ -484,7 +508,41 @@ final class SidebarViewModel: ObservableObject { /// handed NSOutlineView the same node object at several row indices and selection snapped back /// to the first of them. private func applyRoutineQuery(_ query: String, to routines: [RoutineInfo]) -> [RoutineInfo] { - DatabaseTreeFilter.filteredRoutines(routines, searchText: query) + DatabaseTreeFilter.filteredRoutines(routines, searchText: query, database: browsedDatabase) + } + + /// Every object the flat list holds lives in this database, which a qualified search can name. + private var browsedDatabase: String? { + DatabaseManager.shared.browseScope(for: connectionId)?.database + } + + /// 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 } + 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 + } + ) + if let browsedDatabase, !browsedDatabase.isEmpty { + databases.insert(browsedDatabase) + } + let isConnected = DatabaseManager.shared.session(for: connectionId)?.status == .connected + for database in databases { + listingDemand.requestIfNeeded( + connectionId: connectionId, + database: database, + isConnected: isConnected, + service: service + ) + } } private func rebuildKindBuckets(from tables: [TableInfo]) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 3294a4bd9c..57a7aafce3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -23,8 +23,6 @@ extension MainContentCoordinator { FeatureTipSignals.quickSwitcherOpened() } let browseSchema = services.databaseManager.session(for: connectionId)?.browseSchema - let switcherScope = browseScope - ?? DatabaseScope(connectionId: connectionId, database: connection.database, schema: nil) let openTables = Set( tabManager.tabs .filter { $0.tabType == .table } @@ -38,7 +36,6 @@ extension MainContentCoordinator { } ) let panelView = QuickSwitcherPanelView( - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: switcherScope), connectionId: connectionId, databaseType: connection.type, openTables: openTables, @@ -69,6 +66,7 @@ extension MainContentCoordinator { schema: schemaName, showStructure: intent == .openStructure, isView: item.isReadOnly, + objectType: item.tableType, activateGridFocus: true, forceNewTab: intent == .openInNewWindowTab ) @@ -79,6 +77,7 @@ extension MainContentCoordinator { schema: schemaName, showStructure: intent == .openStructure, isView: true, + objectType: item.tableType, activateGridFocus: true, forceNewTab: intent == .openInNewWindowTab ) @@ -121,6 +120,7 @@ extension MainContentCoordinator { item.name, schema: target.schemaName, isView: item.kind == .view || item.isReadOnly, + objectType: item.tableType, activateGridFocus: true, forceNewTab: intent == .openInNewWindowTab ) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index ce6ad28f14..86156a88dc 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -7,7 +7,6 @@ import AppKit import SwiftUI struct QuickSwitcherPanelView: View { - let schemaProvider: SQLSchemaProvider let connectionId: UUID let databaseType: DatabaseType let openTables: Set @@ -18,7 +17,6 @@ struct QuickSwitcherPanelView: View { @StateObject private var viewModel: QuickSwitcherViewModel init( - schemaProvider: SQLSchemaProvider, connectionId: UUID, databaseType: DatabaseType, openTables: Set = [], @@ -26,7 +24,6 @@ struct QuickSwitcherPanelView: View { onSelect: @escaping (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void, onDismiss: @escaping () -> Void ) { - self.schemaProvider = schemaProvider self.connectionId = connectionId self.databaseType = databaseType self.openTables = openTables @@ -44,7 +41,6 @@ struct QuickSwitcherPanelView: View { } .task { await viewModel.loadItems( - schemaProvider: schemaProvider, databaseType: databaseType, openTables: openTables, browseSchema: browseSchema @@ -296,6 +292,7 @@ struct QuickSwitcherPanelContent: View { .contextMenu { contextMenuActions(for: item) } .accessibilityElement(children: .combine) .accessibilityLabel(Text(item.name)) + .accessibilityValue(Text(namesLocation(item) ? item.subtitle : "")) .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) .accessibilityAction { onCommit(item, .open) } .id(item.id) @@ -345,10 +342,17 @@ struct QuickSwitcherPanelContent: View { } /// A cross-connection result keeps its path while selected, because it names the connection - /// the commit is about to open and nothing else on the row carries that. + /// the commit is about to open and nothing else on the row carries that. A table in another + /// schema keeps its schema for the same reason. private func showsSubtitle(for item: QuickSwitcherItem, isSelected: Bool) -> Bool { guard !item.subtitle.isEmpty else { return false } - return !isSelected || item.target != nil + return !isSelected || namesLocation(item) + } + + /// Spoken as well as shown, or two rows named `timesheet` in different schemas would read the + /// same to VoiceOver. + private func namesLocation(_ item: QuickSwitcherItem) -> Bool { + item.target != nil || item.isOutsideBrowsedSchema } private func commitHint(for item: QuickSwitcherItem) -> String { diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index ddb18fff15..686150f71e 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -58,31 +58,53 @@ enum DatabaseTreeFilter { SidebarNameFilter.matches(query: query, candidate: candidate) } - static func filteredTables(_ tables: [TableInfo], searchText: String) -> [TableInfo] { - let matched = SidebarNameFilter.ranked(tables, query: searchText, name: { $0.name }) + /// `database` is where the objects live when the caller knows it. A qualified search matches + /// each object's own schema, and the database only when the search names one. + static func filteredTables(_ tables: [TableInfo], searchText: String, database: String? = nil) -> [TableInfo] { + let search = SidebarSearch(searchText) + let admitted = tables.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } + let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) return deduplicated(matched, by: \.id) } - static func filteredRoutines(_ routines: [RoutineInfo], searchText: String) -> [RoutineInfo] { - let matched = SidebarNameFilter.ranked(routines, query: searchText, name: { $0.name }) + static func filteredRoutines( + _ routines: [RoutineInfo], + searchText: String, + database: String? = nil + ) -> [RoutineInfo] { + let search = SidebarSearch(searchText) + let admitted = routines.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } + let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) return deduplicated(matched, by: \.id) } /// A trigger is findable by its own name and by the table it fires for, because a reader who /// knows only the table is exactly the reader the database-level list exists for. - static func filteredTriggers(_ triggers: [TriggerInfo], searchText: String) -> [TriggerInfo] { - let matched = SidebarNameFilter.ranked(triggers, query: searchText, name: { $0.name }) - let byTable = searchText.isEmpty + static func filteredTriggers( + _ triggers: [TriggerInfo], + searchText: String, + database: String? = nil + ) -> [TriggerInfo] { + let search = SidebarSearch(searchText) + let named = triggers.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } + let matched = SidebarNameFilter.ranked(named, query: search.nameQuery, name: { $0.name }) + let byTable = search.nameQuery.isEmpty ? [] : triggers.filter { trigger in - guard let table = trigger.table, matches(searchText, table) else { return false } - return true + guard let table = trigger.table else { return false } + return search.matchesObject(named: table, database: database, schema: trigger.schema) } return deduplicated(matched + byTable, by: \.id) } - static func filteredUserTypes(_ types: [UserDefinedTypeInfo], searchText: String) -> [UserDefinedTypeInfo] { - let matched = SidebarNameFilter.ranked(types, query: searchText, name: { $0.name }) + static func filteredUserTypes( + _ types: [UserDefinedTypeInfo], + searchText: String, + database: String? = nil + ) -> [UserDefinedTypeInfo] { + let search = SidebarSearch(searchText) + let admitted = types.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } + let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) return deduplicated(matched, by: \.id) } @@ -91,21 +113,22 @@ enum DatabaseTreeFilter { routines: [RoutineInfo], triggers: [TriggerInfo], userTypes: [UserDefinedTypeInfo] = [], - searchText: String + searchText: String, + database: String? = nil ) -> DatabaseTreeObjectBuckets { var tableBuckets: [SidebarObjectKind: [TableInfo]] = [:] - for table in filteredTables(tables, searchText: searchText) { + for table in filteredTables(tables, searchText: searchText, database: database) { tableBuckets[SidebarObjectKind.resolve(tableType: table.type), default: []].append(table) } var routineBuckets: [SidebarObjectKind: [RoutineInfo]] = [:] - for routine in filteredRoutines(routines, searchText: searchText) { + for routine in filteredRoutines(routines, searchText: searchText, database: database) { routineBuckets[routine.kind.sidebarObjectKind, default: []].append(routine) } return DatabaseTreeObjectBuckets( tables: tableBuckets, routines: routineBuckets, - triggers: filteredTriggers(triggers, searchText: searchText), - userTypes: filteredUserTypes(userTypes, searchText: searchText) + triggers: filteredTriggers(triggers, searchText: searchText, database: database), + userTypes: filteredUserTypes(userTypes, searchText: searchText, database: database) ) } @@ -113,6 +136,9 @@ enum DatabaseTreeFilter { /// 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. + /// + /// `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, searchText: String, @@ -120,16 +146,19 @@ enum DatabaseTreeFilter { tables: [TableInfo], routines: [RoutineInfo], triggers: [TriggerInfo], - userTypes: [UserDefinedTypeInfo] + userTypes: [UserDefinedTypeInfo], + database: String? = nil ) -> Bool { - if matches(searchText, schema) { return true } - guard isLoaded else { return true } + 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 + searchText: searchText, + database: database ).isEmpty } @@ -141,23 +170,29 @@ enum DatabaseTreeFilter { routines: [RoutineInfo], triggers: [TriggerInfo], userTypes: [UserDefinedTypeInfo], - searchText: String + searchText: String, + database: String? = nil ) -> DatabaseTreeObjectBuckets { - objectBuckets( + let matchesSchema = SidebarSearch(searchText).matchesContainer(database: database, schema: schema) + return objectBuckets( tables: tables, routines: routines, triggers: triggers, userTypes: userTypes, - searchText: matches(searchText, schema) ? "" : searchText + searchText: matchesSchema ? "" : searchText, + database: database ) } + /// `contentMatches` answers for what is inside a schema, and for a qualified search it has to + /// apply the search's containers itself, as `schemaSearchVerdict` does. static func visibleSchemas( _ schemas: [String], systemSchemas: Set, activeSchema: String?, showsSystem: Bool, searchText: String, + database: String? = nil, contentMatches: (String) -> Bool ) -> [String] { let browsable = DatabaseTreeVisibility.visibleSchemas( @@ -166,12 +201,138 @@ enum DatabaseTreeFilter { activeSchema: activeSchema, showsSystem: showsSystem ) - let matched = searchText.isEmpty + let search = SidebarSearch(searchText) + let matched = search.isEmpty ? browsable - : browsable.filter { matches(searchText, $0) || contentMatches($0) } + : browsable.filter { search.matchesContainer(database: database, schema: $0) || contentMatches($0) } return deduplicated(matched, by: { $0 }) } + /// While searching, a container's kind section opens only when it holds a match. Opening every + /// section put a "No items" row under each empty kind of each matching container, burying the + /// match it was opened for. Outside a search the user's own choice stands. + static func objectGroupIsExpanded(searching: Bool, matchCount: Int, stored: Bool) -> Bool { + 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. + enum SchemaSearchVerdict: Equatable { + case match + case noMatch + case unknown + + var isVisible: Bool { + self != .noMatch + } + } + + /// One pass over a database's all-schema listing, recording which schemas hold a match. A tree + /// judges hundreds of schemas against the same listing, and scanning it once per schema cost a + /// comparison per table per schema on every redraw. + struct SchemaListingMatches: Equatable { + let listed: Set + let matched: Set + let unlisted: Set + + init(listing: CatalogTableListing.Result, database: String, searchText: String) { + let search = SidebarSearch(searchText) + var listed: Set = [] + var matched: Set = [] + for table in listing.tables { + guard let schema = table.schema else { continue } + listed.insert(schema) + guard !matched.contains(schema), + search.matchesObject(named: table.name, database: database, schema: schema) else { continue } + matched.insert(schema) + } + self.listed = listed + self.matched = matched + self.unlisted = listing.unlistedSchemas + } + } + + /// The schema's own loaded lists answer first. A schema the tree has not loaded is judged from + /// the database's all-schema listing, and is unknown while that listing is missing, failed, or + /// says it could not read the schema, unless a qualified search names another schema: holding + /// every schema on screen for an object literally named with a dot is not worth it. + /// + /// `listingCoversSchema` is false for a schema the listing leaves out on purpose, a system + /// schema, which is judged by its loaded lists alone, as every unloaded schema used to be. + /// `countsSchemaName` is false where a schema that matches only by its own name would be + /// noise, as in the flat list, which names a schema only for the objects found in it. + static func schemaSearchVerdict( + schema: String, + database: String, + searchText: String, + loadedContent: DatabaseTreeObjectBuckets?, + listingMatches: SchemaListingMatches?, + listingCoversSchema: Bool = true, + countsSchemaName: Bool = true + ) -> SchemaSearchVerdict { + let search = SidebarSearch(searchText) + let namesContainer = search.qualified != nil || countsSchemaName + if namesContainer, search.matchesContainer(database: database, schema: schema) { return .match } + if let loadedContent { return loadedContent.isEmpty ? .noMatch : .match } + guard listingCoversSchema else { return .noMatch } + guard let listingMatches, !listingMatches.unlisted.contains(schema) else { + return search.admits(database: database, schema: schema) ? .unknown : .noMatch + } + return listingMatches.matched.contains(schema) ? .match : .noMatch + } + + /// A schema's objects as the tree has loaded them, filtered by the search. Nil until its tables + /// are loaded, so a search can tell a schema holding no match from one nobody has listed yet. + @MainActor + static func loadedObjectBuckets( + in service: DatabaseTreeMetadataService, + connectionId: UUID, + database: String, + schema: String?, + searchText: String + ) -> DatabaseTreeObjectBuckets? { + guard case .loaded(let tables) = service.tablesLoadState( + connectionId: connectionId, database: database, schema: schema + ) else { return nil } + return objectBuckets( + tables: tables, + routines: service.routines(connectionId: connectionId, database: database, schema: schema), + triggers: service.triggers(connectionId: connectionId, database: database, schema: schema), + userTypes: service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema), + searchText: searchText, + database: database + ) + } + + /// The schemas besides the browsed one that a search found objects in, which the flat list adds + /// below its own sections. Only confirmed matches are named: a flat list that also named every + /// schema whose listing had not arrived would read as a list of results. + static func otherSchemaMatches( + database: String, + browsedSchema: String?, + searchText: String, + hiddenSchemas: Set, + allSchemaTables: MetadataLoadState, + loadedContent: (String) -> DatabaseTreeObjectBuckets? + ) -> [String] { + guard !SidebarSearch(searchText).isEmpty, let listing = allSchemaTables.value else { return [] } + let listingMatches = SchemaListingMatches(listing: listing, database: database, searchText: searchText) + return listingMatches.listed + .filter { schema in + guard schema != browsedSchema, !hiddenSchemas.contains(schema) else { return false } + return schemaSearchVerdict( + schema: schema, + database: database, + searchText: searchText, + loadedContent: loadedContent(schema), + listingMatches: listingMatches, + countsSchemaName: false + ) == .match + } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + private static func deduplicated( _ items: [Element], by key: (Element) -> Key diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift index 96f2edf371..37edf3c2d7 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -25,6 +25,12 @@ extension DatabaseTreeOutlineCoordinator { if outlineView.isItemExpanded(sectionNode) { restorePartitionExpansion(under: sectionNode) } case .redisKeysSection: setExpanded(sectionNode, searching || (viewModel?.isRedisKeysExpanded ?? true)) + case .schema: + setExpanded(sectionNode, true) + if outlineView.isItemExpanded(sectionNode) { + triggerLoad(for: sectionNode) + restoreObjectGroupExpansion(under: sectionNode) + } case .hierarchicalSchemaSection(let schema): let want = searching ? hierarchicalSchemaMatches(schema) @@ -53,7 +59,7 @@ extension DatabaseTreeOutlineCoordinator { for schemaNode in resolvedChildren(of: databaseNode) { guard case .schema(let database, let schema) = schemaNode.kind else { continue } let wantSchema = searching - ? DatabaseTreeFilter.matches(searchText, schema) || schemaContentMatchesSearch(database: database, schema: schema) + ? schemaSearchVerdict(database: database, schema: schema) == .match : windowState?.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: database, schema: schema)) ?? false setExpanded(schemaNode, wantSchema) if outlineView.isItemExpanded(schemaNode) { @@ -118,7 +124,11 @@ extension DatabaseTreeOutlineCoordinator { let searching = !searchText.isEmpty for groupNode in resolvedChildren(of: parent) { guard case .containerObjectKindSection(let group) = groupNode.kind else { continue } - let expanded = searching || (windowState?.isTreeObjectGroupExpanded(group) ?? group.kind.isExpandedByDefault) + let expanded = DatabaseTreeFilter.objectGroupIsExpanded( + searching: searching, + matchCount: searching ? matchCount(in: group) : 0, + stored: windowState?.isTreeObjectGroupExpanded(group) ?? group.kind.isExpandedByDefault + ) setExpanded(groupNode, expanded) if outlineView.isItemExpanded(groupNode) { restorePartitionExpansion(under: groupNode) } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index eca161ea7c..26faf593f1 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -156,6 +156,11 @@ extension DatabaseTreeOutlineCoordinator { nodes += visibleObjectKinds().map { node(id: DatabaseTreeNode.objectKindSectionId($0), kind: .objectKindSection($0)) } + if let database = browsingDatabase { + nodes += flatOtherSchemaMatches(database: database).map { + node(id: DatabaseTreeNode.schemaId(database: database, schema: $0), kind: .schema(database: database, schema: $0)) + } + } if sidebarState?.redisKeyTreeViewModel != nil { nodes.append(node(id: DatabaseTreeNode.redisKeysSectionId, kind: .redisKeysSection)) } @@ -279,7 +284,8 @@ extension DatabaseTreeOutlineCoordinator { 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) + userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), + database: browsingDatabase ) } @@ -330,7 +336,8 @@ extension DatabaseTreeOutlineCoordinator { routines: schemaService.routines(for: connectionId, schema: schema), triggers: schemaService.triggers(for: connectionId, schema: schema), userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), - searchText: searchText + searchText: searchText, + database: browsingDatabase ) } @@ -353,12 +360,36 @@ extension DatabaseTreeOutlineCoordinator { private func recentTableRefs() -> [DatabaseTreeTableRef] { guard let sidebarState, showRecentTables else { return [] } let database = browsingDatabase + let search = SidebarSearch(searchText) return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in - if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } + if !search.isEmpty { + guard search.matchesObject(named: entry.name, database: database, schema: entry.schema) else { + return nil + } + } return DatabaseTreeTableRef(database: database, schema: entry.schema, table: entry.tableInfo) } } + /// The flat list shows the browsed schema's objects only, so a search also names the other + /// schemas it found objects in. Each is the tree's own schema row, which expands, loads and + /// offers its menus exactly as it does in the tree. + private func flatOtherSchemaMatches(database: String) -> [String] { + guard !searchText.isEmpty, listsTablesPerSchema else { return [] } + return DatabaseTreeFilter.otherSchemaMatches( + database: database, + browsedSchema: activeSchema, + searchText: searchText, + hiddenSchemas: showSystemContainers ? [] : systemSchemas, + allSchemaTables: service.allSchemaTablesLoadState(connectionId: connectionId, database: database), + loadedContent: { self.loadedObjectBuckets(database: database, schema: $0) } + ) + } + + internal var listsTablesPerSchema: Bool { + DatabaseTreeMetadataService.listsTablesPerSchema(PluginManager.shared.databaseGroupingStrategy(for: databaseType)) + } + private func schemaNodes(database: String) -> [DatabaseTreeNode] { let parentId = DatabaseTreeNode.databaseId(database) switch service.schemaListState(connectionId: connectionId, database: database) { @@ -373,7 +404,8 @@ extension DatabaseTreeOutlineCoordinator { activeSchema: database == browsingDatabase ? activeSchema : nil, showsSystem: showSystemContainers, searchText: searchText, - contentMatches: { schemaContentMatchesSearch(database: database, schema: $0) } + database: database, + contentMatches: { schemaSearchVerdict(database: database, schema: $0).isVisible } ) if visible.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } return visible.map { @@ -409,13 +441,27 @@ extension DatabaseTreeOutlineCoordinator { routines: service.routines(connectionId: connectionId, database: database, schema: schema), triggers: service.triggers(connectionId: connectionId, database: database, schema: schema), userTypes: service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema), - searchText: searchText + searchText: searchText, + database: database ) } objectBucketsCache[key] = buckets return buckets } + internal func matchCount(in group: DatabaseTreeObjectGroup) -> Int { + objectBuckets(database: group.database, schema: group.schema).itemCounts[group.kind] ?? 0 + } + + /// Nil until the tree has loaded this schema's tables, so a search can tell a schema that holds + /// no match from one nobody has listed yet. + private func loadedObjectBuckets(database: String, schema: String?) -> DatabaseTreeObjectBuckets? { + guard case .loaded = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) else { + return nil + } + return objectBuckets(database: database, schema: schema) + } + /// A fetch the engine never runs stays idle for good, and idle is not loaded: counting it /// would hold every empty container on a spinner for a list that is never coming. So only the /// kinds this engine declares take part in deciding between "empty" and "loading". @@ -541,24 +587,57 @@ extension DatabaseTreeOutlineCoordinator { // MARK: - Search + /// A database is kept when its name answers a plain search, when a schema of it is kept, or when + /// objects it holds outside any schema match. Only schemas the tree would show are asked, so a + /// hidden system schema cannot keep a database on screen that shows nothing matching. internal func databaseMatchesSearch(_ metadata: DatabaseMetadata) -> Bool { - if DatabaseTreeFilter.matches(searchText, metadata.name) { return true } + let search = SidebarSearch(searchText) + if search.qualified == nil, DatabaseTreeFilter.matches(searchText, metadata.name) { return true } if case .loaded(let schemas) = service.schemaListState(connectionId: connectionId, database: metadata.name) { - if schemas.contains(where: { DatabaseTreeFilter.matches(searchText, $0) }) { return true } - for schema in schemas where schemaContentMatchesSearch(database: metadata.name, schema: schema) { + let browsable = DatabaseTreeVisibility.visibleSchemas( + schemas, + systemSchemas: systemSchemas, + activeSchema: metadata.name == browsingDatabase ? activeSchema : nil, + showsSystem: showSystemContainers + ) + if browsable.contains(where: { schemaSearchVerdict(database: metadata.name, schema: $0).isVisible }) { return true } } - return schemaContentMatchesSearch(database: metadata.name, schema: nil) + return databaseContentMatchesSearch(database: metadata.name) } - internal func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { - if let schema, DatabaseTreeFilter.matches(searchText, schema) { return true } - let tables = service.tables(connectionId: connectionId, database: database, schema: schema) - if tables.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } - let routines = service.routines(connectionId: connectionId, database: database, schema: schema) - if routines.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } - let types = service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema) - return types.contains { DatabaseTreeFilter.matches(searchText, $0.name) } + internal func schemaSearchVerdict(database: String, schema: String) -> DatabaseTreeFilter.SchemaSearchVerdict { + DatabaseTreeFilter.schemaSearchVerdict( + schema: schema, + database: database, + searchText: searchText, + loadedContent: loadedObjectBuckets(database: database, schema: schema), + listingMatches: listingMatches(database: database), + listingCoversSchema: listsTablesPerSchema && !systemSchemas.contains(schema) + ) + } + + /// Worked out once per database per redraw, since every schema of the database is judged + /// against the same listing. + private func listingMatches(database: String) -> DatabaseTreeFilter.SchemaListingMatches? { + let key = DatabaseTreeContainerKey(database: database, schema: nil, searchText: searchText) + if let cached = listingMatchesCache[key] { return cached } + guard let listing = service.allSchemaTablesLoadState(connectionId: connectionId, database: database).value else { + return nil + } + let matches = DatabaseTreeFilter.SchemaListingMatches(listing: listing, database: database, searchText: searchText) + listingMatchesCache[key] = matches + return matches + } + + /// The objects a database holds outside any schema, which is every object on an engine with no + /// schema level. `shop.` asks for all of them, and a schema engine has none to give. + private func databaseContentMatchesSearch(database: String) -> Bool { + let search = SidebarSearch(searchText) + if search.qualified != nil, search.nameQuery.isEmpty { + return !supportsSchemaLevel && search.admits(database: database, schema: nil) + } + return !objectBuckets(database: database, schema: nil).isEmpty } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index b52be414bd..67166daef1 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -38,6 +38,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { internal var nodeCache: [String: DatabaseTreeNode] = [:] internal var childrenCache: [String: [DatabaseTreeNode]] = [:] internal var objectBucketsCache: [DatabaseTreeContainerKey: DatabaseTreeObjectBuckets] = [:] + internal var listingMatchesCache: [DatabaseTreeContainerKey: DatabaseTreeFilter.SchemaListingMatches] = [:] /// Whether a routine row shows its signature depends on the other rows in its own section, so /// the label is decided where the section is built and looked up here when the row draws. internal var routineDisplayLabels: [String: String] = [:] @@ -293,6 +294,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { isReloading = true childrenCache.removeAll() objectBucketsCache.removeAll() + listingMatchesCache.removeAll() routineDisplayLabels.removeAll() invalidateRowConfiguration() outlineView.reloadData() diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 7205bb70c2..d55e497437 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -114,7 +114,8 @@ struct SidebarTreeView: View { 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) + userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), + database: activeDatabase ) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 523948eabd..bbcf32d05e 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -16,6 +16,7 @@ struct SidebarView: View { @State private var showsSchemaProgress = false @ObservedObject private var schemaService = SchemaService.shared + @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared @ObservedObject var sidebarState: SharedSidebarState @ObservedObject var windowState: WindowSidebarState @@ -44,7 +45,33 @@ struct SidebarView: View { private var hasAnyMatch: Bool { SidebarObjectKind.allCases.contains { kind in countFor(kind: kind) > 0 - } + } || !otherSchemaMatches.isEmpty + } + + /// The schemas the flat outline lists below its own sections. Counted here too, or a search + /// whose only matches are in another schema is shown as "No Results" and the outline that would + /// have listed them is never drawn. + private var otherSchemaMatches: [String] { + guard rootShape == .flat, !viewModel.filterQuery.isEmpty, let database = activeDatabase, + DatabaseTreeMetadataService.listsTablesPerSchema(groupingStrategy) else { return [] } + let searchText = viewModel.filterQuery + let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) + return DatabaseTreeFilter.otherSchemaMatches( + database: database, + browsedSchema: coordinator?.toolbarState.currentSchema, + searchText: searchText, + hiddenSchemas: settingsManager.general.showSystemContainers ? [] : systemSchemas, + allSchemaTables: treeMetadata.allSchemaTablesLoadState(connectionId: connectionId, database: database), + loadedContent: { schema in + DatabaseTreeFilter.loadedObjectBuckets( + in: treeMetadata, + connectionId: connectionId, + database: database, + schema: schema, + searchText: searchText + ) + } + ) } private var groupingStrategy: GroupingStrategy { diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index bf46b5c4c3..b36d3d9693 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -151,9 +151,42 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen } guard let schema else { return tablesToReturn } fetchSchemaTablesCalls.append(schema) + if let error = schemaTablesErrors[schema] { + throw error + } return schemaTablesToReturn[schema] ?? tablesToReturn } + /// Nil leaves the driver without a single call, so the host lists each schema itself. + var allSchemaTablesToReturn: [TableInfo]? + var allSchemaTablesError: Error? + var schemaTablesErrors: [String: Error] = [:] + var fetchTablesInAllSchemasCallCount = 0 + var pausesNextAllSchemaTablesFetch = false + var onAllSchemaTablesFetchPaused: (@Sendable () -> Void)? + private var allSchemaTablesGate: CheckedContinuation? + + func resumeAllSchemaTablesFetch() { + allSchemaTablesGate?.resume() + allSchemaTablesGate = nil + } + + func fetchTablesInAllSchemas() async throws -> [TableInfo]? { + fetchTablesInAllSchemasCallCount += 1 + if let allSchemaTablesError { + throw allSchemaTablesError + } + let snapshot = allSchemaTablesToReturn + if pausesNextAllSchemaTablesFetch { + pausesNextAllSchemaTablesFetch = false + await withCheckedContinuation { continuation in + allSchemaTablesGate = continuation + onAllSchemaTablesFetchPaused?() + } + } + return snapshot + } + func fetchColumns(table: String) async throws -> [ColumnInfo] { fetchColumnsCallCount += 1 fetchColumnsCalls.append(table) diff --git a/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift b/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift new file mode 100644 index 0000000000..19c1c26d52 --- /dev/null +++ b/TableProTests/Core/Concurrency/CatalogFreshnessTests.swift @@ -0,0 +1,75 @@ +// +// CatalogFreshnessTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CatalogFreshness") +struct CatalogFreshnessTests { + @Test("A key never fetched is not current") + func neverFetched() { + let freshness = CatalogFreshness() + #expect(!freshness.isCurrent("shop")) + } + + @Test("A committed fetch makes the key current") + func committedFetch() { + var freshness = CatalogFreshness() + let revision = freshness.revision(for: "shop") + let committed = freshness.commit(revision, for: "shop") + #expect(committed) + #expect(freshness.isCurrent("shop")) + } + + @Test("A change after the commit makes the key stale again") + func changeAfterCommit() { + var freshness = CatalogFreshness() + _ = freshness.commit(freshness.revision(for: "shop"), for: "shop") + freshness.markChanged("shop") + #expect(!freshness.isCurrent("shop")) + } + + @Test("A fetch that started before a change commits its rows but leaves the key stale") + func fetchOvertakenByChange() { + var freshness = CatalogFreshness() + let started = freshness.revision(for: "shop") + freshness.markChanged("shop") + let committed = freshness.commit(started, for: "shop") + #expect(committed) + #expect(!freshness.isCurrent("shop")) + } + + @Test("An older fetch finishing last cannot replace a newer one") + func olderFetchFinishingLast() { + var freshness = CatalogFreshness() + let older = freshness.revision(for: "shop") + freshness.markChanged("shop") + let newer = freshness.revision(for: "shop") + let newerCommitted = freshness.commit(newer, for: "shop") + let olderCommitted = freshness.commit(older, for: "shop") + #expect(newerCommitted) + #expect(!olderCommitted) + #expect(freshness.isCurrent("shop")) + } + + @Test("A change to one key leaves another current") + func keysAreIndependent() { + var freshness = CatalogFreshness() + _ = freshness.commit(freshness.revision(for: "shop"), for: "shop") + _ = freshness.commit(freshness.revision(for: "blog"), for: "blog") + freshness.markChanged("blog") + #expect(freshness.isCurrent("shop")) + #expect(!freshness.isCurrent("blog")) + } + + @Test("Removed keys are fetched again") + func removedKeys() { + var freshness = CatalogFreshness() + _ = freshness.commit(freshness.revision(for: "shop"), for: "shop") + freshness.removeAll { $0 == "shop" } + #expect(!freshness.isCurrent("shop")) + } +} diff --git a/TableProTests/Core/Database/CatalogTableListingTests.swift b/TableProTests/Core/Database/CatalogTableListingTests.swift new file mode 100644 index 0000000000..3b06ea1f36 --- /dev/null +++ b/TableProTests/Core/Database/CatalogTableListingTests.swift @@ -0,0 +1,176 @@ +// +// CatalogTableListingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("CatalogTableListing") +@MainActor +struct CatalogTableListingTests { + private struct ListingFailed: Error {} + + private let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + + private func table(_ name: String, _ schema: String) -> TableInfo { + TestFixtures.makeTableInfo(name: name, schema: schema) + } + + @Test("A single call answers for every schema, less the excluded ones") + func singleCall() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [table("users", "public"), table("pg_class", "pg_catalog")] + let metadata = RecordingMetadataProvider(driver: driver) + + let listing = try await CatalogTableListing.tables(in: scope, excludingSchemas: ["pg_catalog"], metadata: metadata) + + #expect(listing.tables.map(\.name) == ["users"]) + #expect(listing.unlistedSchemas.isEmpty) + #expect(driver.fetchSchemasCallCount == 0) + } + + /// A scope per schema would open a pooled connection per schema, one for every schema of a + /// database with hundreds of them. + @Test("Every per-schema read goes through the one scope it was given") + func perSchemaReadsShareOneScope() async throws { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "attendance", "sales"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")], + "sales": [table("orders", "sales")] + ] + let metadata = RecordingMetadataProvider(driver: driver) + + let listing = try await CatalogTableListing.tables(in: scope, excludingSchemas: ["sales"], metadata: metadata) + + #expect(listing.tables.map(\.name) == ["users", "timesheet"]) + #expect(driver.fetchSchemaTablesCalls == ["public", "attendance"]) + #expect(Set(metadata.requestedScopes) == [scope]) + #expect(metadata.requestedWorkloads.allSatisfy { $0 == .bulk }) + } + + @Test("A schema whose read fails is named, not read as empty") + func failedSchemaIsNamed() async throws { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesToReturn = ["public": [table("users", "public")]] + driver.schemaTablesErrors = ["attendance": ListingFailed()] + + let listing = try await CatalogTableListing.tables( + in: scope, excludingSchemas: [], metadata: RecordingMetadataProvider(driver: driver) + ) + + #expect(listing.tables.map(\.name) == ["users"]) + #expect(listing.unlistedSchemas == ["attendance"]) + } + + @Test("A second read of unlisted schemas folds in what it listed and keeps what it could not") + func mergingARetry() { + let first = CatalogTableListing.Result( + tables: [table("users", "public")], + unlistedSchemas: ["attendance", "payroll"] + ) + let retry = CatalogTableListing.Result( + tables: [table("timesheet", "attendance")], + unlistedSchemas: ["payroll"] + ) + + let merged = first.merging(retry, retried: ["attendance", "payroll"]) + + #expect(merged.tables.map(\.name) == ["users", "timesheet"]) + #expect(merged.unlistedSchemas == ["payroll"]) + } + + @Test("A retry that still cannot read a schema keeps what was known of it") + func mergingARetryThatFailsAgain() { + let first = CatalogTableListing.Result( + tables: [table("users", "public"), table("payslips", "payroll")], + unlistedSchemas: ["payroll"] + ) + let retry = CatalogTableListing.Result(tables: [], unlistedSchemas: ["payroll"]) + + let merged = first.merging(retry, retried: ["payroll"]) + + #expect(merged.tables.map(\.name) == ["users", "payslips"]) + #expect(merged.unlistedSchemas == ["payroll"]) + } + + @Test("A refresh that cannot read a schema carries that schema's earlier rows over") + func keepingRowsFromAnEarlierListing() { + let earlier = CatalogTableListing.Result( + tables: [table("users", "public"), table("timesheet", "attendance")], + unlistedSchemas: [] + ) + let refresh = CatalogTableListing.Result(tables: [table("orders", "public")], unlistedSchemas: ["attendance"]) + + let kept = refresh.keepingRows(from: earlier) + + #expect(kept.tables.map(\.name) == ["orders", "timesheet"]) + #expect(kept.unlistedSchemas == ["attendance"]) + } + + @Test("A lost connection fails the whole listing rather than reading as empty") + func lostConnectionFailsTheListing() async { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesErrors = ["attendance": DatabaseError.notConnected] + + await #expect(throws: DatabaseError.self) { + try await CatalogTableListing.tables( + in: scope, excludingSchemas: [], metadata: RecordingMetadataProvider(driver: driver) + ) + } + } + + @Test("Every schema failing fails the whole listing") + func everySchemaFailingFailsTheListing() async { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesErrors = ["public": ListingFailed(), "attendance": ListingFailed()] + + await #expect(throws: ListingFailed.self) { + try await CatalogTableListing.tables( + in: scope, excludingSchemas: [], metadata: RecordingMetadataProvider(driver: driver) + ) + } + } + + @Test("A failed schema list fails the whole listing") + func failedSchemaListThrows() async { + let driver = MockDatabaseDriver() + driver.fetchSchemasError = ListingFailed() + + await #expect(throws: ListingFailed.self) { + try await CatalogTableListing.tables( + in: scope, excludingSchemas: [], metadata: RecordingMetadataProvider(driver: driver) + ) + } + } +} + +@MainActor +private final class RecordingMetadataProvider: ScopedMetadataProviding { + private let driver: MockDatabaseDriver + private(set) var requestedScopes: [DatabaseScope] = [] + private(set) var requestedWorkloads: [MetadataConnectionPool.Workload] = [] + + init(driver: MockDatabaseDriver) { + self.driver = driver + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + requestedScopes.append(scope) + requestedWorkloads.append(workload) + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } +} diff --git a/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift b/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift new file mode 100644 index 0000000000..8648d29b95 --- /dev/null +++ b/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift @@ -0,0 +1,82 @@ +// +// AllSchemaTablesDemandTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("AllSchemaTablesDemand") +@MainActor +struct AllSchemaTablesDemandTests { + private let service = DatabaseTreeMetadataService.shared + + private func connectedListing() async -> DatabaseConnection { + let connection = TestFixtures.makeConnection(type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.allSchemaTablesToReturn = [TestFixtures.makeTableInfo(name: "users", schema: "public")] + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + await service.loadAllSchemaTables(connectionId: connection.id, database: connection.database) + return connection + } + + private func disconnect(_ connection: DatabaseConnection) async { + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + @Test("A listing nobody holds is asked for") + func idleListingIsRequested() { + let demand = AllSchemaTablesDemand() + #expect(demand.needsRequest(connectionId: UUID(), database: "shop", service: service)) + } + + @Test("A listing is not asked for twice at one revision, and is asked again once it moves") + func oncePerRevision() async { + let connection = await connectedListing() + var demand = AllSchemaTablesDemand() + demand.noteRequested(connectionId: connection.id, database: connection.database, service: service) + #expect(!demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) + + service.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: connection.id, database: connection.database) + ]) + #expect(demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) + await disconnect(connection) + } + + /// A reconnect moves the revision while the session is still connecting. Counting that revision + /// as asked for, when the load could not start, left the search stale once the session was back. + @Test("A request while not connected is not counted, so the next one after connecting goes out") + func notConnectedIsNotCounted() async { + let connection = await connectedListing() + var demand = AllSchemaTablesDemand() + demand.noteRequested(connectionId: connection.id, database: connection.database, service: service) + service.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: connection.id, database: connection.database) + ]) + + demand.requestIfNeeded( + connectionId: connection.id, database: connection.database, isConnected: false, service: service + ) + + #expect(demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) + await disconnect(connection) + } + + @Test("Starting a new search asks again whatever was asked before") + func resetForgetsEarlierRequests() async { + let connection = await connectedListing() + var demand = AllSchemaTablesDemand() + demand.noteRequested(connectionId: connection.id, database: connection.database, service: service) + + demand.reset() + + #expect(demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) + await disconnect(connection) + } +} diff --git a/TableProTests/Core/Services/Query/DatabaseTreeAllSchemaTablesTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeAllSchemaTablesTests.swift new file mode 100644 index 0000000000..850b309a2e --- /dev/null +++ b/TableProTests/Core/Services/Query/DatabaseTreeAllSchemaTablesTests.swift @@ -0,0 +1,242 @@ +// +// DatabaseTreeAllSchemaTablesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Uses PGlite because it cannot open a pooled connection, so every read stays on the injected +/// session driver, and because it groups by schema with `pg_catalog` as a system schema. +@Suite("DatabaseTreeMetadataService all-schema tables") +@MainActor +struct DatabaseTreeAllSchemaTablesTests { + private struct ListingFailed: Error {} + + private let service = DatabaseTreeMetadataService.shared + + private func connect(_ configure: (MockDatabaseDriver) -> Void) -> (DatabaseConnection, MockDatabaseDriver) { + let connection = TestFixtures.makeConnection(type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + configure(driver) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + return (connection, driver) + } + + private func disconnect(_ connection: DatabaseConnection) async { + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + private func listing(_ connection: DatabaseConnection) -> CatalogTableListing.Result? { + service.allSchemaTablesLoadState(connectionId: connection.id, database: connection.database).value + } + + private func load(_ connection: DatabaseConnection) async { + await service.loadAllSchemaTables(connectionId: connection.id, database: connection.database) + } + + private func table(_ name: String, _ schema: String) -> TableInfo { + TestFixtures.makeTableInfo(name: name, schema: schema) + } + + @Test("An engine with a single call is asked once, and system schemas stay out") + func singleCall() async { + let (connection, driver) = connect { driver in + driver.allSchemaTablesToReturn = [ + table("users", "public"), table("timesheet", "attendance"), table("pg_class", "pg_catalog") + ] + } + await load(connection) + + #expect(listing(connection)?.tables.map(\.name) == ["users", "timesheet"]) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + #expect(driver.fetchSchemasCallCount == 0) + await disconnect(connection) + } + + @Test("Any other engine is asked schema by schema, skipping system schemas") + func perSchemaFallback() async { + let (connection, driver) = connect { driver in + driver.schemasToReturn = ["public", "attendance", "pg_catalog"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")], + "pg_catalog": [table("pg_class", "pg_catalog")] + ] + } + await load(connection) + + #expect(listing(connection)?.tables.map(\.name) == ["users", "timesheet"]) + #expect(driver.fetchSchemaTablesCalls == ["public", "attendance"]) + await disconnect(connection) + } + + @Test("A schema that cannot be listed is named, and the rest are still listed") + func unlistedSchema() async { + let (connection, _) = connect { driver in + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesToReturn = ["public": [table("users", "public")]] + driver.schemaTablesErrors = ["attendance": ListingFailed()] + } + await load(connection) + + #expect(listing(connection)?.tables.map(\.name) == ["users"]) + #expect(listing(connection)?.unlistedSchemas == ["attendance"]) + await disconnect(connection) + } + + @Test("A schema that could not be listed is asked for again on the next read, alone") + func unlistedSchemaIsRetriedAlone() async { + let (connection, driver) = connect { driver in + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")] + ] + driver.schemaTablesErrors = ["attendance": ListingFailed()] + } + await load(connection) + #expect(listing(connection)?.unlistedSchemas == ["attendance"]) + + driver.schemaTablesErrors = [:] + await load(connection) + + #expect(driver.fetchSchemaTablesCalls == ["public", "attendance", "attendance"]) + #expect(driver.fetchSchemasCallCount == 1) + #expect(listing(connection)?.tables.map(\.name) == ["users", "timesheet"]) + #expect(listing(connection)?.unlistedSchemas.isEmpty == true) + await disconnect(connection) + } + + @Test("A refresh that cannot read a schema keeps the rows it had for it") + func refreshKeepsRowsOfAnUnreadableSchema() async { + let (connection, driver) = connect { driver in + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")] + ] + } + await load(connection) + await service.refreshCatalog(for: CatalogChange(connectionId: connection.id, kinds: .tables)) + + driver.schemaTablesToReturn["public"] = [table("users", "public"), table("orders", "public")] + driver.schemaTablesErrors = ["attendance": ListingFailed()] + await load(connection) + + #expect(listing(connection)?.tables.map(\.name) == ["users", "orders", "timesheet"]) + #expect(listing(connection)?.unlistedSchemas == ["attendance"]) + await disconnect(connection) + } + + @Test("A second read is served without fetching again") + func secondReadIsCached() async { + let (connection, driver) = connect { $0.allSchemaTablesToReturn = [table("users", "public")] } + await load(connection) + await load(connection) + + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + await disconnect(connection) + } + + @Test("A catalog change marks the listing stale without fetching, and the next read refetches") + func catalogChangeMarksStale() async { + let (connection, driver) = connect { $0.allSchemaTablesToReturn = [table("users", "public")] } + await load(connection) + + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + await service.refreshCatalog(for: CatalogChange(connectionId: connection.id, kinds: .tables)) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + #expect(listing(connection)?.tables.map(\.name) == ["users"]) + + await load(connection) + #expect(driver.fetchTablesInAllSchemasCallCount == 2) + #expect(listing(connection)?.tables.map(\.name) == ["users", "timesheet"]) + await disconnect(connection) + } + + @Test("A change that cannot touch the tables leaves the listing current") + func unrelatedChangeKeepsListing() async { + let (connection, driver) = connect { $0.allSchemaTablesToReturn = [table("users", "public")] } + await load(connection) + + await service.refreshCatalog(for: CatalogChange(connectionId: connection.id, kinds: .routines)) + await service.refreshCatalog( + for: CatalogChange(connectionId: connection.id, database: "elsewhere", kinds: .tables) + ) + await load(connection) + + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + await disconnect(connection) + } + + @Test("A failed refresh keeps the rows it had and is retried on the next read") + func failedRefreshKeepsRows() async { + let (connection, driver) = connect { $0.allSchemaTablesToReturn = [table("users", "public")] } + await load(connection) + await service.refreshCatalog(for: CatalogChange(connectionId: connection.id, kinds: .tables)) + + driver.allSchemaTablesError = ListingFailed() + await load(connection) + #expect(listing(connection)?.tables.map(\.name) == ["users"]) + + driver.allSchemaTablesError = nil + driver.allSchemaTablesToReturn = [table("orders", "public")] + await load(connection) + #expect(listing(connection)?.tables.map(\.name) == ["orders"]) + #expect(driver.fetchTablesInAllSchemasCallCount == 3) + await disconnect(connection) + } + + @Test("A first read that fails reports the failure") + func firstReadFails() async { + let (connection, _) = connect { $0.allSchemaTablesError = ListingFailed() } + await load(connection) + + guard case .failed = service.allSchemaTablesLoadState( + connectionId: connection.id, database: connection.database + ) else { + Issue.record("expected a failed state") + await disconnect(connection) + return + } + await disconnect(connection) + } + + @Test("A fetch that started before a catalog change delivers its rows but leaves the listing stale") + func fetchOvertakenByChange() async { + let (connection, driver) = connect { driver in + driver.allSchemaTablesToReturn = [table("users", "public")] + driver.pausesNextAllSchemaTablesFetch = true + } + var first: Task? + await withCheckedContinuation { (paused: CheckedContinuation) in + driver.onAllSchemaTablesFetchPaused = { paused.resume() } + first = Task { await load(connection) } + } + await service.refreshCatalog(for: CatalogChange(connectionId: connection.id, kinds: .tables)) + driver.resumeAllSchemaTablesFetch() + await first?.value + #expect(listing(connection)?.tables.map(\.name) == ["users"]) + + driver.allSchemaTablesToReturn = [table("users", "public"), table("orders", "public")] + await load(connection) + #expect(driver.fetchTablesInAllSchemasCallCount == 2) + #expect(listing(connection)?.tables.map(\.name) == ["users", "orders"]) + await disconnect(connection) + } + + @Test("A disconnect drops the listing") + func disconnectDropsListing() async { + let (connection, _) = connect { $0.allSchemaTablesToReturn = [table("users", "public")] } + await load(connection) + await disconnect(connection) + + #expect(listing(connection) == nil) + } +} diff --git a/TableProTests/Core/Services/Query/DatabaseTreeCatalogRefreshPlanTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeCatalogRefreshPlanTests.swift index 8fd03444bb..a7ed3c8cea 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeCatalogRefreshPlanTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeCatalogRefreshPlanTests.swift @@ -160,4 +160,44 @@ struct DatabaseTreeCatalogRefreshPlanTests { #expect(result.schemaLists == [shop]) } + + private func allSchemaPlan(_ change: CatalogChange, keys: [DatabaseKey]) -> CatalogTreeRefreshPlan { + DatabaseTreeMetadataService.catalogRefreshPlan( + for: change, + hasDatabaseList: false, + schemaListKeys: [DatabaseKey](), + tableKeys: [ObjectsKey](), + routineKeys: [ObjectsKey](), + triggerKeys: [ObjectsKey](), + typeKeys: [ObjectsKey](), + allSchemaTableKeys: keys + ) + } + + @Test("a table or schema change reaches the all-schema listing of its database only") + func allSchemaListingFollowsItsDatabase() { + let shop = DatabaseKey(connectionId: connectionId, database: "shop") + let warehouse = DatabaseKey(connectionId: connectionId, database: "warehouse") + let other = DatabaseKey(connectionId: UUID(), database: "shop") + + let tables = allSchemaPlan( + CatalogChange(connectionId: connectionId, database: "shop", kinds: .tables), + keys: [shop, warehouse, other] + ) + let schemas = allSchemaPlan(CatalogChange(connectionId: connectionId, kinds: .schemas), keys: [shop, warehouse]) + + #expect(tables.allSchemaTables == [shop]) + #expect(schemas.allSchemaTables == [shop, warehouse]) + #expect(!tables.isEmpty) + } + + @Test("a change that cannot touch a table leaves the all-schema listing alone") + func routineChangeLeavesAllSchemaListing() { + let shop = DatabaseKey(connectionId: connectionId, database: "shop") + + let result = allSchemaPlan(CatalogChange(connectionId: connectionId, kinds: .routines), keys: [shop]) + + #expect(result.allSchemaTables.isEmpty) + #expect(result.isEmpty) + } } diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift new file mode 100644 index 0000000000..abdc219826 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift @@ -0,0 +1,64 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLSchemaQueries.fetchTables across every schema") +struct PostgreSQLFetchTablesAllSchemasTests { + private static let attempts = PostgreSQLTableListingLadder.degradableAttempts + + [PostgreSQLTableListingLadder.leastCapableAttempt] + + private func query( + _ listing: PostgreSQLTableListingScope, + _ attempt: PostgreSQLTableListingAttempt = PostgreSQLTableListingLadder.degradableAttempts[0] + ) -> String { + PostgreSQLSchemaQueries.fetchTables( + in: listing, + includeMaterializedViews: attempt.includeOptionalCatalogs, + includeForeignTables: attempt.includeOptionalCatalogs, + includeComments: attempt.includeComments, + includePartitionAwareness: attempt.includePartitionAwareness + ) + } + + private func occurrences(of needle: String, in text: String) -> Int { + text.components(separatedBy: needle).count - 1 + } + + /// Every union arm filters by the schema list itself, so a table is listed exactly when its + /// schema is one `fetchSchemas()` returns, on every rung of the ladder. + @Test("Every arm is filtered by the schema list query, on every rung") + func everyArmUsesTheSchemaList() { + for attempt in Self.attempts { + let sql = query(.allSchemas, attempt) + let arms = occurrences(of: "UNION ALL", in: sql) + 1 + #expect(occurrences(of: PostgreSQLSchemaQueries.listSchemas, in: sql) == arms, "\(attempt.label)") + #expect(occurrences(of: "AS schema_name", in: sql) == arms, "\(attempt.label)") + #expect(sql.hasSuffix("ORDER BY schema_name, table_name"), "\(attempt.label)") + } + } + + @Test("The one-schema listing is the listing it always was") + func oneSchemaListingUnchanged() { + for attempt in Self.attempts { + let sql = query(.schema("public"), attempt) + #expect(!sql.contains("schema_name"), "\(attempt.label)") + #expect(!sql.contains(PostgreSQLSchemaQueries.listSchemas), "\(attempt.label)") + #expect(sql.hasSuffix("ORDER BY table_name"), "\(attempt.label)") + #expect(sql.contains("t.table_schema = 'public'"), "\(attempt.label)") + } + } + + @Test("The all-schema listing keeps the partition exclusion and the comment column") + func allSchemaListingKeepsItsColumns() { + let sql = query(.allSchemas) + #expect(sql.contains("pg_catalog.pg_inherits")) + #expect(sql.contains("obj_description(pc.oid, 'pg_class')")) + #expect(sql.contains("pg_matviews")) + #expect(sql.contains("pg_foreign_table")) + } + + @Test("A schema name is quoted as a literal in the one-schema listing") + func schemaLiteralIsQuoted() { + #expect(query(.schema("it's")).contains("t.table_schema = 'it''s'")) + } +} diff --git a/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift b/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift index 5e5e70249d..367c2ded68 100644 --- a/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift +++ b/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift @@ -220,46 +220,4 @@ struct QuickSwitcherCatalogStoreTests { store.removeConnection(dropped) #expect(store.catalog(for: kept, version: key)?.map(\.name) == ["users"]) } - - // MARK: - Open state applied on read - - @Test("Open state is applied to a cached catalog on the way out") - func openStateAppliedOnRead() { - let openTables: Set = [ - QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "public") - ] - let applied = QuickSwitcherViewModel.applyingOpenState( - to: [table("users"), table("orders")], - openTables: openTables, - browsing: "public" - ) - #expect(applied.first { $0.name == "users" }?.isOpenInTab == true) - #expect(applied.first { $0.name == "orders" }?.isOpenInTab == false) - } - - /// The same defect #2191 fixed, reached through the cache instead of a fresh load. - @Test("A table of the same name in another schema is not badged from the cache") - func openStateRespectsSchemaOnRead() { - let openTables: Set = [ - QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "analytics") - ] - let applied = QuickSwitcherViewModel.applyingOpenState( - to: [table("users", schema: "analytics")], - openTables: openTables, - browsing: "analytics" - ) - #expect(applied.first?.isOpenInTab == false) - } - - /// The function is unconditionally authoritative rather than skipping an empty set, so a badge - /// can never survive from whatever it was handed. - @Test("No open tabs clears the badge on every row") - func noOpenTabsClearsBadge() { - let applied = QuickSwitcherViewModel.applyingOpenState( - to: [table("users", isOpen: true)], - openTables: [], - browsing: "public" - ) - #expect(applied.first?.isOpenInTab == false) - } } diff --git a/TableProTests/Utilities/QualifiedSearchQueryTests.swift b/TableProTests/Utilities/QualifiedSearchQueryTests.swift new file mode 100644 index 0000000000..225fb86218 --- /dev/null +++ b/TableProTests/Utilities/QualifiedSearchQueryTests.swift @@ -0,0 +1,110 @@ +// +// QualifiedSearchQueryTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("QualifiedSearchQuery") +struct QualifiedSearchQueryTests { + @Test("A schema and a name") + func schemaAndName() throws { + let query = try #require(QualifiedSearchQuery("attendance.timesheet")) + #expect(query.containers == ["attendance"]) + #expect(query.name == "timesheet") + } + + @Test("A trailing dot asks for everything in the container") + func trailingDot() throws { + let query = try #require(QualifiedSearchQuery("attendance.")) + #expect(query.containers == ["attendance"]) + #expect(query.name.isEmpty) + } + + @Test("A database, a schema and a name") + func threeParts() throws { + let query = try #require(QualifiedSearchQuery("shop.attendance.timesheet")) + #expect(query.containers == ["shop", "attendance"]) + #expect(query.name == "timesheet") + } + + @Test("Spaces around a dot are ignored") + func spacesAroundDot() throws { + let query = try #require(QualifiedSearchQuery("attendance . timesheet")) + #expect(query.containers == ["attendance"]) + #expect(query.name == "timesheet") + } + + @Test("Text with no dot is not qualified") + func plainText() { + #expect(QualifiedSearchQuery("timesheet") == nil) + #expect(QualifiedSearchQuery("") == nil) + } + + @Test("An empty container keeps the text an ordinary search") + func emptyContainer() { + #expect(QualifiedSearchQuery(".orders") == nil) + #expect(QualifiedSearchQuery("a..b") == nil) + } + + @Test("A dot inside quotes belongs to the name", arguments: [ + "attendance.\"time.sheet\"", + "attendance.[time.sheet]", + "attendance.`time.sheet`" + ]) + func quotedDot(_ text: String) throws { + let query = try #require(QualifiedSearchQuery(text)) + #expect(query.containers == ["attendance"]) + #expect(query.name == "time.sheet") + } + + @Test("A quoted schema keeps its case and its dot") + func quotedSchema() throws { + let query = try #require(QualifiedSearchQuery("\"My.Schema\".\"TimeSheet\"")) + #expect(query.containers == ["My.Schema"]) + #expect(query.name == "TimeSheet") + } + + @Test("A doubled closing quote is a literal quote") + func doubledQuote() throws { + let query = try #require(QualifiedSearchQuery("\"a\"\"b\".c")) + #expect(query.containers == ["a\"b"]) + #expect(query.name == "c") + } + + @Test("An unterminated quote runs to the end of the text") + func unterminatedQuote() throws { + let query = try #require(QualifiedSearchQuery("attendance.\"time.sh")) + #expect(query.containers == ["attendance"]) + #expect(query.name == "time.sh") + } + + @Test("Containers line up with a location from the right") + func containerPairs() throws { + let query = try #require(QualifiedSearchQuery("attendance.timesheet")) + let pairs = try #require(query.containerPairs(with: ["shop", "attendance"])) + #expect(pairs.map { $0.query } == ["attendance"]) + #expect(pairs.map { $0.candidate } == ["attendance"]) + } + + @Test("A query naming more containers than the location has cannot match") + func tooManyContainers() throws { + let query = try #require(QualifiedSearchQuery("shop.attendance.timesheet")) + #expect(query.containerPairs(with: ["attendance"]) == nil) + } + + @Test("A location keeps a schema named like its database as a level of its own") + func locationKeepsBothLevels() throws { + #expect(QualifiedSearchQuery.location(database: "shop", schema: "shop") == ["shop", "shop"]) + #expect(QualifiedSearchQuery.location(database: "shop", schema: "public") == ["shop", "public"]) + #expect(QualifiedSearchQuery.location(database: nil, schema: "public") == ["public"]) + #expect(QualifiedSearchQuery.location(database: "", schema: nil).isEmpty) + + let full = try #require(QualifiedSearchQuery("shop.shop.orders")) + let short = try #require(QualifiedSearchQuery("shop.orders")) + #expect(full.containerPairs(with: ["shop", "shop"]) != nil) + #expect(short.containerPairs(with: ["shop", "shop"]) != nil) + } +} diff --git a/TableProTests/Utilities/SidebarSearchTests.swift b/TableProTests/Utilities/SidebarSearchTests.swift new file mode 100644 index 0000000000..fd10d5b9b3 --- /dev/null +++ b/TableProTests/Utilities/SidebarSearchTests.swift @@ -0,0 +1,84 @@ +// +// SidebarSearchTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SidebarSearch") +struct SidebarSearchTests { + @Test("A plain search admits every container and matches names") + func plainSearch() { + let search = SidebarSearch("sheet") + #expect(search.qualified == nil) + #expect(search.admits(database: "shop", schema: "attendance")) + #expect(search.matchesName("timesheet")) + #expect(!search.matchesName("orders")) + } + + @Test("A plain search matches a container by its own name") + func plainContainerName() { + let search = SidebarSearch("attend") + #expect(search.matchesContainer(database: "shop", schema: "attendance")) + #expect(!search.matchesContainer(database: "shop", schema: "public")) + } + + @Test("A qualified search admits only the container it names") + func qualifiedAdmits() { + let search = SidebarSearch("attendance.time") + #expect(search.admits(database: "shop", schema: "attendance")) + #expect(!search.admits(database: "shop", schema: "public")) + #expect(search.nameQuery == "time") + #expect(search.matchesName("timesheet")) + } + + @Test("A qualified search matches its container by substring, like the plain filter") + func qualifiedSubstring() { + #expect(SidebarSearch("tend.sheet").admits(database: nil, schema: "attendance")) + #expect(!SidebarSearch("atd.sheet").admits(database: nil, schema: "attendance")) + } + + @Test("A trailing dot matches the container and every name in it") + func trailingDot() { + let search = SidebarSearch("attendance.") + #expect(search.matchesContainer(database: "shop", schema: "attendance")) + #expect(search.matchesName("anything")) + } + + @Test("A qualified search with a name does not match the container by itself") + func namedQueryDoesNotMatchContainer() { + #expect(!SidebarSearch("attendance.time").matchesContainer(database: "shop", schema: "attendance")) + } + + @Test("A database, a schema and a name need both containers to match") + func threeParts() { + let search = SidebarSearch("shop.attendance.time") + #expect(search.admits(database: "shop", schema: "attendance")) + #expect(!search.admits(database: "other", schema: "attendance")) + #expect(!search.admits(database: nil, schema: "attendance")) + } + + @Test("A name that holds a dot matches its own text anywhere") + func literalDottedName() { + let search = SidebarSearch("audit.events") + #expect(search.matchesObject(named: "audit.events", database: "shop", schema: "public")) + #expect(search.matchesObject(named: "events", database: "shop", schema: "audit")) + #expect(!search.matchesObject(named: "events", database: "shop", schema: "public")) + } + + @Test("A plain search matches objects by name alone") + func plainObjectMatch() { + let search = SidebarSearch("sheet") + #expect(search.matchesObject(named: "timesheet", database: "shop", schema: "attendance")) + #expect(!search.matchesObject(named: "orders", database: "shop", schema: "attendance")) + } + + @Test("On an engine with no schemas the container is the database") + func schemaLessEngine() { + let search = SidebarSearch("shop.orders") + #expect(search.admits(database: "shop", schema: nil)) + #expect(!search.admits(database: "blog", schema: nil)) + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift new file mode 100644 index 0000000000..606aea9cb3 --- /dev/null +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -0,0 +1,317 @@ +// +// QuickSwitcherCrossSchemaTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Quick switcher across schemas") +@MainActor +struct QuickSwitcherCrossSchemaTests { + private func table(_ name: String, _ schema: String?, type: TableInfo.TableType = .table) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: schema) + } + + private func items( + _ tables: [TableInfo], + browsing browseSchema: String? = "public", + openTables: Set = [] + ) -> [QuickSwitcherItem] { + QuickSwitcherViewModel.makeTableItems( + tables, database: "shop", browseSchema: browseSchema, openTables: openTables + ) + } + + private func search(_ query: String, in catalog: [QuickSwitcherItem]) async -> [QuickSwitcherItem] { + guard let defaults = UserDefaults(suiteName: "QuickSwitcherCrossSchemaTests.\(UUID().uuidString)") else { + return [] + } + let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: defaults) + viewModel.allItems = catalog + viewModel.searchText = query + await viewModel.flushPendingFilter() + return viewModel.flatItems + } + + // MARK: - Rows + + @Test("A table outside the browsed schema names its schema, and one inside does not") + func rowsNameOtherSchemas() { + let rows = items([table("users", "public"), table("timesheet", "attendance")]) + + #expect(rows.map(\.subtitle) == ["", "attendance"]) + #expect(rows.map(\.isOutsideBrowsedSchema) == [false, true]) + } + + @Test("A view in another schema names both its schema and its kind") + func viewNamesSchemaAndKind() { + let rows = items([table("active", "attendance", type: .view), table("summary", "public", type: .materializedView)]) + + #expect(rows.map(\.subtitle) == ["attendance · View", "Materialized View"]) + } + + @Test("A table on an engine without schemas names nothing") + func schemaLessEngine() { + let rows = items([table("orders", nil)], browsing: nil) + + #expect(rows.first?.subtitle == "") + #expect(rows.first?.isOutsideBrowsedSchema == false) + } + + @Test("A row keeps its table type for the tab it opens") + func rowsKeepTableType() { + let rows = items([table("order_seq", "public", type: .sequence), table("summary", "public", type: .materializedView)]) + + #expect(rows.map(\.tableType) == [.sequence, .materializedView]) + } + + @Test("A row knows where it lives for a qualified query") + func rowsCarryLocation() { + #expect(items([table("timesheet", "attendance")]).first?.searchLocation == ["shop", "attendance"]) + } + + // MARK: - Open state + + @Test("An open table is badged, one without a tab is not") + func openState() { + let open: Set = [QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "public")] + let rows = items([table("users", "public"), table("orders", "public")], openTables: open) + + #expect(rows.first { $0.name == "users" }?.isOpenInTab == true) + #expect(rows.first { $0.name == "orders" }?.isOpenInTab == false) + } + + /// The defect #2191 fixed: a tab on `public.users` badged `analytics.users` as open. + @Test("A table of the same name in another schema is not badged") + func openStateRespectsSchema() { + let open: Set = [QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "analytics")] + let rows = items([table("users", "analytics")], browsing: "analytics", openTables: open) + + #expect(rows.first?.isOpenInTab == false) + } + + // MARK: - Merging + + @Test("The schema service answers for its own schemas and the listing fills in the rest") + func mergedTables() { + let local = [table("users", "public")] + 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 + ) + + #expect(merged.map(\.name) == ["users", "timesheet"]) + } + + /// The last table of `public` was dropped: the schema service reloaded `public` as empty, and + /// the all-schema listing still holds the table until it is next asked. + @Test("A schema the schema service found empty keeps the listing's stale rows out") + func emptyCoveredSchemaKeepsStaleRowsOut() { + let allSchemas = [table("users", "public"), table("timesheet", "attendance")] + + let merged = QuickSwitcherViewModel.mergedTables( + local: [], loadedFrom: "shop", coveredSchemas: ["public"], listing: allSchemas, browsing: "shop", + grouping: .bySchema + ) + + #expect(merged.map(\.name) == ["timesheet"]) + } + + @Test("A table listed twice is merged once") + func mergedTablesDeduplicate() { + let allSchemas = [table("timesheet", "attendance"), table("timesheet", "attendance")] + + let merged = QuickSwitcherViewModel.mergedTables( + local: [], loadedFrom: "shop", coveredSchemas: [], listing: allSchemas, browsing: "shop", + grouping: .bySchema + ) + + #expect(merged.count == 1) + } + + /// While a database switch settles, the schema service still holds the old database's tables, + /// and each would open against the new database. + @Test("Tables loaded from the database the connection left are not offered") + func tablesFromThePreviousDatabaseAreWithheld() { + let merged = QuickSwitcherViewModel.mergedTables( + local: [table("invoices", "public")], + loadedFrom: "billing", + coveredSchemas: ["public"], + listing: [table("orders", "public"), table("timesheet", "attendance")], + browsing: "shop", + grouping: .bySchema + ) + + #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() { + let merged = QuickSwitcherViewModel.mergedTables( + local: [table("OLD_ORDERS", "PUBLIC")], + loadedFrom: "SALES", + coveredSchemas: ["PUBLIC"], + listing: [table("ORDERS", "PUBLIC")], + browsing: "SALES", + grouping: .hierarchicalSchema + ) + + #expect(merged.map(\.name) == ["ORDERS"]) + } + + @Test("On a hierarchical engine the schema service stands in until the listing arrives") + func hierarchicalBeforeListing() { + let merged = QuickSwitcherViewModel.mergedTables( + local: [table("ORDERS", "PUBLIC")], + loadedFrom: "SALES", + coveredSchemas: ["PUBLIC"], + listing: nil, + browsing: "SALES", + grouping: .hierarchicalSchema + ) + + #expect(merged.map(\.name) == ["ORDERS"]) + } + + // MARK: - Qualified queries + + @Test("A schema-qualified query finds the table in that schema only") + func qualifiedQuery() async { + let catalog = items([table("timesheet", "public"), table("timesheet", "attendance")]) + + let results = await search("attendance.timesheet", in: catalog) + + #expect(results.map(\.schemaName) == ["attendance"]) + #expect(results.first?.matchedIndices.isEmpty == false) + } + + @Test("A trailing dot lists everything in the schema") + func trailingDot() async { + let catalog = items([table("timesheet", "attendance"), table("shift", "attendance"), table("users", "public")]) + + let results = await search("attendance.", in: catalog) + + #expect(Set(results.map(\.name)) == ["timesheet", "shift"]) + } + + /// A shorter name is a tighter match only when there is a name to match. `attendance.` names + /// none, so the schema's tables come back the way a list does. + @Test("A trailing dot lists the schema alphabetically") + func trailingDotIsAlphabetical() async { + let catalog = items([ + table("timesheet", "attendance"), table("leave_requests", "attendance"), + table("shifts", "attendance"), table("holidays", "attendance") + ]) + + let results = await search("attendance.", in: catalog) + + #expect(results.map(\.name) == ["holidays", "leave_requests", "shifts", "timesheet"]) + } + + @Test("A named table still ranks its shorter matches first") + func namedQueryKeepsShorterFirst() async { + let catalog = items([table("timesheet_archive", "attendance"), table("timesheet", "attendance")]) + + let results = await search("attendance.timesheet", in: catalog) + + #expect(results.map(\.name) == ["timesheet", "timesheet_archive"]) + } + + @Test("Each part of a qualified query matches fuzzily") + func fuzzyParts() async { + let catalog = items([table("timesheet", "attendance"), table("orders", "sales")]) + + #expect(await search("att.time", in: catalog).map(\.name) == ["timesheet"]) + } + + @Test("A database, schema and name reach the database the connection is browsing") + func threePartQuery() async { + let catalog = items([table("timesheet", "attendance")]) + + #expect(await search("shop.attendance.timesheet", in: catalog).count == 1) + #expect(await search("blog.attendance.timesheet", in: catalog).isEmpty) + } + + @Test("A quoted schema with a dot in it is one schema") + func quotedSchema() async { + let catalog = items([table("orders", "my.schema"), table("orders", "my")]) + + let results = await search("\"my.schema\".orders", in: catalog) + + #expect(results.map(\.schemaName) == ["my.schema"]) + } + + @Test("A table whose own name holds a dot is still found by that name") + func dottedTableName() async { + let catalog = items([table("b.c", "public")]) + + #expect(await search("b.c", in: catalog).map(\.name) == ["b.c"]) + } + + @Test("A bare name finds the table in every schema, the browsed schema first") + func bareNameBrowsedFirst() async { + let catalog = items([table("timesheet", "attendance"), table("timesheet", "public")]) + + let results = await search("timesheet", in: catalog) + + #expect(results.map(\.schemaName) == ["public", "attendance"]) + } + + @Test("A qualified query reaches a result from another connection through its target") + func qualifiedQueryInConnectionsScope() async throws { + let target = QuickSwitcherTarget( + connectionId: UUID(), connectionName: "Primary", databaseName: "app", schemaName: "fallback" + ) + let remote = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [table("timesheet", "attendance"), table("timesheet", "public")], + target: target + ) + guard let defaults = UserDefaults(suiteName: "QuickSwitcherCrossSchemaTests.\(UUID().uuidString)") else { + Issue.record("no defaults suite") + return + } + let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: defaults) + viewModel.crossConnectionItems = remote + viewModel.scope = .connections + viewModel.searchText = "attendance.timesheet" + await viewModel.flushPendingFilter() + + #expect(viewModel.flatItems.map { $0.target?.schemaName } == ["attendance"]) + } + + @Test("A path match needs every container it names") + func pathMatchNeedsEveryContainer() throws { + let row = try #require(items([table("timesheet", "attendance")]).first) + let named = try #require(QualifiedSearchQuery("attendance.timesheet")) + let other = try #require(QualifiedSearchQuery("payroll.timesheet")) + + #expect(QuickSwitcherViewModel.pathMatch(for: row, query: named) != nil) + #expect(QuickSwitcherViewModel.pathMatch(for: row, query: other) == nil) + } + + // MARK: - Identity + + @Test("A name without a dot keeps the id it always had") + func idUnchangedWithoutDots() { + #expect(QuickSwitcherItem.tableItemId(name: "users", schema: "public") == "table_public.users") + #expect(QuickSwitcherItem.tableItemId(name: "users", schema: nil) == "table_users") + } + + @Test("Dotted names in different schemas never share an id") + func dottedNamesStayDistinct() { + let first = QuickSwitcherItem.tableItemId(name: "b.c", schema: "a") + let second = QuickSwitcherItem.tableItemId(name: "c", schema: "a.b") + let unqualified = QuickSwitcherItem.tableItemId(name: "a.b", schema: nil) + let qualified = QuickSwitcherItem.tableItemId(name: "b", schema: "a") + + #expect(first != second) + #expect(unqualified != qualified) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift new file mode 100644 index 0000000000..dd58ac42ff --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift @@ -0,0 +1,279 @@ +// +// DatabaseTreeFilterQualifiedSearchTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseTreeFilter qualified and cross-schema search") +struct DatabaseTreeFilterQualifiedSearchTests { + private func table(_ name: String, schema: String?) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: 0, schema: schema) + } + + private func listing(_ tables: [TableInfo], unlisted: Set = []) -> MetadataLoadState { + .loaded(CatalogTableListing.Result(tables: tables, unlistedSchemas: unlisted)) + } + + private func buckets(_ tables: [TableInfo], searchText: String) -> DatabaseTreeObjectBuckets { + DatabaseTreeFilter.objectBuckets(tables: tables, routines: [], triggers: [], searchText: searchText, database: "shop") + } + + // MARK: - Object filters + + @Test("A qualified search keeps only objects in the schema it names") + func qualifiedTablesFilter() { + let tables = [table("timesheet", schema: "attendance"), table("timesheet", schema: "public")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "attendance.time", database: "shop") + #expect(result.map(\.schema) == ["attendance"]) + } + + @Test("A trailing dot keeps every object in the schema") + func trailingDotKeepsEverything() { + let tables = [table("timesheet", schema: "attendance"), table("shift", schema: "attendance")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "attendance.", database: "shop") + #expect(result.map(\.name) == ["timesheet", "shift"]) + } + + @Test("A table named with a dot is found by typing its name") + func dottedTableNameStillMatches() { + let tables = [table("audit.events", schema: "public"), table("orders", schema: "public")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "audit.events", database: "shop") + #expect(result.map(\.name) == ["audit.events"]) + } + + @Test("A schema holding a table named with a dot is kept for that name") + func dottedTableNameKeepsItsSchema() { + let matches = DatabaseTreeFilter.SchemaListingMatches( + listing: CatalogTableListing.Result(tables: [table("audit.events", schema: "public")], unlistedSchemas: []), + database: "shop", + searchText: "audit.events" + ) + #expect(matches.matched == ["public"]) + } + + @Test("A plain search is unchanged by the database it is told about") + func plainSearchUnchanged() { + let tables = [table("timesheet", schema: "attendance"), table("orders", schema: "public")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "sheet", database: "shop") + #expect(result.map(\.name) == ["timesheet"]) + } + + // MARK: - Schema verdicts + + /// Nil `tables` stands for a listing that has not arrived, which is all a verdict can be told. + private func verdict( + _ schema: String, + search searchText: String, + tables: [TableInfo]?, + unlisted: Set = [], + loadedContent: DatabaseTreeObjectBuckets? = nil, + listingCoversSchema: Bool = true + ) -> DatabaseTreeFilter.SchemaSearchVerdict { + let matches = tables.map { + DatabaseTreeFilter.SchemaListingMatches( + listing: CatalogTableListing.Result(tables: $0, unlistedSchemas: unlisted), + database: "shop", + searchText: searchText + ) + } + return DatabaseTreeFilter.schemaSearchVerdict( + schema: schema, + database: "shop", + searchText: searchText, + loadedContent: loadedContent, + listingMatches: matches, + listingCoversSchema: listingCoversSchema + ) + } + + @Test("An unloaded schema is judged from the all-schema listing") + func unloadedSchemaJudgedByListing() { + let tables = [table("timesheet", schema: "attendance"), table("orders", schema: "sales")] + + #expect(verdict("attendance", search: "timesheet", tables: tables) == .match) + #expect(verdict("sales", search: "timesheet", tables: tables) == .noMatch) + } + + @Test("An unloaded schema is unknown while the listing has not arrived") + func unloadedSchemaUnknownWithoutListing() { + let result = verdict("attendance", search: "timesheet", tables: nil) + + #expect(result == .unknown) + #expect(result.isVisible) + } + + @Test("A schema the listing could not read is unknown, not empty") + func unlistedSchemaIsUnknown() { + #expect(verdict("attendance", search: "timesheet", tables: [], unlisted: ["attendance"]) == .unknown) + } + + @Test("A schema's own loaded lists answer before the listing") + func loadedContentWins() { + let loadedWithoutMatch = buckets([table("shift", schema: "attendance")], searchText: "timesheet") + + let result = verdict( + "attendance", search: "timesheet", + tables: [table("timesheet", schema: "attendance")], loadedContent: loadedWithoutMatch + ) + + #expect(result == .noMatch) + } + + @Test("A system schema the listing leaves out is not kept unknown") + func uncoveredSchemaIsNotUnknown() { + #expect(verdict("pg_catalog", search: "timesheet", tables: nil, listingCoversSchema: false) == .noMatch) + } + + @Test("A qualified search drops every schema it does not name") + func qualifiedSearchDropsOtherSchemas() { + let tables = [table("timesheet", schema: "attendance"), table("timesheet", schema: "public")] + + #expect(verdict("public", search: "attendance.timesheet", tables: tables) == .noMatch) + #expect(verdict("attendance", search: "attendance.timesheet", tables: tables) == .match) + } + + @Test("A plain search matching a schema's own name keeps it") + func plainSchemaNameMatches() { + #expect(verdict("attendance", search: "attend", tables: nil) == .match) + } + + @Test("One pass records every schema listed and every schema holding a match") + func listingMatchesOnePass() { + let matches = DatabaseTreeFilter.SchemaListingMatches( + listing: CatalogTableListing.Result( + tables: [table("timesheet", schema: "attendance"), table("orders", schema: "sales")], + unlistedSchemas: ["locked"] + ), + database: "shop", + searchText: "sheet" + ) + + #expect(matches.listed == ["attendance", "sales"]) + #expect(matches.matched == ["attendance"]) + #expect(matches.unlisted == ["locked"]) + } + + // MARK: - Flat list + + @Test("The flat list names the other schemas holding a match, sorted, never the browsed one") + func otherSchemaMatches() { + let allSchemas = listing([ + table("timesheet", schema: "public"), + table("timesheet", schema: "payroll"), + table("timesheet", schema: "attendance"), + table("orders", schema: "sales") + ]) + let schemas = DatabaseTreeFilter.otherSchemaMatches( + database: "shop", browsedSchema: "public", searchText: "timesheet", + hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _ in nil } + ) + #expect(schemas == ["attendance", "payroll"]) + } + + @Test("The flat list does not name a schema that matched only by its own name") + func otherSchemaMatchesIgnoresSchemaNames() { + let allSchemas = listing([table("orders", schema: "timesheets")]) + let schemas = DatabaseTreeFilter.otherSchemaMatches( + database: "shop", browsedSchema: "public", searchText: "timesheet", + hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _ in nil } + ) + #expect(schemas.isEmpty) + } + + @Test("A trailing dot names the schema it asks for") + func otherSchemaMatchesTrailingDot() { + let allSchemas = listing([table("orders", schema: "attendance"), table("orders", schema: "sales")]) + let schemas = DatabaseTreeFilter.otherSchemaMatches( + database: "shop", browsedSchema: "public", searchText: "attendance.", + hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _ in nil } + ) + #expect(schemas == ["attendance"]) + } + + @Test("The flat list names nothing until the listing arrives, and nothing hidden") + func otherSchemaMatchesWithoutListing() { + let pending = DatabaseTreeFilter.otherSchemaMatches( + database: "shop", browsedSchema: "public", searchText: "timesheet", + hiddenSchemas: [], allSchemaTables: .loading, loadedContent: { _ in nil } + ) + let hidden = DatabaseTreeFilter.otherSchemaMatches( + database: "shop", browsedSchema: "public", searchText: "timesheet", + hiddenSchemas: ["attendance"], + allSchemaTables: listing([table("timesheet", schema: "attendance")]), + loadedContent: { _ in nil } + ) + #expect(pending.isEmpty) + #expect(hidden.isEmpty) + } + + // MARK: - Hierarchical shape + + @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) + } + + @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) + } + + @Test("A trailing dot shows everything in the hierarchical schema it names") + func hierarchicalTrailingDot() { + let buckets = DatabaseTreeFilter.hierarchicalObjectBuckets( + schema: "SALES", + tables: [table("ORDERS", schema: "SALES"), table("LINES", schema: "SALES")], + routines: [], triggers: [], userTypes: [], searchText: "SALES." + ) + #expect(buckets.itemCounts[.table] == 2) + } + + @Test("visibleSchemas keeps a qualified search's schema and drops the rest") + func visibleSchemasQualified() { + let visible = DatabaseTreeFilter.visibleSchemas( + ["attendance", "public", "sales"], + systemSchemas: [], + activeSchema: nil, + showsSystem: false, + searchText: "attendance.", + database: "shop", + contentMatches: { _ in false } + ) + #expect(visible == ["attendance"]) + } + + @Test("While searching, a kind section opens only when it holds a match") + func searchOpensOnlyMatchingSections() { + #expect(DatabaseTreeFilter.objectGroupIsExpanded(searching: true, matchCount: 1, stored: false)) + #expect(!DatabaseTreeFilter.objectGroupIsExpanded(searching: true, matchCount: 0, stored: true)) + } + + @Test("Outside a search a kind section keeps the user's choice") + func noSearchKeepsStoredExpansion() { + #expect(DatabaseTreeFilter.objectGroupIsExpanded(searching: false, matchCount: 0, stored: true)) + #expect(!DatabaseTreeFilter.objectGroupIsExpanded(searching: false, matchCount: 3, stored: false)) + } +} diff --git a/docs/features/connection-window.mdx b/docs/features/connection-window.mdx index 47a76e5454..814c058979 100644 --- a/docs/features/connection-window.mdx +++ b/docs/features/connection-window.mdx @@ -86,6 +86,24 @@ The palette offers a tile for a control the tab you are on has hidden. Drag **Re In [Agent mode](/features/agent-mode) the chooser and the filter field go, and the session list takes their height. +## Filtering the sidebar + +The filter field matches any part of a name, so `sheet` finds `timesheet`. How far it reaches depends on the layout: + +| Layout | Searches | +|---|---| +| **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. + + + Sidebar with timesheet in public at the top, and attendance and payroll below it, each opened to its timesheet table + Sidebar with timesheet in public at the top, and attendance and payroll below it, each opened to its timesheet table + + +Type a dot to filter by schema. `attendance.time` keeps the objects in `attendance` whose names contain `time`, `attendance.` shows everything in `attendance`, and `shop.attendance.time` names the database too. A table whose own name holds a dot, such as `audit.events`, still matches when you type that name. [Open Quickly](/features/open-quickly#other-schemas) takes the same form with fuzzy matching. + ## The trailing pane One column on the trailing edge, holding one surface at a time. Its header keeps one height across all three, so switching surface moves nothing beneath it. diff --git a/docs/features/open-quickly.mdx b/docs/features/open-quickly.mdx index 6f65de919e..cd04a3e8b2 100644 --- a/docs/features/open-quickly.mdx +++ b/docs/features/open-quickly.mdx @@ -30,8 +30,8 @@ The segmented control under the search field sets the scope. It stays on screen, | Scope | Key | Covers | |-------|-----|--------| -| **All** | `Cmd+1` | Tables, views, databases, schemas, and saved queries in this connection, plus its last 50 queries | -| **Tables** | `Cmd+2` | Tables, views, and system tables | +| **All** | `Cmd+1` | Tables and views in every schema of this connection's database, plus its databases, schemas, saved queries, and last 50 queries | +| **Tables** | `Cmd+2` | Tables, views, and system tables in every schema | | **Databases** | `Cmd+3` | Databases and schemas | | **Queries** | `Cmd+4` | Saved queries and recent queries, from every connected window | | **Connections** | `Cmd+5` | Tables, views, and system tables in every connected window | @@ -40,6 +40,27 @@ Leave the search empty in any scope other than All and everything in it is liste Opening a table or view opens a table tab, a database or schema switches the connection to it, and a query loads into the SQL editor without running it. +## Other schemas + +A table outside the schema the sidebar is browsing shows its schema at the end of the row, `attendance` or `attendance · View`, and keeps it while selected. Opening it leaves the connection on the schema it was browsing. + + + Open Quickly searching timesheet, with rows for public, attendance and payroll + Open Quickly searching timesheet, with rows for public, attendance and payroll + + +Type a dot to search by where a table lives: + +| Search | Finds | +|--------|-------| +| `attendance.timesheet` | `timesheet` in `attendance`, and no other `timesheet` | +| `attendance.` | Everything in `attendance` | +| `att.time` | The same table, since each part matches fuzzily | +| `shop.attendance.timesheet` | The table, when `shop` is the database the connection is browsing | +| `"my.schema".orders` | `orders` in a schema whose name holds a dot. Backticks and square brackets quote too | + +The browsed schema's tables are listed as the panel opens, and the rest arrive after one catalog query on PostgreSQL or one query per schema on other engines. Until then the panel reads **Loading…** rather than reporting no results. + ## Across connections The **Connections** and **Queries** scopes reach into every connected window, and that catalog is read the first time you open one of them, not before. @@ -60,13 +81,15 @@ A table already open in a tab shows an **Open** badge, and the hint on the selec ## Ranking -An empty search shows **Recent**: the last 10 items you opened through the panel on this connection. Once you type, a match on the name beats a match on the connection and database path beside it, tables rank above other kinds, and anything you open often through the panel or already have open in a tab moves up. The list stops at 200 results. +An empty search shows **Recent**: the last 10 items you opened through the panel on this connection. Once you type, a match on the name beats a match on the connection and database path beside it, tables rank above other kinds, and anything you open often through the panel or already have open in a tab moves up. Between two equal matches, the table in the browsed schema comes first. The list stops at 200 results. ## When a table is not listed -- The list covers the database the connection is browsing. To reach a table somewhere else, switch database first, then search again. +- The list covers every schema of the database the connection is browsing. To reach a table in another database, switch database first, then search again. +- System schemas such as `pg_catalog` and `information_schema` are left out. +- A table created in another schema since the panel last listed that schema appears the next time you open the panel. - The **Databases** scope offers the databases the sidebar's database filter keeps, plus every system database. Clear the filter with **View > Show All Databases**. -- The **Connections** scope covers connected windows only, and only the database each one is browsing. +- The **Connections** scope covers connected windows only, and only the database and schema each one is browsing. ## Right-click actions diff --git a/docs/images/quick-switcher-other-schemas-dark.png b/docs/images/quick-switcher-other-schemas-dark.png new file mode 100644 index 0000000000..117a1ef051 Binary files /dev/null and b/docs/images/quick-switcher-other-schemas-dark.png differ diff --git a/docs/images/quick-switcher-other-schemas.png b/docs/images/quick-switcher-other-schemas.png new file mode 100644 index 0000000000..535c9af8ab Binary files /dev/null and b/docs/images/quick-switcher-other-schemas.png differ diff --git a/docs/images/sidebar-filter-other-schemas-dark.png b/docs/images/sidebar-filter-other-schemas-dark.png new file mode 100644 index 0000000000..4e2034701f Binary files /dev/null and b/docs/images/sidebar-filter-other-schemas-dark.png differ diff --git a/docs/images/sidebar-filter-other-schemas.png b/docs/images/sidebar-filter-other-schemas.png new file mode 100644 index 0000000000..1ae710acdb Binary files /dev/null and b/docs/images/sidebar-filter-other-schemas.png differ diff --git a/scripts/check-postgres-table-listing-parity.sh b/scripts/check-postgres-table-listing-parity.sh new file mode 100755 index 0000000000..48be63a421 --- /dev/null +++ b/scripts/check-postgres-table-listing-parity.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# Check that PostgreSQL's all-schema table listing lists exactly what the per-schema listing does. +# +# Open Quickly and the sidebar filter find tables in schemas nobody has opened through one query, +# PostgreSQLSchemaQueries.fetchTables(in: .allSchemas). The sidebar lists each schema through +# fetchTables(in: .schema(name)). The two have to agree row for row, on every rung of the +# degradation ladder: a table the one-schema listing shows and the search cannot find is the bug +# this listing exists to fix, and one the search finds that the sidebar hides is a result that +# opens nothing. +# +# This builds a database with every shape the listing treats specially (declarative partitions, +# a partition in another schema than its parent, legacy inheritance, a materialized view, a +# foreign table, a schema the role cannot use, quoted names with dots and mixed case), compiles +# the plugin's real query builder into a small harness, and compares the two listings as a role +# with ordinary privileges. +# +# Usage: +# scripts/check-postgres-table-listing-parity.sh [host] [port] [user] +# +# Needs psql, a reachable PostgreSQL 10 or newer the user may create a database and a role on, and +# a Debug build of TableProPluginKit in DerivedData (run verify.sh build first). Exits non-zero on +# a disagreement. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-5432}" +USER_NAME="${3:-postgres}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Named per run and never dropped unless this run created them, so pointing the check at a shared +# server cannot touch anything that was already there, and two runs cannot remove each other's. +RUN_ID="$$_$RANDOM" +DATABASE="tablepro_listing_parity_$RUN_ID" +READER="tablepro_listing_reader_$RUN_ID" +CREATED_DATABASE=0 +CREATED_READER=0 +WORK="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK" + if [ "$CREATED_DATABASE" -eq 1 ]; then + psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE $DATABASE" > /dev/null 2>&1 + fi + if [ "$CREATED_READER" -eq 1 ]; then + psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP ROLE $READER" > /dev/null 2>&1 + fi +} +trap cleanup EXIT + +command -v psql > /dev/null || { + echo "psql not found" >&2 + exit 3 +} + +DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" +export DEVELOPER_DIR +FRAMEWORK_DIR="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -path '*/Build/Products/Debug/TableProPluginKit.framework' -print 2> /dev/null \ + | while read -r path; do echo "$(stat -f %m "$path") $(dirname "$path")"; done \ + | sort -rn | head -1 | cut -d' ' -f2-)" +[ -n "$FRAMEWORK_DIR" ] || { + echo "no Debug TableProPluginKit.framework in DerivedData; build the app first" >&2 + exit 3 +} + +psql_do() { + psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$1" -v ON_ERROR_STOP=1 "${@:2}" +} + +if ! psql_do postgres -Atc "SELECT 1" > /dev/null 2>&1; then + echo "no PostgreSQL at $HOST:$PORT as $USER_NAME" >&2 + exit 3 +fi + +# The same plugin sources the test target compiles, read out of project.yml so the list cannot +# drift from what the tests exercise. +SOURCES=() +while IFS= read -r source; do + SOURCES+=("$ROOT/$source") +done < <(grep -oE 'Plugins/PostgreSQLDriverPlugin/[A-Za-z+]+\.swift' "$ROOT/project.yml" | sort -u) + +cat > "$WORK/main.swift" << 'SWIFT' +@main +enum ListingSQL { + static func main() { + let arguments = CommandLine.arguments + let attempts = PostgreSQLTableListingLadder.degradableAttempts + [PostgreSQLTableListingLadder.leastCapableAttempt] + if arguments[1] == "rungs" { + print(attempts.count) + return + } + if arguments[1] == "schemas" { + print(PostgreSQLSchemaQueries.listSchemas) + return + } + let attempt = attempts[Int(arguments[2]) ?? 0] + let listing: PostgreSQLTableListingScope = arguments[1] == "all" ? .allSchemas : .schema(arguments[3]) + print(PostgreSQLSchemaQueries.fetchTables( + in: listing, + includeMaterializedViews: attempt.includeOptionalCatalogs, + includeForeignTables: attempt.includeOptionalCatalogs, + includeComments: attempt.includeComments, + includePartitionAwareness: attempt.includePartitionAwareness + )) + } +} +SWIFT + +xcrun swiftc -swift-version 6 -parse-as-library -module-name ListingSQL -Onone \ + -F "$FRAMEWORK_DIR" -framework TableProPluginKit -Xlinker -rpath -Xlinker "$FRAMEWORK_DIR" \ + "${SOURCES[@]}" "$WORK/main.swift" -o "$WORK/listing-sql" > "$WORK/compile.log" 2>&1 || { + echo "harness failed to compile:" >&2 + grep -E 'error:' "$WORK/compile.log" | sort -u | head -20 >&2 + exit 3 +} +HARNESS="$WORK/listing-sql" + +VERSION="$(psql_do postgres -Atc "SHOW server_version")" +echo "Checking the all-schema table listing against PostgreSQL $VERSION at $HOST:$PORT" + +psql_do postgres -c "CREATE DATABASE $DATABASE" > /dev/null || exit 3 +CREATED_DATABASE=1 +psql_do postgres -c "CREATE ROLE $READER" > /dev/null || exit 3 +CREATED_READER=1 + +psql_do "$DATABASE" > /dev/null << SQL || exit 3 +CREATE SCHEMA attendance; +CREATE SCHEMA "Attendance"; +CREATE SCHEMA "my.schema"; +CREATE SCHEMA archive; +CREATE SCHEMA locked; +CREATE SCHEMA empty_schema; +CREATE TABLE public.timesheet (id int); +CREATE TABLE attendance.timesheet (id int); +CREATE TABLE attendance."time.sheet" (id int); +CREATE TABLE "Attendance"."TimeSheet" (id int); +CREATE TABLE "my.schema".orders (id int); +CREATE TABLE locked.secret (id int); +CREATE TABLE public.events (at date) PARTITION BY RANGE (at); +CREATE TABLE public.events_2025 PARTITION OF public.events FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE TABLE archive.events_2024 PARTITION OF public.events FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE public.parent_legacy (id int); +CREATE TABLE public.child_legacy () INHERITS (public.parent_legacy); +CREATE VIEW attendance.active AS SELECT id FROM attendance.timesheet; +CREATE MATERIALIZED VIEW attendance.summary AS SELECT count(*) FROM attendance.timesheet; +CREATE EXTENSION IF NOT EXISTS postgres_fdw; +CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw OPTIONS (dbname '$DATABASE'); +CREATE FOREIGN TABLE archive.remote_orders (id int) SERVER loopback OPTIONS (table_name 'orders'); +GRANT USAGE ON SCHEMA public, attendance, "Attendance", "my.schema", archive, empty_schema TO $READER; +GRANT SELECT ON ALL TABLES IN SCHEMA public, attendance, "Attendance", "my.schema", archive TO $READER; +REVOKE ALL ON SCHEMA locked FROM PUBLIC; +SQL + +as_reader() { + psql_do "$DATABASE" -Atq -F '|' -c "SET ROLE $READER" -c "$1" +} + +FAILURES=0 +RUNGS="$("$HARNESS" rungs)" +SCHEMA_LIST="$("$HARNESS" schemas)" +for ((rung = 0; rung < RUNGS; rung++)); do + : > "$WORK/one.txt" + while IFS= read -r schema; do + [ -n "$schema" ] || continue + as_reader "$("$HARNESS" one "$rung" "$schema")" | while IFS='|' read -r name type _; do + printf '%s|%s|%s\n' "$schema" "$name" "$type" + done >> "$WORK/one.txt" + done < <(as_reader "$SCHEMA_LIST") + as_reader "$("$HARNESS" all "$rung")" | while IFS='|' read -r name type _ _ schema; do + printf '%s|%s|%s\n' "$schema" "$name" "$type" + done > "$WORK/all.txt" + + sort -o "$WORK/one.txt" "$WORK/one.txt" + sort -o "$WORK/all.txt" "$WORK/all.txt" + if diff -u "$WORK/one.txt" "$WORK/all.txt" > "$WORK/diff.txt"; then + echo "rung $rung: $(wc -l < "$WORK/all.txt" | tr -d ' ') tables, listings agree" + else + echo "FAIL rung $rung: per-schema (-) and all-schema (+) listings differ" >&2 + cat "$WORK/diff.txt" >&2 + FAILURES=$((FAILURES + 1)) + fi +done + +for expected in "attendance|timesheet|BASE TABLE" "attendance|time.sheet|BASE TABLE" "Attendance|TimeSheet|BASE TABLE" "my.schema|orders|BASE TABLE"; do + grep -qxF "$expected" "$WORK/all.txt" || { + echo "FAIL: the all-schema listing is missing $expected" >&2 + FAILURES=$((FAILURES + 1)) + } +done +if grep -q '^locked|' "$WORK/all.txt"; then + echo "FAIL: the all-schema listing shows a schema the role cannot use" >&2 + FAILURES=$((FAILURES + 1)) +fi + +[ "$FAILURES" -eq 0 ] || exit 1 +echo "PASS"