diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cfc51f7..59a3b03f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off. - **Local only** connections taking edits and deletions made on another device. +- Favorites tab missing starred tables from other schemas, and every starred table on Oracle, Snowflake, BigQuery and Trino. - etcd SSL Mode that the driver ignored, and etcd URLs that lost or faked TLS on import and Copy as URL. - Export dialog offering a SQL row scope on MongoDB, Redis and other engines without SQL. - CSV, XLSX and MQL exports ignoring a table's row filter, row limit and column choice. diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ac60072a8..6e48d4a27 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -99531,6 +99531,9 @@ } } } + }, + "No Favorites in This %@" : { + }, "No favorites match the selected environment." : { "localizations" : { @@ -162274,6 +162277,9 @@ }, "The table's primary key is not loaded, so the row cannot be written." : { + }, + "The tables you starred here no longer exist." : { + }, "the target" : { "localizations" : { @@ -181005,6 +181011,9 @@ } } } + }, + "Your favorite tables are in other %@. Switch to one to see them here." : { + }, "Your license has expired" : { "localizations" : { diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index fac6da7fd..411df543b 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -22,7 +22,7 @@ final class ERDiagramViewModel: ObservableObject { services.databaseManager.resolvedScope(database: databaseName, schema: schemaName, for: connectionId) } - private static let noSchemaMarker = "default" + nonisolated private static let noSchemaMarker = "default" /// `schemaKey` is the diagram's identity, written as `database.schema` with /// `noSchemaMarker` standing in for an engine that has no schemas. It is also the only @@ -30,7 +30,7 @@ final class ERDiagramViewModel: ObservableObject { /// database into the tab's table context but never a schema. Stripping the database /// prefix rather than splitting on the separator keeps a database name that contains a /// dot intact. - static func resolveSchemaName(fromSchemaKey schemaKey: String, databaseName: String) -> String? { + nonisolated static func resolveSchemaName(fromSchemaKey schemaKey: String, databaseName: String) -> String? { let prefix = databaseName + "." guard !databaseName.isEmpty, schemaKey.hasPrefix(prefix) else { return nil } let schema = String(schemaKey.dropFirst(prefix.count)) @@ -38,6 +38,15 @@ final class ERDiagramViewModel: ObservableObject { return schema } + nonisolated static func schemaKey(databaseName: String, schema: String?) -> String { + "\(databaseName).\(schema ?? noSchemaMarker)" + } + + nonisolated static func schemaKeyPreserves(_ schema: String, databaseName: String) -> Bool { + resolveSchemaName(fromSchemaKey: schemaKey(databaseName: databaseName, schema: schema), databaseName: databaseName) + == schema + } + // MARK: - State enum LoadState: Equatable { diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift b/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift index b985919b6..5b71ce941 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift @@ -64,9 +64,9 @@ extension FavoritesSidebarViewModel { FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } } - internal func removeTableFavorite(_ table: TableInfo, database: String?) { + internal func removeTableFavorite(_ entry: FavoriteTablesStorage.FavoriteEntry) { FavoriteTablesStorage.shared.removeFavorite( - name: table.name, schema: table.schema, database: database, connectionId: connectionId + name: entry.name, schema: entry.schema, database: entry.database, connectionId: entry.connectionId ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index 6d5f1427c..c72945ea9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift @@ -2,7 +2,8 @@ import AppKit import Foundation extension MainContentCoordinator { - /// Open (or focus) an ER Diagram tab for the current database/schema. + /// Open (or focus) an ER Diagram tab for the current database and `schema`, or the browsed + /// schema when none is named. /// /// Resolution order: /// 1. If another window for this connection already hosts an ER Diagram @@ -11,10 +12,10 @@ extension MainContentCoordinator { /// tabs yet), add the ER Diagram tab locally. /// 3. Otherwise open a new native window tab so the current tab's content /// (unsaved queries, filters, etc.) is preserved. - func showERDiagram() { + func showERDiagram(schema: String? = nil) { let dbName = browseDatabaseName - let schemaName = DatabaseManager.shared.session(for: connectionId)?.browseSchema - let schemaKey = "\(dbName).\(schemaName ?? "default")" + let schemaName = schema ?? DatabaseManager.shared.session(for: connectionId)?.browseSchema + let schemaKey = ERDiagramViewModel.schemaKey(databaseName: dbName, schema: schemaName) if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { $0.tabType == .erDiagram && $0.display.erDiagramSchemaKey == schemaKey diff --git a/TablePro/Views/Sidebar/FavoriteTableCatalogReader.swift b/TablePro/Views/Sidebar/FavoriteTableCatalogReader.swift new file mode 100644 index 000000000..85950b62a --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteTableCatalogReader.swift @@ -0,0 +1,209 @@ +// +// FavoriteTableCatalogReader.swift +// TablePro +// + +import Combine +import Foundation +import TableProPluginKit + +internal struct FavoriteTableLoadRequest: Equatable { + internal let database: String? + internal let schemas: Set + + internal static let none = FavoriteTableLoadRequest(database: nil, schemas: []) +} + +internal struct FavoriteTableRead: Equatable { + internal let resolution: FavoriteTableResolution + internal let loadRequest: FavoriteTableLoadRequest +} + +@MainActor +internal struct FavoriteTableCatalogReader { + internal let connectionId: UUID + internal let grouping: GroupingStrategy + internal let isConnected: Bool + internal let schemaService: SchemaService + internal let treeService: DatabaseTreeMetadataService + + internal init( + connectionId: UUID, + grouping: GroupingStrategy, + isConnected: Bool, + schemaService: SchemaService = .shared, + treeService: DatabaseTreeMetadataService = .shared + ) { + self.connectionId = connectionId + self.grouping = grouping + self.isConnected = isConnected + self.schemaService = schemaService + self.treeService = treeService + } + + internal func read( + _ entries: [FavoriteTablesStorage.FavoriteEntry], + scope: FavoriteTableBrowseScope, + search: SidebarSearch + ) -> FavoriteTableRead { + let catalog = catalog(for: entries, scope: scope) + return FavoriteTableRead( + resolution: FavoriteTableResolver.resolve(entries, scope: scope, catalog: catalog, search: search), + loadRequest: loadRequest(for: entries, scope: scope, catalog: catalog) + ) + } + + internal func catalog( + for entries: [FavoriteTablesStorage.FavoriteEntry], + scope: FavoriteTableBrowseScope + ) -> FavoriteTableCatalog { + let browsed = entries.filter(scope.contains) + guard !browsed.isEmpty else { return .empty } + let names = Set(browsed.map(\.name)) + let schemas = Set(browsed.map { $0.schema ?? "" }) + var sources: [FavoriteTableCatalog.Source] = [] + if let loadedScope = schemaServiceScope(browsing: scope.database) { + sources += flatListSource(loadedScope: loadedScope, names: names) + sources += perSchemaSources(schemas: schemas, names: names) + } + sources += treeSources(database: scope.database, schemas: schemas, names: names) + sources += listingSource(database: scope.database, names: names) + return FavoriteTableCatalog(sources: sources) + } + + internal func load(_ request: FavoriteTableLoadRequest) { + for schema in request.schemas.sorted() { + Task { await load(schema: schema, database: request.database) } + } + } + + internal func rowForOpening( + _ entry: FavoriteTablesStorage.FavoriteEntry, + scope: FavoriteTableBrowseScope + ) async -> FavoriteTableRow? { + if let row = row(for: entry, scope: scope), row.isVerified { return row } + await schemaService.waitForRefresh(connectionId: connectionId) + if let row = row(for: entry, scope: scope), row.isVerified { return row } + await loadList(for: entry, scope: scope) + return row(for: entry, scope: scope) + } + + private func row( + for entry: FavoriteTablesStorage.FavoriteEntry, + scope: FavoriteTableBrowseScope + ) -> FavoriteTableRow? { + FavoriteTableResolver.resolve( + [entry], + scope: scope, + catalog: catalog(for: [entry], scope: scope), + search: SidebarSearch("") + ).rows.first + } + + private func loadList(for entry: FavoriteTablesStorage.FavoriteEntry, scope: FavoriteTableBrowseScope) async { + guard isConnected, scope.listsTablesPerSchema, scope.contains(entry), + let schema = entry.schema?.nilIfEmpty, + schemaServiceScope(browsing: scope.database) != nil else { return } + if grouping == .hierarchicalSchema { + await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, database: scope.database) + return + } + let database = scope.database ?? "" + await treeService.loadTables(connectionId: connectionId, database: database, schema: schema) + let key = DatabaseTreeMetadataService.ObjectsKey(connectionId: connectionId, database: database, schema: schema) + for await states in treeService.$tablesState.values { + guard case .loading = states[key] else { return } + } + } + + private func load(schema: String, database: String?) async { + guard canStartLoad(schema: schema, database: database) else { return } + if grouping == .hierarchicalSchema { + await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, database: database) + } else { + await treeService.loadTables(connectionId: connectionId, database: database ?? "", schema: schema) + } + } + + private func loadRequest( + for entries: [FavoriteTablesStorage.FavoriteEntry], + scope: FavoriteTableBrowseScope, + catalog: FavoriteTableCatalog + ) -> FavoriteTableLoadRequest { + guard isConnected, schemaServiceScope(browsing: scope.database) != nil else { return .none } + let schemas = FavoriteTableResolver.schemasNeedingLoad(entries, scope: scope, catalog: catalog) + .filter { canStartLoad(schema: $0, database: scope.database) } + guard !schemas.isEmpty else { return .none } + return FavoriteTableLoadRequest(database: scope.database, schemas: schemas) + } + + private func canStartLoad(schema: String, database: String?) -> Bool { + if grouping == .hierarchicalSchema { + return schemaService.schemaObjectsNeedFetch(for: connectionId, schema: schema) + } + guard case .idle = treeService.tablesLoadState( + connectionId: connectionId, database: database ?? "", schema: schema + ) else { return false } + return true + } + + private func schemaServiceScope(browsing database: String?) -> DatabaseScope? { + guard let loadedScope = schemaService.loadedScope(for: connectionId), + loadedScope.database.nilIfEmpty == database else { return nil } + return loadedScope + } + + private func flatListSource(loadedScope: DatabaseScope, names: Set) -> [FavoriteTableCatalog.Source] { + guard grouping != .hierarchicalSchema, + case .loaded(let tables) = schemaService.state(for: connectionId) else { return [] } + let coverage: FavoriteTableCatalog.Source.Coverage = grouping == .bySchema + ? .schemas(Set(tables.map { $0.schema ?? "" } + [loadedScope.schema].compactMap { $0 })) + : .everySchema + return [FavoriteTableCatalog.Source( + tables: tables.filter { names.contains($0.name) }, + coverage: coverage, + isCurrent: !schemaService.isRefreshing(connectionId: connectionId) + )] + } + + private func perSchemaSources(schemas: Set, names: Set) -> [FavoriteTableCatalog.Source] { + schemas.filter { !$0.isEmpty }.compactMap { schema in + guard case .loaded(let tables) = schemaService.schemaState(for: connectionId, schema: schema) else { + return nil + } + return FavoriteTableCatalog.Source( + tables: tables.filter { names.contains($0.name) }, + coverage: .schemas([schema]), + isCurrent: schemaService.isSchemaCurrent(for: connectionId, schema: schema) + ) + } + } + + private func treeSources( + database: String?, + schemas: Set, + names: Set + ) -> [FavoriteTableCatalog.Source] { + schemas.compactMap { schema in + let state = treeService.tablesLoadState( + connectionId: connectionId, database: database ?? "", schema: schema.nilIfEmpty + ) + guard case .loaded(let tables) = state else { return nil } + return FavoriteTableCatalog.Source( + tables: tables.filter { names.contains($0.name) }, + coverage: .schemas([schema]), + isCurrent: true + ) + } + } + + private func listingSource(database: String?, names: Set) -> [FavoriteTableCatalog.Source] { + let state = treeService.allSchemaTablesLoadState(connectionId: connectionId, database: database ?? "") + guard case .loaded(let listing) = state else { return [] } + return [FavoriteTableCatalog.Source( + tables: listing.tables.filter { names.contains($0.name) }, + coverage: .schemas(Set(listing.tables.map { $0.schema ?? "" })), + isCurrent: false + )] + } +} diff --git a/TablePro/Views/Sidebar/FavoriteTableResolver.swift b/TablePro/Views/Sidebar/FavoriteTableResolver.swift new file mode 100644 index 000000000..d194d25f5 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteTableResolver.swift @@ -0,0 +1,183 @@ +// +// FavoriteTableResolver.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct FavoriteTableBrowseScope: Equatable { + internal let database: String? + internal let schema: String? + internal let listsTablesPerSchema: Bool + + internal init(database: String?, schema: String?, listsTablesPerSchema: Bool) { + self.database = database?.nilIfEmpty + self.schema = schema?.nilIfEmpty + self.listsTablesPerSchema = listsTablesPerSchema + } + + internal func contains(_ entry: FavoriteTablesStorage.FavoriteEntry) -> Bool { + entry.database?.nilIfEmpty == database + } +} + +internal struct FavoriteTableCatalog { + internal struct Source { + internal enum Coverage: Equatable { + case everySchema + case schemas(Set) + } + + internal let coverage: Coverage + internal let isCurrent: Bool + private let tablesByKey: [FavoriteTableKey: TableInfo] + + internal init(tables: [TableInfo], coverage: Coverage, isCurrent: Bool) { + self.coverage = coverage + self.isCurrent = isCurrent + self.tablesByKey = Dictionary( + tables.map { (FavoriteTableKey(schema: $0.schema, name: $0.name), $0) }, + uniquingKeysWith: { first, _ in first } + ) + } + + internal func covers(schema: String) -> Bool { + switch coverage { + case .everySchema: + return true + case .schemas(let schemas): + return schemas.contains(schema) + } + } + + internal func table(for entry: FavoriteTablesStorage.FavoriteEntry) -> TableInfo? { + tablesByKey[FavoriteTableKey(schema: entry.schema, name: entry.name)] + } + } + + internal static let empty = FavoriteTableCatalog(sources: []) + + internal let sources: [Source] + + internal func hasList(forSchema schema: String) -> Bool { + sources.contains { $0.covers(schema: schema) } + } +} + +internal struct FavoriteTableKey: Hashable { + internal let schema: String + internal let name: String + + internal init(schema: String?, name: String) { + self.schema = schema ?? "" + self.name = name + } +} + +internal struct FavoriteTableRow: Equatable, Identifiable { + internal let entry: FavoriteTablesStorage.FavoriteEntry + internal let listedTable: TableInfo? + internal let isVerified: Bool + internal let otherSchema: String? + + internal var id: String { + FavoritesOutlineNode.tableId(database: entry.database, schema: entry.schema, name: entry.name) + } + + internal var knownType: TableInfo.TableType? { + listedTable?.type + } + + internal var verifiedType: TableInfo.TableType? { + isVerified ? knownType : nil + } + + internal var opensReadOnly: Bool { + verifiedType.map { !$0.allowsRowEditing } ?? true + } + + internal var table: TableInfo { + listedTable ?? TableInfo(name: entry.name, type: .table, rowCount: nil, schema: entry.schema) + } +} + +internal struct FavoriteTableResolution: Equatable { + internal static let empty = FavoriteTableResolution(rows: [], missingCount: 0, otherDatabaseCount: 0) + + internal let rows: [FavoriteTableRow] + internal let missingCount: Int + internal let otherDatabaseCount: Int +} + +internal enum FavoriteTableResolver { + internal static func resolve( + _ entries: some Sequence, + scope: FavoriteTableBrowseScope, + catalog: FavoriteTableCatalog, + search: SidebarSearch + ) -> FavoriteTableResolution { + var rows: [FavoriteTableRow] = [] + var missingCount = 0 + var otherDatabaseCount = 0 + for entry in entries { + guard scope.contains(entry) else { + otherDatabaseCount += 1 + continue + } + guard let row = row(for: entry, scope: scope, catalog: catalog) else { + missingCount += 1 + continue + } + rows.append(row) + } + let visible = search.isEmpty ? rows : rows.filter { row in + search.matchesObject(named: row.entry.name, database: scope.database, schema: row.entry.schema) + } + return FavoriteTableResolution( + rows: visible.sorted(by: displayOrder), + missingCount: missingCount, + otherDatabaseCount: otherDatabaseCount + ) + } + + internal static func schemasNeedingLoad( + _ entries: some Sequence, + scope: FavoriteTableBrowseScope, + catalog: FavoriteTableCatalog + ) -> Set { + guard scope.listsTablesPerSchema else { return [] } + var schemas: Set = [] + for entry in entries where scope.contains(entry) { + guard let schema = entry.schema?.nilIfEmpty, !catalog.hasList(forSchema: schema) else { continue } + schemas.insert(schema) + } + return schemas + } + + private static func row( + for entry: FavoriteTablesStorage.FavoriteEntry, + scope: FavoriteTableBrowseScope, + catalog: FavoriteTableCatalog + ) -> FavoriteTableRow? { + let schema = entry.schema ?? "" + let otherSchema = scope.listsTablesPerSchema + ? SchemaQualifiedName.explicitSchema(entry.schema, implicitSchemaName: scope.schema) + : nil + let authorities = catalog.sources.filter { $0.isCurrent && $0.covers(schema: schema) } + guard authorities.isEmpty else { + guard let listed = authorities.lazy.compactMap({ $0.table(for: entry) }).first else { return nil } + return FavoriteTableRow(entry: entry, listedTable: listed, isVerified: true, otherSchema: otherSchema) + } + return FavoriteTableRow( + entry: entry, + listedTable: catalog.sources.lazy.compactMap { $0.table(for: entry) }.first, + isVerified: false, + otherSchema: otherSchema + ) + } + + private static func displayOrder(_ lhs: FavoriteTableRow, _ rhs: FavoriteTableRow) -> Bool { + (lhs.entry.name, lhs.entry.schema ?? "") < (rhs.entry.name, rhs.entry.schema ?? "") + } +} diff --git a/TablePro/Views/Sidebar/FavoritesEmptyState.swift b/TablePro/Views/Sidebar/FavoritesEmptyState.swift index 6027943af..1a4b465b3 100644 --- a/TablePro/Views/Sidebar/FavoritesEmptyState.swift +++ b/TablePro/Views/Sidebar/FavoritesEmptyState.swift @@ -5,7 +5,7 @@ import Foundation -/// Which of the four states the Favorites tab is in. +/// Which state the Favorites tab is in. /// /// The tab used to decide this inline, and read a list emptied by the environment filter as a /// failed search: `ContentUnavailableView.search(text:)` renders "No Results for “”" over spelling @@ -16,6 +16,8 @@ internal enum FavoritesEmptyState: Equatable { case noFavorites case noFilterMatch case noSearchMatch(String) + case favoriteTablesInOtherDatabases + case favoriteTablesMissing case content internal struct Input { @@ -24,19 +26,25 @@ internal enum FavoritesEmptyState: Equatable { internal let hasVisibleContent: Bool internal let searchText: String internal let isEnvironmentFiltered: Bool + internal let favoriteTablesInOtherDatabases: Int + internal let missingFavoriteTables: Int internal init( isInitialLoadComplete: Bool, hasAnyFavorite: Bool, hasVisibleContent: Bool, searchText: String, - isEnvironmentFiltered: Bool + isEnvironmentFiltered: Bool, + favoriteTablesInOtherDatabases: Int = 0, + missingFavoriteTables: Int = 0 ) { self.isInitialLoadComplete = isInitialLoadComplete self.hasAnyFavorite = hasAnyFavorite self.hasVisibleContent = hasVisibleContent self.searchText = searchText self.isEnvironmentFiltered = isEnvironmentFiltered + self.favoriteTablesInOtherDatabases = favoriteTablesInOtherDatabases + self.missingFavoriteTables = missingFavoriteTables } } @@ -59,6 +67,8 @@ internal enum FavoritesEmptyState: Equatable { if !input.hasAnyFavorite { return .noFavorites } if !input.searchText.isEmpty { return .noSearchMatch(input.searchText) } if input.isEnvironmentFiltered { return .noFilterMatch } + if input.favoriteTablesInOtherDatabases > 0 { return .favoriteTablesInOtherDatabases } + if input.missingFavoriteTables > 0 { return .favoriteTablesMissing } return .noFavorites } } diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift index fcf6540a0..73178e257 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift @@ -154,11 +154,8 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline } if !owner.input.tables.isEmpty { nodes.append(node(id: FavoritesOutlineNode.tablesHeaderId, kind: .header(String(localized: "Tables")))) - nodes += owner.input.tables.map { table in - let id = FavoritesOutlineNode.tableId( - database: owner.input.activeDatabase, schema: table.schema, name: table.name - ) - return node(id: id, kind: .table(table)) + nodes += owner.input.tables.map { row in + node(id: row.id, kind: .table(row)) } } if !owner.input.queryNodes.isEmpty { @@ -269,9 +266,7 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline } /// Selecting is not running. Arrowing through saved queries must never insert or execute /// them, so the primary action stays behind a double-click or Return. - owner.selection = FavoritesOutlineSelection.selection( - for: node.kind, database: owner.input.activeDatabase - ) + owner.selection = FavoritesOutlineSelection.selection(for: node.kind) } // MARK: - Actions diff --git a/TablePro/Views/Sidebar/FavoritesOutlineNode.swift b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift index 08c39982a..0bfa034b9 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineNode.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift @@ -15,7 +15,7 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { case header(String) case databaseEnvironment(FavoriteDatabaseGroup) case database(FavoriteDatabaseEntry) - case table(TableInfo) + case table(FavoriteTableRow) case query(FavoriteNode) case teamQuery(id: String, name: String, publishedBy: String?) } @@ -52,7 +52,7 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { internal static let teamHeaderId = "favorites\u{1}header\u{1}team" /// Built from the three plain strings the persisted selection carries, so a selection can be - /// restored without a live `TableInfo` to hand. + /// restored without a live row to hand. internal static func tableId(database: String?, schema: String?, name: String) -> String { ["favtable", database ?? "", schema ?? "", name].joined(separator: "\u{1}") } diff --git a/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift index 722aa2154..fb816d294 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift @@ -14,10 +14,7 @@ internal enum FavoritesOutlineSelection { return false } - internal static func selection( - for kind: FavoritesOutlineNode.Kind, - database: String? - ) -> FavoriteSelection? { + internal static func selection(for kind: FavoritesOutlineNode.Kind) -> FavoriteSelection? { switch kind { case .header: return nil @@ -25,8 +22,8 @@ internal enum FavoritesOutlineSelection { return .node(id: FavoritesOutlineNode.databaseEnvironmentId(group.environment)) case .database(let entry): return .node(id: FavoritesOutlineNode.databaseId(entry)) - case .table(let table): - return .table(database: database, schema: table.schema, name: table.name) + case .table(let row): + return .table(database: row.entry.database, schema: row.entry.schema, name: row.entry.name) case .query(let node): return .node(id: node.id) case .teamQuery(let id, _, _): @@ -53,8 +50,8 @@ internal enum FavoritesOutlineSelection { return group.environment.title case .database(let entry): return entry.database - case .table(let table): - return table.name + case .table(let row): + return row.entry.name case .teamQuery(_, let name, _): return name case .query(let node): diff --git a/TablePro/Views/Sidebar/FavoritesOutlineView.swift b/TablePro/Views/Sidebar/FavoritesOutlineView.swift index 635cac5e4..9c1bd53cd 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineView.swift @@ -15,7 +15,7 @@ internal struct FavoritesOutlineInput { internal let databaseEntityName: String internal let databaseEntityNamePlural: String internal let isNarrowingDatabases: Bool - internal let tables: [TableInfo] + internal let tables: [FavoriteTableRow] internal let queryNodes: [FavoriteNode] internal let teamQueries: [FavoritesOutlineTeamQuery] internal let renamingFolderId: UUID? diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index bc09c472b..25859d8a7 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -1,11 +1,14 @@ import SwiftUI import TableProImport +import TableProPluginKit internal struct FavoritesTabView: View { @ObservedObject private var teamLibrarySync = TeamLibrarySyncCoordinator.shared @ObservedObject private var licenseManager = LicenseManager.shared @ObservedObject private var settingsManager = AppSettingsManager.shared @ObservedObject private var gitStatusStore = LinkedFolderGitStatusStore.shared + @ObservedObject private var schemaService = SchemaService.shared + @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared @Environment(\.sidebarRowSize) private var systemRowSize @StateObject private var viewModel: FavoritesSidebarViewModel @@ -18,10 +21,10 @@ internal struct FavoritesTabView: View { @State private var linkedMetadataTarget: LinkedSQLFavorite? @State private var linkedFolderToRemove: LinkedSQLFolder? @State private var showRemoveLinkedFolderAlert = false + @State private var tableOpenTask: Task? let connectionId: UUID let databaseType: DatabaseType @ObservedObject private var sharedSidebarState: SharedSidebarState - let tables: [TableInfo] private var coordinator: MainContentCoordinator? private var searchText: String { sharedSidebarState.favoritesSearchText } @@ -46,38 +49,52 @@ internal struct FavoritesTabView: View { PluginManager.shared.containerEntityNamePlural(for: databaseType) } - private var availableFavoriteTables: [TableInfo] { - let database = activeDatabase - let tablesByKey = Dictionary( - tables.map { (Self.tableKey(schema: $0.schema, name: $0.name), $0) }, - uniquingKeysWith: { first, _ in first } + private var session: ConnectionSession? { + coordinator?.services.databaseManager.session(for: connectionId) + } + + private var favoriteTableScope: FavoriteTableBrowseScope { + FavoriteTableBrowseScope( + database: activeDatabase, + schema: session?.browseSchema, + listsTablesPerSchema: DatabaseTreeMetadataService.listsTablesPerSchema(groupingStrategy) ) - return favoriteTables.compactMap { entry in - guard entry.database == database else { return nil } - return tablesByKey[Self.tableKey(schema: entry.schema, name: entry.name)] - } } - private static func tableKey(schema: String?, name: String) -> String { - "\(schema ?? "")\u{1}\(name)" + private var groupingStrategy: GroupingStrategy { + PluginManager.shared.databaseGroupingStrategy(for: databaseType) + } + + private var favoriteTableReader: FavoriteTableCatalogReader { + FavoriteTableCatalogReader( + connectionId: connectionId, + grouping: groupingStrategy, + isConnected: session?.isConnected ?? false, + schemaService: schemaService, + treeService: treeMetadata + ) } init( connectionId: UUID, databaseType: DatabaseType, sharedSidebarState: SharedSidebarState, - tables: [TableInfo], coordinator: MainContentCoordinator? ) { self.connectionId = connectionId self.databaseType = databaseType self.sharedSidebarState = sharedSidebarState - self.tables = tables _viewModel = StateObject(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) self.coordinator = coordinator } var body: some View { + let reader = favoriteTableReader + let tableRead = reader.read( + favoriteTables, + scope: favoriteTableScope, + search: SidebarSearch(searchText) + ) VStack(spacing: 0) { if !favoriteDatabases.isEmpty { FavoriteDatabaseFilterBar(selection: $sharedSidebarState.favoriteDatabaseEnvironmentFilter) @@ -86,9 +103,7 @@ internal struct FavoritesTabView: View { Group { let items = viewModel.filteredNodes(searchText: searchText) let groups = databaseGroups - let filteredTables = searchText.isEmpty - ? availableFavoriteTables - : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + let filteredTables = tableRead.resolution.rows switch FavoritesEmptyState.resolve(FavoritesEmptyState.Input( isInitialLoadComplete: viewModel.isInitialLoadComplete, @@ -103,13 +118,24 @@ internal struct FavoritesTabView: View { || !filteredTables.isEmpty || !teamLibraryQueries.isEmpty, searchText: searchText, - isEnvironmentFiltered: sharedSidebarState.favoriteDatabaseEnvironmentFilter != .all + isEnvironmentFiltered: sharedSidebarState.favoriteDatabaseEnvironmentFilter != .all, + favoriteTablesInOtherDatabases: tableRead.resolution.otherDatabaseCount, + missingFavoriteTables: tableRead.resolution.missingCount )) { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) case .noFavorites: emptyState + case .favoriteTablesInOtherDatabases: + favoriteTablesElsewhereState(description: String( + format: String(localized: "Your favorite tables are in other %@. Switch to one to see them here."), + databaseEntityNamePlural.lowercased() + )) + case .favoriteTablesMissing: + favoriteTablesElsewhereState( + description: String(localized: "The tables you starred here no longer exist.") + ) case .noFilterMatch: noFilterMatchState case .noSearchMatch(let term): @@ -119,6 +145,9 @@ internal struct FavoritesTabView: View { } } } + .task(id: tableRead.loadRequest) { + reader.load(tableRead.loadRequest) + } .onAppear { viewModel.startWatchingLinkedFolders() favoriteTables = viewModel.favoriteTables(for: connectionId) @@ -284,7 +313,7 @@ internal struct FavoritesTabView: View { private func favoritesList( _ items: [FavoriteNode], databaseGroups: [FavoriteDatabaseGroup], - filteredTables: [TableInfo] + filteredTables: [FavoriteTableRow] ) -> some View { FavoritesOutlineView( input: FavoritesOutlineInput( @@ -352,8 +381,8 @@ internal struct FavoritesTabView: View { databaseEnvironmentRow(group) case .database(let entry): favoriteDatabaseRow(entry) - case .table(let table): - favoriteTableRow(table: table) + case .table(let row): + favoriteTableRow(row) case .query(let favoriteNode): favoriteQueryRow(favoriteNode) case .teamQuery(_, let name, let publishedBy): @@ -429,35 +458,53 @@ internal struct FavoritesTabView: View { } } - private func favoriteTableRow(table: TableInfo) -> some View { + private func favoriteTableRow(_ row: FavoriteTableRow) -> some View { Label { - Text(table.name) + HStack(spacing: 6) { + Text(row.entry.name) + .lineLimit(1) + .layoutPriority(1) + if let schema = row.otherSchema { + Text(schema) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } } icon: { - Image(systemName: TableRowLogic.iconName(for: table.type)) + Image(systemName: TableRowLogic.iconName(for: row.table.type)) .selectionAwareTint(Color.accentColor) } .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityLabel( - TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) + [ + TableRowLogic.accessibilityLabel(table: row.table, isPendingDelete: false, isPendingTruncate: false), + row.otherSchema + ] + .compactMap { $0 } + .joined(separator: ", ") ) } - @ViewBuilder - private func favoriteTableContextMenu(_ table: TableInfo) -> some View { - Button(String(localized: "Open Table")) { - coordinator?.openTableTab(table, forceNonPreview: true, activateGridFocus: true) - } - - Button(String(localized: "Show ER Diagram")) { - coordinator?.showERDiagram() - } - - Divider() - - Button(role: .destructive) { - FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId) - } label: { - Text(String(localized: "Remove from Favorites")) + private func openFavoriteTable(_ row: FavoriteTableRow) { + let reader = favoriteTableReader + let scope = favoriteTableScope + let host = coordinator + tableOpenTask?.cancel() + tableOpenTask = Task { [weak host] in + guard let resolved = await reader.rowForOpening(row.entry, scope: scope), + !Task.isCancelled, + let host, + !host.isTearingDown else { return } + host.openTableTab( + resolved.entry.name, + schema: resolved.entry.schema, + database: resolved.entry.database, + isView: resolved.opensReadOnly, + objectType: resolved.verifiedType, + forceNonPreview: true, + activateGridFocus: true + ) } } @@ -472,8 +519,8 @@ internal struct FavoritesTabView: View { break case .database(let entry): useDatabase(entry) - case .table(let table): - coordinator?.openTableTab(table, forceNonPreview: true, activateGridFocus: true) + case .table(let row): + openFavoriteTable(row) case .query(let node): switch node.content { case .favorite(let favorite): @@ -499,10 +546,8 @@ internal struct FavoritesTabView: View { database: entry.database, connectionId: connectionId ) - case .table(let table): - FavoriteTablesStorage.shared.removeFavorite( - name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId - ) + case .table(let row): + viewModel.removeTableFavorite(row.entry) case .query(let node): switch node.content { case .favorite(let favorite): @@ -535,12 +580,12 @@ internal struct FavoritesTabView: View { database: entry.database, connectionId: connectionId ) - case .openTable(let table): - coordinator?.openTableTab(table, forceNonPreview: true, activateGridFocus: true) - case .showERDiagram: - coordinator?.showERDiagram() - case .removeTableFavorite(let table): - viewModel.removeTableFavorite(table, database: activeDatabase) + case .openTable(let row): + openFavoriteTable(row) + case .showERDiagram(let schema): + coordinator?.showERDiagram(schema: schema) + case .removeTableFavorite(let entry): + viewModel.removeTableFavorite(entry) case .insertFavorite(let favorite): coordinator?.insertFavorite(favorite) case .runFavoriteInNewTab(let favorite): @@ -637,21 +682,39 @@ internal struct FavoritesTabView: View { } description: { Text("Save frequently used queries, or link a folder of .sql files to share with your team.") } actions: { - VStack(spacing: 8) { - Button(String(localized: "New Favorite…")) { - viewModel.createFavorite() - } - Button(String(localized: "New Folder")) { - viewModel.createFolder() - } - Button(String(localized: "Link a Folder…")) { - addLinkedFolder() - } - } + emptyStateActions + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func favoriteTablesElsewhereState(description: String) -> some View { + UnavailableStateView { + Label( + String(format: String(localized: "No Favorites in This %@"), databaseEntityName), + systemImage: "star" + ) + } description: { + Text(description) + } actions: { + emptyStateActions } .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var emptyStateActions: some View { + VStack(spacing: 8) { + Button(String(localized: "New Favorite…")) { + viewModel.createFavorite() + } + Button(String(localized: "New Folder")) { + viewModel.createFolder() + } + Button(String(localized: "Link a Folder…")) { + addLinkedFolder() + } + } + } + private func noSearchMatchState(_ term: String) -> some View { UnavailableStateView.search(text: term) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift index 14c43940c..8efed3554 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift @@ -15,9 +15,9 @@ internal enum FavoritesMenuCommand: Equatable { case setDatabaseEnvironment(FavoriteDatabaseEntry, FavoriteDatabaseEnvironment) case removeDatabaseFavorite(FavoriteDatabaseEntry) - case openTable(TableInfo) - case showERDiagram - case removeTableFavorite(TableInfo) + case openTable(FavoriteTableRow) + case showERDiagram(schema: String?) + case removeTableFavorite(FavoriteTablesStorage.FavoriteEntry) case insertFavorite(SQLFavorite) case runFavoriteInNewTab(SQLFavorite) diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift index 4ca256b55..383df5780 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift @@ -39,8 +39,8 @@ internal enum FavoritesMenuSpec { return backgroundSections(context) case .database(let entry): return databaseSections(entry, context: context) - case .table(let table): - return tableSections(table) + case .table(let row): + return tableSections(row, context: context) case .query(let node): return querySections(node, context: context) case .header, .teamQuery: @@ -83,18 +83,27 @@ internal enum FavoritesMenuSpec { /// Spelled as the Database menu and the object tree spell it. It read "Show ER Diagram" here /// and "View ER Diagram" everywhere else, which is one command reading as two. - private static func tableSections(_ table: TableInfo) -> [FavoritesMenuSection] { - [ - FavoritesMenuSection([ - .command(String(localized: "Open Table"), .openTable(table)), - .command(String(localized: "View ER Diagram"), .showERDiagram) - ]), + private static func tableSections( + _ row: FavoriteTableRow, + context: FavoritesMenuContext + ) -> [FavoritesMenuSection] { + var opens: [FavoritesMenuItem] = [.command(String(localized: "Open Table"), .openTable(row))] + if canShowDiagram(ofSchema: row.otherSchema, context: context) { + opens.append(.command(String(localized: "View ER Diagram"), .showERDiagram(schema: row.otherSchema))) + } + return [ + FavoritesMenuSection(opens), FavoritesMenuSection([ - .command(String(localized: "Remove from Favorites"), .removeTableFavorite(table)) + .command(String(localized: "Remove from Favorites"), .removeTableFavorite(row.entry)) ]) ] } + private static func canShowDiagram(ofSchema schema: String?, context: FavoritesMenuContext) -> Bool { + guard let schema else { return true } + return ERDiagramViewModel.schemaKeyPreserves(schema, databaseName: context.activeDatabase ?? "") + } + private static func querySections( _ node: FavoriteNode, context: FavoritesMenuContext diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index bbcf32d05..f4db9f4ba 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -145,7 +145,6 @@ struct SidebarView: View { connectionId: connectionId, databaseType: viewModel.databaseType, sharedSidebarState: sidebarState, - tables: tables, coordinator: coordinator ) } else { diff --git a/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift b/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift index 8084c6439..26d78a148 100644 --- a/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift +++ b/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift @@ -56,6 +56,21 @@ struct ERDiagramSchemaKeyTests { #expect(ERDiagramViewModel.resolveSchemaName(fromSchemaKey: ".public", databaseName: "") == nil) } + @Test("A built key reads back as the schema it was built for") + func builtKeyRoundTrips() { + let key = ERDiagramViewModel.schemaKey(databaseName: "app", schema: "sales") + #expect(key == "app.sales") + #expect(ERDiagramViewModel.resolveSchemaName(fromSchemaKey: key, databaseName: "app") == "sales") + #expect(ERDiagramViewModel.schemaKey(databaseName: "app", schema: nil) == "app.default") + } + + @Test("A key keeps a schema only when a database is named and the schema is not the marker") + func keyPreservesSchema() { + #expect(ERDiagramViewModel.schemaKeyPreserves("sales", databaseName: "app")) + #expect(!ERDiagramViewModel.schemaKeyPreserves("sales", databaseName: "")) + #expect(!ERDiagramViewModel.schemaKeyPreserves("default", databaseName: "app")) + } + @Test("The view model binds to the schema its key names") func viewModelBindsSchema() { let viewModel = ERDiagramViewModel( diff --git a/TableProTests/Views/Sidebar/FavoriteTableCatalogReaderTests.swift b/TableProTests/Views/Sidebar/FavoriteTableCatalogReaderTests.swift new file mode 100644 index 000000000..be97ef341 --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoriteTableCatalogReaderTests.swift @@ -0,0 +1,287 @@ +// +// FavoriteTableCatalogReaderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class CatalogStubDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var schemas: [String] = [] + var browsedTables: [TableInfo] = [] + var tablesBySchema: [String: [TableInfo]] = [:] + + init(connection: DatabaseConnection) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { .empty } + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { .empty } + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { .empty } + + func fetchSchemas() async throws -> [String] { schemas } + func fetchTables() async throws -> [TableInfo] { browsedTables } + func fetchTables(schema: String?) async throws -> [TableInfo] { tablesBySchema[schema ?? ""] ?? [] } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} + +@MainActor +struct FavoriteTableCatalogReaderTests { + private let connectionId = UUID() + + private func table(_ name: String, schema: String?, type: TableInfo.TableType = .table) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: schema) + } + + private func entry(_ name: String, schema: String?, database: String?) -> FavoriteTablesStorage.FavoriteEntry { + FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + } + + private func reader( + _ service: SchemaService, + type: DatabaseType, + isConnected: Bool = false + ) -> FavoriteTableCatalogReader { + FavoriteTableCatalogReader( + connectionId: connectionId, + grouping: PluginManager.shared.databaseGroupingStrategy(for: type), + isConnected: isConnected, + schemaService: service + ) + } + + private func scope(database: String?, schema: String?, type: DatabaseType) -> FavoriteTableBrowseScope { + FavoriteTableBrowseScope( + database: database, + schema: schema, + listsTablesPerSchema: DatabaseTreeMetadataService.listsTablesPerSchema( + PluginManager.shared.databaseGroupingStrategy(for: type) + ) + ) + } + + /// Oracle-class engines load no flat list at all: every table lives in its own schema's list. + private func hierarchicalService(loadedDatabase: String) async -> SchemaService { + let service = SchemaService() + let connection = TestFixtures.makeConnection(id: connectionId, database: loadedDatabase, type: .bigQuery) + let driver = CatalogStubDriver(connection: connection) + driver.schemas = ["HR", "SALES", "FINANCE"] + driver.tablesBySchema = [ + "SALES": [table("ORDERS", schema: "SALES", type: .view), table("CUSTOMERS", schema: "SALES")] + ] + let loadedScope = DatabaseScope(connectionId: connectionId, database: loadedDatabase, schema: "HR") + await service.load(connectionId: connectionId, driver: driver, connection: connection, scope: loadedScope) + await service.loadSchemaObjects(schema: "SALES", in: loadedScope, driver: driver) + return service + } + + @Test("A hierarchical engine lists a favorite from the schema's own list, with an empty flat list") + func hierarchicalFavoriteIsListed() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + #expect(service.tables(for: connectionId).isEmpty) + + let read = reader(service, type: .bigQuery).read( + [entry("ORDERS", schema: "SALES", database: "PROD")], + scope: scope(database: "PROD", schema: "HR", type: .bigQuery), + search: SidebarSearch("") + ) + + #expect(read.resolution.rows.map(\.entry.name) == ["ORDERS"]) + #expect(read.resolution.rows.first?.isVerified == true) + #expect(read.resolution.rows.first?.knownType == .view) + #expect(read.resolution.rows.first?.otherSchema == "SALES") + } + + @Test("A hierarchical favorite that its schema's current list lacks is hidden") + func hierarchicalMissingFavoriteIsHidden() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + + let read = reader(service, type: .bigQuery).read( + [entry("REFUNDS", schema: "SALES", database: "PROD")], + scope: scope(database: "PROD", schema: "HR", type: .bigQuery), + search: SidebarSearch("") + ) + + #expect(read.resolution.rows.isEmpty) + #expect(read.resolution.missingCount == 1) + } + + @Test("A favorite in a schema nothing loaded is asked for, and one in a loaded schema is not") + func unloadedSchemaIsRequested() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + + let read = reader(service, type: .bigQuery, isConnected: true).read( + [entry("ORDERS", schema: "SALES", database: "PROD"), entry("LEDGER", schema: "FINANCE", database: "PROD")], + scope: scope(database: "PROD", schema: "HR", type: .bigQuery), + search: SidebarSearch("") + ) + + #expect(read.loadRequest == FavoriteTableLoadRequest(database: "PROD", schemas: ["FINANCE"])) + #expect(read.resolution.rows.map(\.entry.name) == ["LEDGER", "ORDERS"]) + } + + @Test("Nothing is asked for while the session is not connected") + func disconnectedRequestsNothing() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + + let read = reader(service, type: .bigQuery, isConnected: false).read( + [entry("LEDGER", schema: "FINANCE", database: "PROD")], + scope: scope(database: "PROD", schema: "HR", type: .bigQuery), + search: SidebarSearch("") + ) + + #expect(read.loadRequest == .none) + } + + /// While a database switch settles, the schema service still answers for the database being + /// left. Read as the browsed one, its SALES list would vouch for, or hide, tables it never saw. + @Test("The schema service's lists are refused while they describe another database") + func databaseSwitchInFlightIsRefused() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + + let read = reader(service, type: .bigQuery, isConnected: true).read( + [entry("ORDERS", schema: "SALES", database: "STAGING"), entry("REFUNDS", schema: "SALES", database: "STAGING")], + scope: scope(database: "STAGING", schema: "HR", type: .bigQuery), + search: SidebarSearch("") + ) + + #expect(read.resolution.rows.map(\.entry.name) == ["ORDERS", "REFUNDS"]) + #expect(read.resolution.rows.allSatisfy { !$0.isVerified && $0.knownType == nil }) + #expect(read.resolution.missingCount == 0) + #expect(read.loadRequest == .none) + } + + @Test("Opening a listed favorite carries its kind, and a missing one opens nothing") + func openingUsesTheListedKind() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + let opener = reader(service, type: .bigQuery) + let browsing = scope(database: "PROD", schema: "HR", type: .bigQuery) + + let listed = await opener.rowForOpening(entry("ORDERS", schema: "SALES", database: "PROD"), scope: browsing) + let missing = await opener.rowForOpening(entry("REFUNDS", schema: "SALES", database: "PROD"), scope: browsing) + + #expect(listed?.knownType == .view) + #expect(listed?.opensReadOnly == true) + #expect(missing == nil) + } + + @Test("A favorite whose kind cannot be learned opens read-only") + func unlearnableKindOpensReadOnly() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + + let row = await reader(service, type: .bigQuery, isConnected: false).rowForOpening( + entry("LEDGER", schema: "FINANCE", database: "PROD"), + scope: scope(database: "PROD", schema: "HR", type: .bigQuery) + ) + + #expect(row?.knownType == nil) + #expect(row?.opensReadOnly == true) + } + + @Test("A kind only a stale list vouches for never opens editable") + func staleKindOpensReadOnly() async { + let service = await hierarchicalService(loadedDatabase: "PROD") + service.markLoadedSchemaObjectsStale(connectionId: connectionId) + + let row = await reader(service, type: .bigQuery, isConnected: false).rowForOpening( + entry("CUSTOMERS", schema: "SALES", database: "PROD"), + scope: scope(database: "PROD", schema: "HR", type: .bigQuery) + ) + + #expect(row?.knownType == .table) + #expect(row?.isVerified == false) + #expect(row?.opensReadOnly == true) + } + + @Test("A schema-grouped engine's flat list vouches for the browsed schema only") + func bySchemaFlatListCoversTheBrowsedSchema() async { + let service = SchemaService() + let connection = TestFixtures.makeConnection(id: connectionId, database: "shop", type: .postgresql) + let driver = CatalogStubDriver(connection: connection) + driver.browsedTables = [table("users", schema: "public")] + await service.load( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: DatabaseScope(connectionId: connectionId, database: "shop", schema: "public") + ) + + let read = reader(service, type: .postgresql, isConnected: true).read( + [ + entry("users", schema: "public", database: "shop"), + entry("sessions", schema: "public", database: "shop"), + entry("orders", schema: "sales", database: "shop") + ], + scope: scope(database: "shop", schema: "public", type: .postgresql), + search: SidebarSearch("") + ) + + #expect(read.resolution.rows.map(\.entry.name) == ["orders", "users"]) + #expect(read.resolution.rows.first { $0.entry.name == "users" }?.isVerified == true) + #expect(read.resolution.rows.first { $0.entry.name == "orders" }?.isVerified == false) + #expect(read.resolution.missingCount == 1) + #expect(read.loadRequest == FavoriteTableLoadRequest(database: "shop", schemas: ["sales"])) + } + + @Test("A flat engine's table list decides for the whole database and asks for nothing") + func flatEngineListDecides() async { + let service = SchemaService() + let connection = TestFixtures.makeConnection(id: connectionId, database: "shop", type: .mysql) + let driver = CatalogStubDriver(connection: connection) + driver.browsedTables = [table("users", schema: nil)] + await service.load( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + ) + + let read = reader(service, type: .mysql, isConnected: true).read( + [entry("users", schema: nil, database: "shop"), entry("gone", schema: nil, database: "shop")], + scope: scope(database: "shop", schema: nil, type: .mysql), + search: SidebarSearch("") + ) + + #expect(read.resolution.rows.map(\.entry.name) == ["users"]) + #expect(read.resolution.missingCount == 1) + #expect(read.loadRequest == .none) + } +} diff --git a/TableProTests/Views/Sidebar/FavoriteTableResolverTests.swift b/TableProTests/Views/Sidebar/FavoriteTableResolverTests.swift new file mode 100644 index 000000000..099ed7a1b --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoriteTableResolverTests.swift @@ -0,0 +1,323 @@ +// +// FavoriteTableResolverTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct FavoriteTableResolverTests { + private let connectionId = UUID() + + private func entry(_ name: String, schema: String?, database: String? = "shop") -> FavoriteTablesStorage.FavoriteEntry { + FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + } + + private func table(_ name: String, schema: String?, type: TableInfo.TableType = .table) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: schema) + } + + private func source( + _ tables: [TableInfo], + schemas: Set, + isCurrent: Bool = true + ) -> FavoriteTableCatalog.Source { + FavoriteTableCatalog.Source(tables: tables, coverage: .schemas(schemas), isCurrent: isCurrent) + } + + private func schemaScope(browsing schema: String? = "public") -> FavoriteTableBrowseScope { + FavoriteTableBrowseScope(database: "shop", schema: schema, listsTablesPerSchema: true) + } + + private func resolve( + _ entries: [FavoriteTablesStorage.FavoriteEntry], + scope: FavoriteTableBrowseScope? = nil, + sources: [FavoriteTableCatalog.Source], + search: String = "" + ) -> FavoriteTableResolution { + FavoriteTableResolver.resolve( + entries, + scope: scope ?? schemaScope(), + catalog: FavoriteTableCatalog(sources: sources), + search: SidebarSearch(search) + ) + } + + @Test("A favorite in another schema that a current list holds is listed, verified, with its schema") + func otherSchemaFavoriteIsListed() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [ + source([table("users", schema: "public")], schemas: ["public"]), + source([table("orders", schema: "sales")], schemas: ["sales"]) + ] + ) + + #expect(resolution.rows.count == 1) + #expect(resolution.rows.first?.isVerified == true) + #expect(resolution.rows.first?.otherSchema == "sales") + #expect(resolution.missingCount == 0) + } + + @Test("A favorite in the browsed schema carries no schema caption") + func browsedSchemaFavoriteHasNoCaption() { + let resolution = resolve( + [entry("users", schema: "public")], + sources: [source([table("users", schema: "public")], schemas: ["public"])] + ) + + #expect(resolution.rows.first?.otherSchema == nil) + } + + @Test("A hierarchical engine lists a favorite from its own schema's list alone") + func hierarchicalSchemaListIsEnough() { + let resolution = resolve( + [entry("EMPLOYEES", schema: "HR", database: nil)], + scope: FavoriteTableBrowseScope(database: nil, schema: "SALES", listsTablesPerSchema: true), + sources: [source([table("EMPLOYEES", schema: "HR")], schemas: ["HR"])] + ) + + #expect(resolution.rows.map(\.entry.name) == ["EMPLOYEES"]) + #expect(resolution.rows.first?.isVerified == true) + } + + @Test("A favorite whose schema nothing has listed stays on the list, unverified") + func unlistedSchemaKeepsTheFavorite() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [source([table("users", schema: "public")], schemas: ["public"])] + ) + + #expect(resolution.rows.count == 1) + #expect(resolution.rows.first?.isVerified == false) + #expect(resolution.rows.first?.knownType == nil) + #expect(resolution.rows.first?.table.type == .table) + } + + @Test("A current list for the favorite's schema that lacks the table hides it") + func currentListWithoutTheTableHidesIt() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [source([table("invoices", schema: "sales")], schemas: ["sales"])] + ) + + #expect(resolution.rows.isEmpty) + #expect(resolution.missingCount == 1) + } + + @Test("A current list that lacks the table hides it even when a stale listing still holds it") + func currentListOverridesAStaleListing() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [ + source([table("orders", schema: "sales")], schemas: ["sales"], isCurrent: false), + source([table("invoices", schema: "sales")], schemas: ["sales"]) + ] + ) + + #expect(resolution.rows.isEmpty) + #expect(resolution.missingCount == 1) + } + + @Test("A stale list without the table keeps the favorite, unverified") + func staleListWithoutTheTableKeepsIt() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [source([table("invoices", schema: "sales")], schemas: ["sales"], isCurrent: false)] + ) + + #expect(resolution.rows.count == 1) + #expect(resolution.rows.first?.isVerified == false) + } + + @Test("A stale list holding the table supplies its type to the unverified row") + func staleListSuppliesTheType() { + let resolution = resolve( + [entry("active_orders", schema: "sales")], + sources: [ + source([table("active_orders", schema: "sales", type: .view)], schemas: ["sales"], isCurrent: false) + ] + ) + + #expect(resolution.rows.first?.isVerified == false) + #expect(resolution.rows.first?.knownType == .view) + } + + @Test("A current list's type wins over a stale one") + func currentTypeWins() { + let resolution = resolve( + [entry("orders", schema: "sales")], + sources: [ + source([table("orders", schema: "sales", type: .view)], schemas: ["sales"], isCurrent: false), + source([table("orders", schema: "sales", type: .table)], schemas: ["sales"]) + ] + ) + + #expect(resolution.rows.first?.isVerified == true) + #expect(resolution.rows.first?.knownType == .table) + } + + @Test("Only a favorite a current list names as a writable kind opens editable") + func onlyVerifiedWritableKindsOpenEditable() { + let resolution = resolve( + [ + entry("orders", schema: "sales"), + entry("active_orders", schema: "sales"), + entry("stock", schema: "inventory"), + entry("audit", schema: "ops") + ], + sources: [ + source( + [table("orders", schema: "sales"), table("active_orders", schema: "sales", type: .view)], + schemas: ["sales"] + ), + source([table("stock", schema: "inventory")], schemas: ["inventory"], isCurrent: false) + ] + ) + let opensReadOnly = Dictionary(uniqueKeysWithValues: resolution.rows.map { ($0.entry.name, $0.opensReadOnly) }) + + #expect(opensReadOnly == ["orders": false, "active_orders": true, "stock": true, "audit": true]) + #expect(resolution.rows.first { $0.entry.name == "stock" }?.verifiedType == nil) + } + + @Test("A favorite from another database is not listed, and is counted as elsewhere") + func otherDatabaseIsExcluded() { + let resolution = resolve( + [entry("orders", schema: "public", database: "warehouse")], + sources: [source([table("orders", schema: "public")], schemas: ["public"])] + ) + + #expect(resolution.rows.isEmpty) + #expect(resolution.otherDatabaseCount == 1) + #expect(resolution.missingCount == 0) + } + + @Test("The same table name in two schemas gives two rows with their own ids") + func sameNameInTwoSchemas() { + let resolution = resolve( + [entry("orders", schema: "public"), entry("orders", schema: "sales")], + sources: [ + source([table("orders", schema: "public")], schemas: ["public"]), + source([table("orders", schema: "sales")], schemas: ["sales"]) + ] + ) + + #expect(resolution.rows.count == 2) + #expect(Set(resolution.rows.map(\.id)).count == 2) + #expect(resolution.rows.map(\.entry.schema) == ["public", "sales"]) + } + + @Test("Rows sort by name, then schema") + func rowsSortByNameThenSchema() { + let resolution = resolve( + [entry("orders", schema: "sales"), entry("accounts", schema: "sales"), entry("orders", schema: "public")], + sources: [] + ) + + #expect(resolution.rows.map { "\($0.entry.schema ?? "").\($0.entry.name)" } == [ + "sales.accounts", "public.orders", "sales.orders" + ]) + } + + @Test("A system schema the all-schema listing leaves out is not decided by that listing") + func listingIsNotAuthorityForASystemSchema() { + let listing = source([table("users", schema: "public")], schemas: ["public"], isCurrent: false) + let resolution = resolve([entry("pg_stat_activity", schema: "pg_catalog")], sources: [listing]) + + #expect(resolution.rows.count == 1) + #expect(resolution.rows.first?.isVerified == false) + } + + @Test("A current list of a system schema decides for it like any other") + func currentSystemSchemaListDecides() { + let catalog = source([table("pg_class", schema: "pg_catalog")], schemas: ["pg_catalog"]) + + let listed = resolve([entry("pg_class", schema: "pg_catalog")], sources: [catalog]) + let missing = resolve([entry("pg_stat_activity", schema: "pg_catalog")], sources: [catalog]) + + #expect(listed.rows.first?.isVerified == true) + #expect(missing.rows.isEmpty) + } + + @Test("An engine without per-schema lists hides a table its whole-database list lacks") + func flatEngineListDecidesEverySchema() { + let scope = FavoriteTableBrowseScope(database: "shop", schema: nil, listsTablesPerSchema: false) + let flat = FavoriteTableCatalog.Source( + tables: [table("users", schema: nil)], + coverage: .everySchema, + isCurrent: true + ) + + let resolution = resolve( + [entry("users", schema: nil), entry("gone", schema: nil)], + scope: scope, + sources: [flat] + ) + + #expect(resolution.rows.map(\.entry.name) == ["users"]) + #expect(resolution.rows.first?.otherSchema == nil) + #expect(resolution.missingCount == 1) + } + + @Test("A plain search matches table names") + func plainSearchMatchesNames() { + let resolution = resolve( + [entry("orders", schema: "sales"), entry("users", schema: "public")], + sources: [], + search: "ord" + ) + + #expect(resolution.rows.map(\.entry.name) == ["orders"]) + } + + @Test("A qualified search matches the schema as well as the name") + func qualifiedSearchMatchesSchema() { + let entries = [entry("orders", schema: "sales"), entry("orders", schema: "public"), entry("refunds", schema: "sales")] + + let wholeSchema = resolve(entries, sources: [], search: "sales.") + let oneTable = resolve(entries, sources: [], search: "sales.ord") + + #expect(wholeSchema.rows.map(\.entry.name) == ["orders", "refunds"]) + #expect(wholeSchema.rows.allSatisfy { $0.entry.schema == "sales" }) + #expect(oneTable.rows.map(\.id) == [entry("orders", schema: "sales").id]) + } + + @Test("Only favorite schemas that no source has listed are asked for") + func schemasNeedingLoadListsUncoveredSchemas() { + let entries = [ + entry("users", schema: "public"), + entry("orders", schema: "sales"), + entry("stock", schema: "inventory"), + entry("staged", schema: "etl", database: "warehouse") + ] + let catalog = FavoriteTableCatalog(sources: [ + source([table("users", schema: "public")], schemas: ["public"]), + source([table("orders", schema: "sales")], schemas: ["sales"], isCurrent: false) + ]) + + let needed = FavoriteTableResolver.schemasNeedingLoad(entries, scope: schemaScope(), catalog: catalog) + + #expect(needed == ["inventory"]) + } + + @Test("An engine without per-schema lists never asks for a schema") + func flatEngineNeedsNoLoad() { + let scope = FavoriteTableBrowseScope(database: "shop", schema: nil, listsTablesPerSchema: false) + + let needed = FavoriteTableResolver.schemasNeedingLoad( + [entry("orders", schema: "sales")], + scope: scope, + catalog: .empty + ) + + #expect(needed.isEmpty) + } +} + +private extension FavoriteTablesStorage.FavoriteEntry { + var id: String { + FavoritesOutlineNode.tableId(database: database, schema: schema, name: name) + } +} diff --git a/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift b/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift index 08d9e0b00..938726d22 100644 --- a/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift @@ -14,14 +14,18 @@ struct FavoritesEmptyStateTests { hasAnyFavorite: Bool = true, hasVisibleContent: Bool = false, searchText: String = "", - isEnvironmentFiltered: Bool = false + isEnvironmentFiltered: Bool = false, + favoriteTablesInOtherDatabases: Int = 0, + missingFavoriteTables: Int = 0 ) -> FavoritesEmptyState.Input { FavoritesEmptyState.Input( isInitialLoadComplete: isInitialLoadComplete, hasAnyFavorite: hasAnyFavorite, hasVisibleContent: hasVisibleContent, searchText: searchText, - isEnvironmentFiltered: isEnvironmentFiltered + isEnvironmentFiltered: isEnvironmentFiltered, + favoriteTablesInOtherDatabases: favoriteTablesInOtherDatabases, + missingFavoriteTables: missingFavoriteTables ) } @@ -78,6 +82,43 @@ struct FavoritesEmptyStateTests { #expect(FavoritesEmptyState.resolve(input()) == .noFavorites) } + @Test("Favorite tables that all live in other databases say so, not the onboarding state") + func favoriteTablesElsewhere() { + #expect( + FavoritesEmptyState.resolve(input(favoriteTablesInOtherDatabases: 2)) + == .favoriteTablesInOtherDatabases + ) + } + + @Test("Favorite tables whose tables are all gone say so, not the onboarding state") + func favoriteTablesMissing() { + #expect(FavoritesEmptyState.resolve(input(missingFavoriteTables: 1)) == .favoriteTablesMissing) + } + + @Test("Favorites in other databases outrank missing ones") + func otherDatabasesOutrankMissing() { + #expect( + FavoritesEmptyState.resolve(input(favoriteTablesInOtherDatabases: 1, missingFavoriteTables: 3)) + == .favoriteTablesInOtherDatabases + ) + } + + @Test("A search that matches nothing stays a search miss while favorites sit elsewhere") + func searchMissWinsOverElsewhere() { + #expect( + FavoritesEmptyState.resolve(input(searchText: "orders", favoriteTablesInOtherDatabases: 1)) + == .noSearchMatch("orders") + ) + } + + @Test("Visible content wins over favorites elsewhere") + func contentWinsOverElsewhere() { + #expect( + FavoritesEmptyState.resolve(input(hasVisibleContent: true, favoriteTablesInOtherDatabases: 1)) + == .content + ) + } + // MARK: - hasAnyFavorite /// The question is whether the user owns a favorite at all. A list the filter field or the diff --git a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift index 22af93c36..e66dd8f62 100644 --- a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift @@ -13,12 +13,14 @@ struct FavoritesMenuSpecTests { private func context( clicked: FavoritesOutlineNode.Kind?, allFolders: [SQLFavoriteFolder] = [], - teamLibraryAvailable: Bool = false + teamLibraryAvailable: Bool = false, + activeDatabase: String? = "shop" ) -> FavoritesMenuContext { FavoritesMenuContext( clicked: clicked, allFolders: allFolders, - teamLibraryAvailable: teamLibraryAvailable + teamLibraryAvailable: teamLibraryAvailable, + activeDatabase: activeDatabase ) } @@ -61,8 +63,15 @@ struct FavoritesMenuSpecTests { } } - private func table() -> TableInfo { - TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public") + private func table(schema: String = "public", otherSchema: String? = nil) -> FavoriteTableRow { + FavoriteTableRow( + entry: FavoriteTablesStorage.FavoriteEntry( + connectionId: UUID(), database: "shop", schema: schema, name: "orders" + ), + listedTable: TableInfo(name: "orders", type: .table, rowCount: nil, schema: schema), + isVerified: true, + otherSchema: otherSchema + ) } private func database() -> FavoriteDatabaseEntry { @@ -120,6 +129,42 @@ struct FavoritesMenuSpecTests { } } + @Test("A table row opens, diagrams and removes the favorite it was built from") + func tableRowCommandsCarryTheRow() { + let row = table() + let issued = commands(FavoritesMenuSpec.sections(for: context(clicked: .table(row)))) + + #expect(issued == [.openTable(row), .showERDiagram(schema: nil), .removeTableFavorite(row.entry)]) + } + + @Test("A table outside the browsed schema diagrams its own schema") + func otherSchemaRowDiagramsItsSchema() { + let row = table(schema: "sales", otherSchema: "sales") + let issued = commands(FavoritesMenuSpec.sections(for: context(clicked: .table(row)))) + + #expect(issued.contains(.showERDiagram(schema: "sales"))) + #expect(issued.contains(.removeTableFavorite(row.entry))) + } + + @Test("A diagram the tab could not tell apart from the browsed schema's is not offered") + func unrepresentableDiagramIsNotOffered() { + let noDatabase = commands(FavoritesMenuSpec.sections(for: context( + clicked: .table(table(schema: "sales", otherSchema: "sales")), + activeDatabase: nil + ))) + let markerSchema = commands(FavoritesMenuSpec.sections(for: context( + clicked: .table(table(schema: "default", otherSchema: "default")) + ))) + let browsedSchema = commands(FavoritesMenuSpec.sections(for: context( + clicked: .table(table()), + activeDatabase: nil + ))) + + #expect(!noDatabase.contains { if case .showERDiagram = $0 { true } else { false } }) + #expect(!markerSchema.contains { if case .showERDiagram = $0 { true } else { false } }) + #expect(browsedSchema.contains(.showERDiagram(schema: nil))) + } + /// These moved out of the bar at the bottom of the sidebar. @Test("The empty area carries the commands the bottom bar used to") func backgroundOffersCreation() { diff --git a/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift index b9e5b6625..07d0e49b0 100644 --- a/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift @@ -11,8 +11,13 @@ import Testing @MainActor struct FavoritesOutlineSelectionTests { - private func table(_ name: String, schema: String? = "public") -> TableInfo { - TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + private func table(_ name: String, schema: String? = "public") -> FavoriteTableRow { + FavoriteTableRow( + entry: FavoriteTablesStorage.FavoriteEntry(connectionId: UUID(), database: "app", schema: schema, name: name), + listedTable: TableInfo(name: name, type: .table, rowCount: nil, schema: schema), + isVerified: true, + otherSchema: nil + ) } private func favorite(_ name: String) -> SQLFavorite { @@ -38,20 +43,29 @@ struct FavoritesOutlineSelectionTests { @Test("A table row maps to the table selection the app persists") func tableMapsToSelection() { - let selection = FavoritesOutlineSelection.selection(for: .table(table("users")), database: "app") + let selection = FavoritesOutlineSelection.selection(for: .table(table("users"))) #expect(selection == .table(database: "app", schema: "public", name: "users")) } + @Test("A table row outside the browsed schema persists and restores to its own row") + func otherSchemaRowRoundTrips() { + let row = table("orders", schema: "sales") + let selection = FavoritesOutlineSelection.selection(for: .table(row)) + + #expect(selection == .table(database: "app", schema: "sales", name: "orders")) + #expect(selection.map(FavoritesOutlineSelection.nodeId(for:)) == row.id) + } + @Test("A saved query maps to its node id") func queryMapsToNodeId() { let node = FavoriteNode.favorite(favorite("daily")) - #expect(FavoritesOutlineSelection.selection(for: .query(node), database: nil) == .node(id: node.id)) + #expect(FavoritesOutlineSelection.selection(for: .query(node)) == .node(id: node.id)) } @Test("A database row maps to its stable node id") func databaseMapsToNodeId() { let entry = database("analytics") - let selection = FavoritesOutlineSelection.selection(for: .database(entry), database: nil) + let selection = FavoritesOutlineSelection.selection(for: .database(entry)) #expect(selection == .node(id: FavoritesOutlineNode.databaseId(entry))) } @@ -83,17 +97,17 @@ struct FavoritesOutlineSelectionTests { @Test("A Team Library row maps to a selection of its own") func teamQueryMapsToSelection() { let selection = FavoritesOutlineSelection.selection( - for: .teamQuery(id: "abc", name: "Shared", publishedBy: "sam"), database: nil + for: .teamQuery(id: "abc", name: "Shared", publishedBy: "sam") ) #expect(selection == .node(id: FavoritesOutlineNode.teamQueryId("abc"))) } @Test("A header maps to no selection") func headerMapsToNothing() { - #expect(FavoritesOutlineSelection.selection(for: .header("Queries"), database: "app") == nil) + #expect(FavoritesOutlineSelection.selection(for: .header("Queries")) == nil) } - /// Restoring a persisted selection must find the same row again without a live TableInfo. + /// Restoring a persisted selection must find the same row again without a live row. @Test("A persisted selection round-trips to the row id") func selectionRoundTripsToNodeId() { let selection = FavoriteSelection.table(database: "app", schema: "public", name: "users") diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 9c775fcdb..aad7ddaac 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -34,9 +34,11 @@ Database favorites belong to the connection, and go when you delete it. A droppe Hover a table row and click the star at its trailing edge, or right-click and choose **Add to Favorites**. A pinned row keeps its star, filled and yellow. -Pinned tables sit under **Tables** in the Favorites tab. Double-click, or press `Return`, to open one. Its right-click menu has **Open Table**, **View ER Diagram**, and **Remove from Favorites**; `Delete` removes the selection. +Pinned tables sit under **Tables** in the Favorites tab, from every schema of the database you are browsing. A table outside the schema you browse shows its schema beside its name. Double-click, or press `Return`, to open one in a tab of its own schema; the schema you browse stays where it is. Its right-click menu has **Open Table**, **View ER Diagram**, and **Remove from Favorites**; the diagram is of the table's own schema, and `Delete` removes the selection. -Table favorites are scoped to the connection, database, and schema, and sync through [iCloud](/features/icloud-sync). One whose table is missing from the database you are browsing is hidden rather than shown broken. +The search field matches table names. Add the schema to narrow by it: `sales.` lists every favorite in `sales`, and `sales.ord` the ones whose names contain `ord`. + +Table favorites are scoped to the connection, database, and schema, and sync through [iCloud](/features/icloud-sync). A favorite from another database is listed once you browse that database. One whose table no longer exists is hidden rather than shown broken. ## Recent tables