-
-
Notifications
You must be signed in to change notification settings - Fork 286
fix(datagrid): apply default row sort on the first table load (#1603) #1604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // | ||
| // 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 [] | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,6 +180,7 @@ final class MainContentCoordinator { | |
|
|
||
| @ObservationIgnored var schemaColumnsCache: [String: (columns: [String], primaryKeys: [String])] = [:] | ||
| @ObservationIgnored var columnScopeRequeryTask: Task<Void, Never>? | ||
| @ObservationIgnored var defaultSortResolveTask: Task<Void, Never>? | ||
|
|
||
| @ObservationIgnored var pendingScrollToTopAfterReplace: Set<UUID> = [] | ||
|
|
||
|
|
@@ -657,6 +658,7 @@ final class MainContentCoordinator { | |
| displayFormatsCache.removeAll() | ||
| schemaColumnsCache.removeAll() | ||
| columnScopeRequeryTask?.cancel() | ||
| defaultSortResolveTask?.cancel() | ||
|
|
||
| tabManager.tabs.removeAll() | ||
| tabManager.selectedTabId = nil | ||
|
|
@@ -853,6 +855,16 @@ 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 } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the user triggers another table execution/refresh while Useful? React with 👍 / 👎. |
||
| 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 } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import Foundation | ||
| import TableProPluginKit | ||
| import Testing | ||
|
|
||
| @testable import TablePro | ||
|
|
||
| @Suite("Default sort resolves before the first table result loads") | ||
| @MainActor | ||
| struct DefaultSortInitialQueryTests { | ||
| private func makeCoordinator(tableName: String) -> (MainContentCoordinator, QueryTabManager, Int) { | ||
| let tabManager = QueryTabManager() | ||
| let coordinator = MainContentCoordinator( | ||
| connection: TestFixtures.makeConnection(), | ||
| tabManager: tabManager, | ||
| changeManager: DataChangeManager(), | ||
| toolbarState: ConnectionToolbarState() | ||
| ) | ||
| var tab = QueryTab(title: tableName, query: "SELECT * FROM `\(tableName)` LIMIT 200", tabType: .table) | ||
| tab.tableContext.tableName = tableName | ||
| tab.tableContext.isEditable = true | ||
| tabManager.tabs.append(tab) | ||
| tabManager.selectedTabId = tab.id | ||
| return (coordinator, tabManager, tabManager.tabs.count - 1) | ||
| } | ||
|
|
||
| private func schemaCacheKey(_ coordinator: MainContentCoordinator, table: String, schema: String? = nil) -> String { | ||
| "\(coordinator.connectionId):\(coordinator.activeDatabaseName):\(schema ?? ""):\(table)" | ||
| } | ||
|
|
||
| @Test("rebuildTableQuery emits ORDER BY from schema columns before any rows load") | ||
| func sortsFromSchemaColumnsBeforeFirstResult() { | ||
| let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") | ||
| coordinator.schemaColumnsCache[schemaCacheKey(coordinator, table: "users")] = (["id", "name", "email"], ["id"]) | ||
|
|
||
| tabManager.mutate(at: index) { | ||
| $0.sortState = SortState( | ||
| columns: [SortColumn(columnIndex: 0, direction: .ascending)], | ||
| source: .defaultSort | ||
| ) | ||
| } | ||
|
|
||
| coordinator.filterCoordinator.rebuildTableQuery(at: index) | ||
|
|
||
| let query = tabManager.tabs[index].content.query | ||
| #expect(query.localizedCaseInsensitiveContains("ORDER BY")) | ||
| #expect(query.contains("id")) | ||
| } | ||
|
|
||
| @Test("Default sort resolves against scoped columns when leading columns are hidden") | ||
| func sortsAgainstScopedColumnsWithHiddenColumns() { | ||
| let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") | ||
| coordinator.schemaColumnsCache[schemaCacheKey(coordinator, table: "users")] = (["a", "id", "name"], ["id"]) | ||
| 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) | ||
|
|
||
| let query = tabManager.tabs[index].content.query | ||
| #expect(query.localizedCaseInsensitiveContains("ORDER BY")) | ||
| #expect(query.contains("id")) | ||
| #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 } | ||
|
|
||
| let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") | ||
| #expect(coordinator.shouldResolveDefaultSort(for: tabManager.tabs[index])) | ||
| } | ||
|
|
||
| @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 } | ||
|
|
||
| let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") | ||
| tabManager.mutate(at: index) { $0.execution.didEvaluateDefaultSort = true } | ||
| #expect(!coordinator.shouldResolveDefaultSort(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 } | ||
|
|
||
| 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])) | ||
| } | ||
|
|
||
| @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 } | ||
|
|
||
| let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") | ||
| #expect(!coordinator.shouldResolveDefaultSort(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 } | ||
|
|
||
| let (coordinator, _, _) = makeCoordinator(tableName: "users") | ||
| let queryTab = QueryTab(title: "Q", query: "SELECT 1", tabType: .query) | ||
| #expect(!coordinator.shouldResolveDefaultSort(for: queryTab)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
loadSchemaColumnsawaits metadata before this call, the selected tab can change (or the preview tab can be reused for another table while preserving the same tab id) before the resolver resumes. This unconditionalrunQuery()executes whatever tab is currently selected rather than thetabIdbeing resolved, and in the reused-tab case can apply the stale resolver to the new table; the previous post-load path guardedselectedTabId == tabIdbefore re-querying. Please re-check that the target tab is still selected and still represents the same table/schema before rebuilding/executing.Useful? React with 👍 / 👎.