From 5907dfaf4689f0f036e573e4bc955e50d9f67ca0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 22:33:45 +0700 Subject: [PATCH 1/2] fix(datagrid): resolve default sort before dispatching the first table query (#1603) --- CHANGELOG.md | 4 + .../Services/Query/SchemaColumnStore.swift | 45 ++++ TablePro/Models/Query/QueryTabManager.swift | 1 - TablePro/Models/Query/QueryTabState.swift | 1 - TablePro/Resources/Localizable.xcstrings | 4 + ...nContentCoordinator+ColumnFetchScope.swift | 44 ++-- .../MainContentCoordinator+DefaultSort.swift | 65 ------ .../MainContentCoordinator+Navigation.swift | 9 +- ...ainContentCoordinator+TableFirstLoad.swift | 83 ++++++++ ...inContentCoordinator+WindowLifecycle.swift | 21 +- .../Extensions/MainContentView+Setup.swift | 2 - .../Views/Main/MainContentCoordinator.swift | 22 +- .../Settings/Sections/DataGridSection.swift | 7 +- .../Query/SchemaColumnStoreTests.swift | 97 +++++++++ .../Models/Query/DefaultSortStateTests.swift | 23 +- .../CoordinatorColumnVisibilityTests.swift | 12 +- .../Main/DefaultSortInitialQueryTests.swift | 199 ++++++++++++------ .../MainContentCoordinatorLazyLoadTests.swift | 2 +- 18 files changed, 431 insertions(+), 210 deletions(-) create mode 100644 TablePro/Core/Services/Query/SchemaColumnStore.swift delete mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+DefaultSort.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift create mode 100644 TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b61c7e5..e5514a6b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Default row sort now applies to the very first table opened after launch, not just tables opened after it. (#1603) + ## [0.49.1] - 2026-06-06 ### Fixed diff --git a/TablePro/Core/Services/Query/SchemaColumnStore.swift b/TablePro/Core/Services/Query/SchemaColumnStore.swift new file mode 100644 index 000000000..b2d2826a3 --- /dev/null +++ b/TablePro/Core/Services/Query/SchemaColumnStore.swift @@ -0,0 +1,45 @@ +import Foundation + +@MainActor +final class SchemaColumnStore { + typealias Entry = (columns: [String], primaryKeys: [String]) + + private var entries: [String: Entry] = [:] + private var loads: [String: Task] = [:] + private var generation = 0 + + func cached(_ key: String) -> Entry? { + entries[key] + } + + func store(_ entry: Entry, for key: String) { + entries[key] = entry + } + + func load(_ key: String, fetch: @escaping () async -> Entry?) async { + if entries[key] != nil { return } + if let inFlight = loads[key] { + await inFlight.value + return + } + + let task = Task { + if let entry = await fetch() { + self.entries[key] = entry + } + } + loads[key] = task + let startedGeneration = generation + await task.value + if generation == startedGeneration { + loads[key] = nil + } + } + + func removeAll() { + generation += 1 + for task in loads.values { task.cancel() } + loads.removeAll() + entries.removeAll() + } +} diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index bc5d4bed0..ff2ca8008 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -289,7 +289,6 @@ final class QueryTabManager { tab.execution.statusMessage = nil tab.execution.errorMessage = nil tab.execution.lastExecutedAt = nil - tab.execution.didEvaluateDefaultSort = false tab.display.resultsViewMode = .data tab.sortState = SortState() tab.selectedRowIndices = [] diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 9464d83e8..48dba093d 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -249,7 +249,6 @@ struct TabExecutionState: Equatable { var errorMessage: String? var rowsAffected: Int = 0 var lastExecutedAt: Date? - var didEvaluateDefaultSort: Bool = false static func == (lhs: TabExecutionState, rhs: TabExecutionState) -> Bool { lhs.isExecuting == rhs.isExecuting diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9af03fcf5..75cea15f9 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7558,6 +7558,7 @@ } }, "Applied when opening a table. Click a column header to override." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -19180,6 +19181,9 @@ } } } + }, + "Default row sort is applied when a table first opens. Click a column header to override it." : { + }, "Default row sort:" : { "localizations" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index c38997c43..4fa061389 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -13,7 +13,7 @@ extension MainContentCoordinator { guard tab.tabType == .table, let tableName = tab.tableContext.tableName, !tab.columnLayout.hiddenColumns.isEmpty, - let schema = schemaColumnsCache[schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)] else { return nil } + let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)) else { return nil } return ColumnFetchScope.selectColumns( schemaColumns: schema.columns, @@ -22,14 +22,6 @@ extension MainContentCoordinator { ) } - func executeSelectedTableTabQuery() { - if selectedTabHiddenColumns.isEmpty { - executeTableTabQueryDirectly() - } else { - requeryWithColumnScope() - } - } - func requeryWithColumnScope(debounced: Bool = false) { columnScopeRequeryTask?.cancel() columnScopeRequeryTask = Task { @MainActor [weak self] in @@ -56,50 +48,50 @@ extension MainContentCoordinator { func loadSchemaColumns(for tableName: String, schema: String?) async { let key = schemaColumnsKey(tableName, schema: schema) - guard schemaColumnsCache[key] == nil else { return } - do { - let columns = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in - try await driver.fetchColumns(table: tableName, schema: schema) - } - guard !columns.isEmpty else { - columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope") - return + await schemaColumns.load(key) { [services, connectionId] in + do { + let columns = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchColumns(table: tableName, schema: schema) + } + guard !columns.isEmpty else { + columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope") + return nil + } + return (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name)) + } catch { + columnScopeLog.error("loadSchemaColumns: fetchColumns failed for table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil } - schemaColumnsCache[key] = (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name)) - } catch { - columnScopeLog.error("loadSchemaColumns: fetchColumns failed for table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)") } } func columnsForVisibilityPicker(for tab: QueryTab, resultColumns: [String]) -> [String] { guard tab.tabType == .table, let tableName = tab.tableContext.tableName else { return resultColumns } - if let schema = schemaColumnsCache[schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)], !schema.columns.isEmpty { + if let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)), !schema.columns.isEmpty { return schema.columns } let missingHidden = tab.columnLayout.hiddenColumns.subtracting(resultColumns) return missingHidden.isEmpty ? resultColumns : resultColumns + missingHidden.sorted() } - /// Full schema columns for the selected table, if loaded. Used to prune stale - /// hidden entries against the schema rather than the scoped result. func selectedTabSchemaColumns() -> [String]? { guard let tab = tabManager.selectedTab, let tableName = tab.tableContext.tableName, - let schema = schemaColumnsCache[schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)], + let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)), !schema.columns.isEmpty else { return nil } return schema.columns } func cachedSchemaColumns(for tab: QueryTab) -> (columns: [String], primaryKeys: [String])? { guard let tableName = tab.tableContext.tableName else { return nil } - return schemaColumnsCache[schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)] + return schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)) } func effectiveResultColumns(for tab: QueryTab) -> [String] { selectColumns(for: tab) ?? cachedSchemaColumns(for: tab)?.columns ?? [] } - private func schemaColumnsKey(_ tableName: String, schema: String?) -> String { + func schemaColumnsKey(_ tableName: String, schema: String?) -> String { "\(connectionId):\(activeDatabaseName):\(schema ?? ""):\(tableName)" } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+DefaultSort.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+DefaultSort.swift deleted file mode 100644 index 12cdf9ef0..000000000 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+DefaultSort.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// MainContentCoordinator+DefaultSort.swift -// TablePro -// - -import Foundation -import TableProPluginKit - -extension MainContentCoordinator { - func shouldResolveDefaultSort(for tab: QueryTab) -> Bool { - guard tab.tabType == .table, - !tab.execution.didEvaluateDefaultSort, - !tab.sortState.isSorting, - let tableName = tab.tableContext.tableName, !tableName.isEmpty else { - return false - } - - switch PluginManager.shared.defaultSortHint(for: connection.type, table: tableName) { - case .suppress: - return false - case .forceColumns: - return true - case .useAppDefault: - return AppSettingsManager.shared.dataGrid.defaultSortBehavior != .none - } - } - - func resolveDefaultSortThenExecuteTableQuery(tabId: UUID) async { - guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let tableName = tab.tableContext.tableName else { return } - - await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) - - guard !Task.isCancelled, - let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let currentTab = tabManager.tabs[index] - - let resolved = DefaultSortResolver.resolveSortState( - behavior: AppSettingsManager.shared.dataGrid.defaultSortBehavior, - pluginHint: PluginManager.shared.defaultSortHint(for: connection.type, table: tableName), - primaryKeyColumns: resolvedPrimaryKeyColumns(for: currentTab), - allColumns: effectiveResultColumns(for: currentTab) - ) - - if resolved.isSorting { - tabManager.mutate(at: index) { tab in - tab.sortState = resolved - tab.pagination.reset() - } - filterCoordinator.rebuildTableQuery(at: index) - } - - runQuery() - } - - private func resolvedPrimaryKeyColumns(for tab: QueryTab) -> [String] { - if let pks = cachedSchemaColumns(for: tab)?.primaryKeys, !pks.isEmpty { - return pks - } - if let defaultPK = PluginManager.shared.defaultPrimaryKeyColumn(for: connection.type) { - return [defaultPK] - } - return [] - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 5e31cdefc..fbf1a012c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -232,10 +232,8 @@ extension MainContentCoordinator { restoreFiltersForTable(tableName) if isInPlace, let dbIndex = Int(currentDatabase) { selectRedisDatabaseAndQuery(dbIndex) - } else if !selectedTabHiddenColumns.isEmpty { - requeryWithColumnScope() } else { - runQuery() + lazyLoadCurrentTabIfNeeded() } } @@ -274,7 +272,10 @@ extension MainContentCoordinator { } restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) - executeSelectedTableTabQuery() + if let tabId = tabManager.selectedTab?.id { + cancelTableLoad(for: tabId) + } + lazyLoadCurrentTabIfNeeded() } // MARK: - Preview Tabs diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift new file mode 100644 index 000000000..d087acb78 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -0,0 +1,83 @@ +// +// MainContentCoordinator+TableFirstLoad.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension MainContentCoordinator { + func openTableTabQuery(tabId: UUID) async { + guard await prepareTableTabFirstLoad(tabId: tabId) else { return } + executeTableTabQueryDirectly() + } + + @discardableResult + func prepareTableTabFirstLoad(tabId: UUID) async -> Bool { + guard tabManager.selectedTabId == tabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }), + tab.tabType == .table, + let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return false } + + guard wantsDefaultSort(for: tab) || !tab.columnLayout.hiddenColumns.isEmpty else { return true } + + await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) + + guard !Task.isCancelled, + tabManager.selectedTabId == tabId, + let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), + tabManager.tabs[index].tableContext.tableName == tableName else { return false } + + let sortApplied = applyResolvedDefaultSort(at: index, tableName: tableName) + if sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty { + filterCoordinator.rebuildTableQuery(at: index) + } + return true + } + + func wantsDefaultSort(for tab: QueryTab) -> Bool { + guard tab.tabType == .table, + !tab.sortState.isSorting, + let tableName = tab.tableContext.tableName, !tableName.isEmpty else { + return false + } + + switch PluginManager.shared.defaultSortHint(for: connection.type, table: tableName) { + case .suppress: + return false + case .forceColumns: + return true + case .useAppDefault: + return AppSettingsManager.shared.dataGrid.defaultSortBehavior != .none + } + } + + private func applyResolvedDefaultSort(at index: Int, tableName: String) -> Bool { + let tab = tabManager.tabs[index] + guard wantsDefaultSort(for: tab) else { return false } + + let resolved = DefaultSortResolver.resolveSortState( + behavior: AppSettingsManager.shared.dataGrid.defaultSortBehavior, + pluginHint: PluginManager.shared.defaultSortHint(for: connection.type, table: tableName), + primaryKeyColumns: resolvedPrimaryKeyColumns(for: tab), + allColumns: effectiveResultColumns(for: tab) + ) + guard resolved.isSorting else { return false } + + tabManager.mutate(at: index) { + $0.sortState = resolved + $0.pagination.reset() + } + return true + } + + private func resolvedPrimaryKeyColumns(for tab: QueryTab) -> [String] { + if let pks = cachedSchemaColumns(for: tab)?.primaryKeys, !pks.isEmpty { + return pks + } + if let defaultPK = PluginManager.shared.defaultPrimaryKeyColumn(for: connection.type) { + return [defaultPK] + } + return [] + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index cab08a350..f023a59b8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -137,14 +137,25 @@ extension MainContentCoordinator { Self.lifecycleLogger.debug( "[switch] coordinator.lazyLoadCurrentTabIfNeeded executing tabId=\(tabId, privacy: .public)" ) - tableLoadTasks[tabId] = Task { @MainActor [weak self] in + let token = UUID() + let task = Task { @MainActor [weak self] in guard let self else { return } - defer { self.tableLoadTasks[tabId] = nil } - self.executeSelectedTableTabQuery() - if let task = self.currentQueryTask { - await task.value + defer { + if self.tableLoadTasks[tabId]?.token == token { + self.tableLoadTasks[tabId] = nil + } + } + await self.openTableTabQuery(tabId: tabId) + if let queryTask = self.currentQueryTask { + await queryTask.value } } + tableLoadTasks[tabId] = (token, task) + } + + func cancelTableLoad(for tabId: UUID) { + tableLoadTasks[tabId]?.task.cancel() + tableLoadTasks[tabId] = nil } private func canAutoLoadTableTab(_ tab: QueryTab) -> Bool { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 49307e58d..3fa11a196 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -67,8 +67,6 @@ extension MainContentView { selectedTab.tableContext.databaseName != session.activeDatabase { await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) - } else if coordinator.selectedTabHiddenColumns.isEmpty { - coordinator.runQuery() } else { coordinator.lazyLoadCurrentTabIfNeeded() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 03ddab63e..56b70220c 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -178,9 +178,8 @@ final class MainContentCoordinator { @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] - @ObservationIgnored var schemaColumnsCache: [String: (columns: [String], primaryKeys: [String])] = [:] + @ObservationIgnored let schemaColumns = SchemaColumnStore() @ObservationIgnored var columnScopeRequeryTask: Task? - @ObservationIgnored var defaultSortResolveTask: Task? @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] @@ -188,7 +187,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var queryGeneration: Int = 0 @ObservationIgnored internal var currentQueryTask: Task? - @ObservationIgnored internal var tableLoadTasks: [UUID: Task] = [:] + @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @@ -528,7 +527,7 @@ final class MainContentCoordinator { func refreshTables() async { guard let driver = services.databaseManager.driver(for: connectionId) else { return } - schemaColumnsCache.removeAll() + schemaColumns.removeAll() await services.schemaService.reload( connectionId: connectionId, driver: driver, @@ -642,7 +641,7 @@ final class MainContentCoordinator { fileWatcher = nil currentQueryTask?.cancel() currentQueryTask = nil - for task in tableLoadTasks.values { task.cancel() } + for entry in tableLoadTasks.values { entry.task.cancel() } tableLoadTasks.removeAll() changeManagerUpdateTask?.cancel() changeManagerUpdateTask = nil @@ -656,9 +655,8 @@ final class MainContentCoordinator { tabSessionRegistry.removeAll() querySortCache.removeAll() displayFormatsCache.removeAll() - schemaColumnsCache.removeAll() + schemaColumns.removeAll() columnScopeRequeryTask?.cancel() - defaultSortResolveTask?.cancel() tabManager.tabs.removeAll() tabManager.selectedTabId = nil @@ -855,16 +853,6 @@ final class MainContentCoordinator { guard let (tab, index) = tabManager.selectedTabAndIndex, !tab.execution.isExecuting else { return } - defaultSortResolveTask?.cancel() - if shouldResolveDefaultSort(for: tab) { - let tabId = tab.id - tabManager.mutate(at: index) { $0.execution.didEvaluateDefaultSort = true } - defaultSortResolveTask = Task { @MainActor [weak self] in - await self?.resolveDefaultSortThenExecuteTableQuery(tabId: tabId) - } - return - } - let sql = tab.content.query guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } diff --git a/TablePro/Views/Settings/Sections/DataGridSection.swift b/TablePro/Views/Settings/Sections/DataGridSection.swift index 87c935292..5f7acdaaa 100644 --- a/TablePro/Views/Settings/Sections/DataGridSection.swift +++ b/TablePro/Views/Settings/Sections/DataGridSection.swift @@ -9,7 +9,7 @@ struct DataGridSection: View { @Binding var settings: DataGridSettings var body: some View { - Section("Data Grid") { + Section { Picker("Row height:", selection: $settings.rowHeight) { ForEach(DataGridRowHeight.allCases) { height in Text(height.displayName).tag(height) @@ -43,7 +43,10 @@ struct DataGridSection: View { Text(behavior.displayName).tag(behavior) } } - .help(String(localized: "Applied when opening a table. Click a column header to override.")) + } header: { + Text("Data Grid") + } footer: { + Text("Default row sort is applied when a table first opens. Click a column header to override it.") } Section("Pagination") { diff --git a/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift b/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift new file mode 100644 index 000000000..773bd5bbe --- /dev/null +++ b/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("SchemaColumnStore") +@MainActor +struct SchemaColumnStoreTests { + @Test("load fetches once and caches the entry") + func loadFetchesOnceAndCaches() async { + let store = SchemaColumnStore() + var fetchCount = 0 + + await store.load("k") { + fetchCount += 1 + return (columns: ["id"], primaryKeys: ["id"]) + } + await store.load("k") { + fetchCount += 1 + return (columns: ["other"], primaryKeys: []) + } + + #expect(fetchCount == 1) + #expect(store.cached("k")?.columns == ["id"]) + } + + @Test("Concurrent loads for the same key share one fetch") + func concurrentLoadsShareOneFetch() async { + let store = SchemaColumnStore() + let counter = FetchCounter() + + async let first: Void = store.load("k") { + await counter.increment() + try? await Task.sleep(for: .milliseconds(50)) + return (columns: ["id"], primaryKeys: ["id"]) + } + async let second: Void = store.load("k") { + await counter.increment() + try? await Task.sleep(for: .milliseconds(50)) + return (columns: ["id"], primaryKeys: ["id"]) + } + _ = await (first, second) + + #expect(await counter.count == 1) + #expect(store.cached("k")?.columns == ["id"]) + } + + @Test("Failed fetch is not cached and the next load retries") + func failedFetchRetries() async { + let store = SchemaColumnStore() + var fetchCount = 0 + + await store.load("k") { + fetchCount += 1 + return nil + } + #expect(store.cached("k") == nil) + + await store.load("k") { + fetchCount += 1 + return (columns: ["id"], primaryKeys: []) + } + + #expect(fetchCount == 2) + #expect(store.cached("k")?.columns == ["id"]) + } + + @Test("removeAll clears entries and allows a fresh fetch") + func removeAllClearsAndRefetches() async { + let store = SchemaColumnStore() + await store.load("k") { (columns: ["old"], primaryKeys: []) } + + store.removeAll() + #expect(store.cached("k") == nil) + + await store.load("k") { (columns: ["new"], primaryKeys: []) } + #expect(store.cached("k")?.columns == ["new"]) + } + + @Test("store and cached round-trip") + func storeAndCachedRoundTrip() { + let store = SchemaColumnStore() + store.store((columns: ["a", "b"], primaryKeys: ["a"]), for: "k") + + #expect(store.cached("k")?.columns == ["a", "b"]) + #expect(store.cached("k")?.primaryKeys == ["a"]) + #expect(store.cached("missing") == nil) + } +} + +private actor FetchCounter { + private(set) var count = 0 + + func increment() { + count += 1 + } +} diff --git a/TableProTests/Models/Query/DefaultSortStateTests.swift b/TableProTests/Models/Query/DefaultSortStateTests.swift index c4d0311e1..bcd9df5c3 100644 --- a/TableProTests/Models/Query/DefaultSortStateTests.swift +++ b/TableProTests/Models/Query/DefaultSortStateTests.swift @@ -1,6 +1,7 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro @Suite("QueryTab.hasUserActiveSort") @@ -42,25 +43,9 @@ struct QueryTabHasUserActiveSortTests { } } -@Suite("QueryTabManager.replaceTabContent resets default-sort gate") +@Suite("QueryTabManager.replaceTabContent resets sort state") @MainActor struct ReplaceTabContentDefaultSortResetTests { - @Test("replaceTabContent clears didEvaluateDefaultSort") - func replaceClearsGate() throws { - let manager = QueryTabManager() - try manager.addTableTab(tableName: "users") - guard let index = manager.selectedTabIndex else { - Issue.record("selectedTabIndex was nil after addTableTab") - return - } - manager.mutate(at: index) { $0.execution.didEvaluateDefaultSort = true } - #expect(manager.tabs[index].execution.didEvaluateDefaultSort) - - try manager.replaceTabContent(tableName: "orders") - - #expect(!manager.tabs[index].execution.didEvaluateDefaultSort) - } - @Test("replaceTabContent clears sortState (back to .user default)") func replaceClearsSortState() throws { let manager = QueryTabManager() @@ -102,9 +87,9 @@ struct DataGridSettingsDefaultSortDecoderTests { "queryResultRowCap": 10000, "truncateQueryResults": true } - """.data(using: .utf8)! + """ - let settings = try JSONDecoder().decode(DataGridSettings.self, from: legacyJSON) + let settings = try JSONDecoder().decode(DataGridSettings.self, from: Data(legacyJSON.utf8)) #expect(settings.defaultSortBehavior == .none) } diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index 286ffa119..246f90bc9 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -122,9 +122,9 @@ struct CoordinatorColumnVisibilityTests { let (coordinator, tabManager) = makeCoordinator() _ = addTableTab(to: tabManager, tableName: "users") coordinator.hideAllColumns(["a", "b", "c", "d"]) - coordinator.schemaColumnsCache["\(coordinator.connectionId):\(coordinator.activeDatabaseName)::users"] = ( - columns: ["b", "d", "e"], - primaryKeys: [] + coordinator.schemaColumns.store( + (columns: ["b", "d", "e"], primaryKeys: []), + for: coordinator.schemaColumnsKey("users", schema: nil) ) coordinator.pruneHiddenColumns(currentColumns: ["b", "d", "e"]) @@ -178,9 +178,9 @@ struct CoordinatorColumnVisibilityTests { for: "users", connectionId: connection.id ) - coordinator.schemaColumnsCache["\(connection.id):db::users"] = ( - columns: ["id", "name", "email"], - primaryKeys: ["id"] + coordinator.schemaColumns.store( + (columns: ["id", "name", "email"], primaryKeys: ["id"]), + for: coordinator.schemaColumnsKey("users", schema: nil) ) coordinator.restoreLastHiddenColumnsForTable("users") diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index 208192ff3..e6c46e581 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -4,7 +4,7 @@ import Testing @testable import TablePro -@Suite("Default sort resolves before the first table result loads") +@Suite("Default sort resolves before the first table query is dispatched") @MainActor struct DefaultSortInitialQueryTests { private func makeCoordinator(tableName: String) -> (MainContentCoordinator, QueryTabManager, Int) { @@ -23,48 +23,132 @@ struct DefaultSortInitialQueryTests { return (coordinator, tabManager, tabManager.tabs.count - 1) } - private func schemaCacheKey(_ coordinator: MainContentCoordinator, table: String, schema: String? = nil) -> String { - "\(coordinator.connectionId):\(coordinator.activeDatabaseName):\(schema ?? ""):\(table)" + private func withDefaultSortBehavior( + _ behavior: DefaultSortBehavior, + body: () async -> Void + ) async { + let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior + AppSettingsManager.shared.dataGrid.defaultSortBehavior = behavior + defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } + await body() } - @Test("rebuildTableQuery emits ORDER BY from schema columns before any rows load") - func sortsFromSchemaColumnsBeforeFirstResult() { + @Test("prepareTableTabFirstLoad bakes the primary key ORDER BY into the first query") + func firstQueryContainsPrimaryKeyOrderBy() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - coordinator.schemaColumnsCache[schemaCacheKey(coordinator, table: "users")] = (["id", "name", "email"], ["id"]) + coordinator.schemaColumns.store( + (columns: ["id", "name", "email"], primaryKeys: ["id"]), + for: coordinator.schemaColumnsKey("users", schema: nil) + ) - tabManager.mutate(at: index) { - $0.sortState = SortState( - columns: [SortColumn(columnIndex: 0, direction: .ascending)], - source: .defaultSort - ) + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) } - coordinator.filterCoordinator.rebuildTableQuery(at: index) - let query = tabManager.tabs[index].content.query #expect(query.localizedCaseInsensitiveContains("ORDER BY")) #expect(query.contains("id")) + #expect(tabManager.tabs[index].sortState.source == .defaultSort) + } + + @Test("Composite primary key sorts by every key column in key order") + func compositePrimaryKeySortsAllKeyColumns() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "invoices") + coordinator.schemaColumns.store( + (columns: ["customer_uid", "order_uid", "total"], primaryKeys: ["customer_uid", "order_uid"]), + for: coordinator.schemaColumnsKey("invoices", schema: nil) + ) + + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + let sortState = tabManager.tabs[index].sortState + #expect(sortState.columns.map(\.columnIndex) == [0, 1]) + let query = tabManager.tabs[index].content.query + #expect(query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("customer_uid")) + #expect(query.contains("order_uid")) + } + + @Test("Table without a primary key dispatches unsorted with no ORDER BY") + func noPrimaryKeyProducesNoOrderBy() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "logs") + coordinator.schemaColumns.store( + (columns: ["message", "level"], primaryKeys: []), + for: coordinator.schemaColumnsKey("logs", schema: nil) + ) + let originalQuery = tabManager.tabs[index].content.query + + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } + + #expect(tabManager.tabs[index].content.query == originalQuery) + #expect(!tabManager.tabs[index].sortState.isSorting) + } + + @Test("Schema fetch failure dispatches unsorted instead of blocking the first load") + func schemaFetchFailureStillDispatches() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + let originalQuery = tabManager.tabs[index].content.query + + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } + + #expect(tabManager.tabs[index].content.query == originalQuery) + } + + @Test("None behavior takes the fast path without touching the query") + func noneBehaviorSkipsSchemaWait() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + let originalQuery = tabManager.tabs[index].content.query + + await withDefaultSortBehavior(DefaultSortBehavior.none) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } + + #expect(tabManager.tabs[index].content.query == originalQuery) + #expect(!tabManager.tabs[index].sortState.isSorting) + } + + @Test("A restored user sort is never overwritten by the default sort") + func userSortSurvivesFirstLoad() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + coordinator.schemaColumns.store( + (columns: ["id", "name"], primaryKeys: ["id"]), + for: coordinator.schemaColumnsKey("users", schema: nil) + ) + let userSort = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) + tabManager.mutate(at: index) { $0.sortState = userSort } + + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + #expect(tabManager.tabs[index].sortState == userSort) } @Test("Default sort resolves against scoped columns when leading columns are hidden") - func sortsAgainstScopedColumnsWithHiddenColumns() { + func sortsAgainstScopedColumnsWithHiddenColumns() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - coordinator.schemaColumnsCache[schemaCacheKey(coordinator, table: "users")] = (["a", "id", "name"], ["id"]) + coordinator.schemaColumns.store( + (columns: ["a", "id", "name"], primaryKeys: ["id"]), + for: coordinator.schemaColumnsKey("users", schema: nil) + ) tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["a"] } let resultColumns = coordinator.effectiveResultColumns(for: tabManager.tabs[index]) #expect(resultColumns == ["id", "name"]) - let resolved = DefaultSortResolver.resolveSortState( - behavior: .primaryKey, - pluginHint: .useAppDefault, - primaryKeyColumns: ["id"], - allColumns: resultColumns - ) - #expect(resolved.columns.first?.columnIndex == 0) - - tabManager.mutate(at: index) { $0.sortState = resolved } - coordinator.filterCoordinator.rebuildTableQuery(at: index) + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } let query = tabManager.tabs[index].content.query #expect(query.localizedCaseInsensitiveContains("ORDER BY")) @@ -72,58 +156,51 @@ struct DefaultSortInitialQueryTests { #expect(!query.contains("`a`")) } - @Test("shouldResolveDefaultSort is true for a fresh table tab when the default sort is primary key") - func gateTrueForPrimaryKeyBehavior() { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .primaryKey - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - + @Test("prepareTableTabFirstLoad bails when the tab is no longer selected") + func bailsWhenTabDeselected() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - #expect(coordinator.shouldResolveDefaultSort(for: tabManager.tabs[index])) - } + let tabId = tabManager.tabs[index].id + tabManager.selectedTabId = nil - @Test("shouldResolveDefaultSort is false once the gate has been evaluated") - func gateFalseAfterEvaluation() { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .primaryKey - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabId) + #expect(!ready) + } + } + @Test("wantsDefaultSort is true for a fresh table tab when the default sort is primary key") + func gateTrueForPrimaryKeyBehavior() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - tabManager.mutate(at: index) { $0.execution.didEvaluateDefaultSort = true } - #expect(!coordinator.shouldResolveDefaultSort(for: tabManager.tabs[index])) + await withDefaultSortBehavior(.primaryKey) { + #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + } } - @Test("shouldResolveDefaultSort is false when the user already sorted") - func gateFalseWhenUserSorting() { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .primaryKey - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - + @Test("wantsDefaultSort is false when the user already sorted") + func gateFalseWhenUserSorting() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") tabManager.mutate(at: index) { $0.sortState = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) } - #expect(!coordinator.shouldResolveDefaultSort(for: tabManager.tabs[index])) + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + } } - @Test("shouldResolveDefaultSort is false when the default sort behavior is none") - func gateFalseForNoneBehavior() { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .none - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - + @Test("wantsDefaultSort is false when the default sort behavior is none") + func gateFalseForNoneBehavior() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - #expect(!coordinator.shouldResolveDefaultSort(for: tabManager.tabs[index])) + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + } } - @Test("shouldResolveDefaultSort is false for non-table tabs") - func gateFalseForQueryTab() { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .primaryKey - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - + @Test("wantsDefaultSort is false for non-table tabs") + func gateFalseForQueryTab() async { let (coordinator, _, _) = makeCoordinator(tableName: "users") let queryTab = QueryTab(title: "Q", query: "SELECT 1", tabType: .query) - #expect(!coordinator.shouldResolveDefaultSort(for: queryTab)) + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: queryTab)) + } } } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 84ad90612..dbb904b94 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -143,7 +143,7 @@ struct MainContentCoordinatorLazyLoadTests { let tabId = addTableTab(to: tabManager) let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { inFlight.cancel() } - coordinator.tableLoadTasks[tabId] = inFlight + coordinator.tableLoadTasks[tabId] = (UUID(), inFlight) coordinator.lazyLoadCurrentTabIfNeeded() From 7899dd23e5498c1281751e3c36605c21050ff0ed Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 22:42:54 +0700 Subject: [PATCH 2/2] refactor(datagrid): resolve the plugin sort hint once per first load and extract the schema-wait predicate --- ...ainContentCoordinator+TableFirstLoad.swift | 19 +++++---- .../Main/DefaultSortInitialQueryTests.swift | 42 +++++++++++++++++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index d087acb78..3d06f3f68 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -19,7 +19,8 @@ extension MainContentCoordinator { tab.tabType == .table, let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return false } - guard wantsDefaultSort(for: tab) || !tab.columnLayout.hiddenColumns.isEmpty else { return true } + let hint = PluginManager.shared.defaultSortHint(for: connection.type, table: tableName) + guard firstLoadNeedsSchemaColumns(for: tab, hint: hint) else { return true } await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) @@ -28,21 +29,25 @@ extension MainContentCoordinator { let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tableContext.tableName == tableName else { return false } - let sortApplied = applyResolvedDefaultSort(at: index, tableName: tableName) + let sortApplied = applyResolvedDefaultSort(at: index, hint: hint) if sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty { filterCoordinator.rebuildTableQuery(at: index) } return true } - func wantsDefaultSort(for tab: QueryTab) -> Bool { + func firstLoadNeedsSchemaColumns(for tab: QueryTab, hint: DefaultSortHint) -> Bool { + wantsDefaultSort(for: tab, hint: hint) || !tab.columnLayout.hiddenColumns.isEmpty + } + + func wantsDefaultSort(for tab: QueryTab, hint: DefaultSortHint) -> Bool { guard tab.tabType == .table, !tab.sortState.isSorting, let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return false } - switch PluginManager.shared.defaultSortHint(for: connection.type, table: tableName) { + switch hint { case .suppress: return false case .forceColumns: @@ -52,13 +57,13 @@ extension MainContentCoordinator { } } - private func applyResolvedDefaultSort(at index: Int, tableName: String) -> Bool { + private func applyResolvedDefaultSort(at index: Int, hint: DefaultSortHint) -> Bool { let tab = tabManager.tabs[index] - guard wantsDefaultSort(for: tab) else { return false } + guard wantsDefaultSort(for: tab, hint: hint) else { return false } let resolved = DefaultSortResolver.resolveSortState( behavior: AppSettingsManager.shared.dataGrid.defaultSortBehavior, - pluginHint: PluginManager.shared.defaultSortHint(for: connection.type, table: tableName), + pluginHint: hint, primaryKeyColumns: resolvedPrimaryKeyColumns(for: tab), allColumns: effectiveResultColumns(for: tab) ) diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index e6c46e581..2c2e75b1a 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -172,7 +172,7 @@ struct DefaultSortInitialQueryTests { func gateTrueForPrimaryKeyBehavior() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") await withDefaultSortBehavior(.primaryKey) { - #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) } } @@ -183,7 +183,7 @@ struct DefaultSortInitialQueryTests { $0.sortState = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) } await withDefaultSortBehavior(.primaryKey) { - #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) } } @@ -191,7 +191,7 @@ struct DefaultSortInitialQueryTests { func gateFalseForNoneBehavior() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") await withDefaultSortBehavior(DefaultSortBehavior.none) { - #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index])) + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) } } @@ -200,7 +200,41 @@ struct DefaultSortInitialQueryTests { let (coordinator, _, _) = makeCoordinator(tableName: "users") let queryTab = QueryTab(title: "Q", query: "SELECT 1", tabType: .query) await withDefaultSortBehavior(.primaryKey) { - #expect(!coordinator.wantsDefaultSort(for: queryTab)) + #expect(!coordinator.wantsDefaultSort(for: queryTab, hint: .useAppDefault)) + } + } + + @Test("wantsDefaultSort is false when the plugin suppresses default sorting") + func gateFalseForSuppressHint() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .suppress)) + } + } + + @Test("wantsDefaultSort is true when the plugin forces sort columns even with behavior none") + func gateTrueForForceColumnsHint() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .forceColumns(["id"]))) + } + } + + @Test("None behavior skips the schema wait unless columns are hidden") + func firstLoadSchemaWaitDecision() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(!coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + } + + await withDefaultSortBehavior(.primaryKey) { + #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + } + + tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["name"] } + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) } } }