diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb529625..9dfdd29f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Default row sort by primary key works again for PostgreSQL and other databases, and the rows arrive already sorted on the first load instead of re-sorting after they appear. (#1603) - Registry plugins built before 0.49.0 install and load again instead of failing with an invalid plugin bundle error. ## [0.49.0] - 2026-06-06 diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 5b1ca7c39..8e111d00c 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -97,6 +97,9 @@ final class FilterCoordinator { let tab = parent.tabManager.tabs[tabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) let hasFilters = tab.filterState.hasAppliedFilters + let columns = buffer.columns.isEmpty + ? parent.effectiveResultColumns(for: tab) + : buffer.columns let newQuery: String if hasFilters { @@ -106,7 +109,7 @@ final class FilterCoordinator { filters: tab.filterState.appliedFilters, logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, - columns: buffer.columns, + columns: columns, selectColumns: parent.selectColumns(for: tab), limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset @@ -116,7 +119,7 @@ final class FilterCoordinator { tableName: tableName, schemaName: tab.tableContext.schemaName, sortState: tab.sortState, - columns: buffer.columns, + columns: columns, selectColumns: parent.selectColumns(for: tab), limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index cbc474d10..11f1638a8 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -171,15 +171,6 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(at: idx) { $0.tableContext.primaryKeyColumns = resolvedPKs } } - applyDefaultSortIfPending( - tabId: tabId, - tabIndex: idx, - tableName: tableName, - columns: columns, - resolvedPKs: resolvedPKs, - connectionType: conn.type - ) - if parent.tabManager.selectedTabId == tabId { parent.changeManager.configureForTable( tableName: tableName ?? "", @@ -243,48 +234,6 @@ extension QueryExecutionCoordinator { ) } - private func applyDefaultSortIfPending( - tabId: UUID, - tabIndex: Int, - tableName: String?, - columns: [String], - resolvedPKs: [String], - connectionType: DatabaseType - ) { - guard tabIndex < parent.tabManager.tabs.count else { return } - let tab = parent.tabManager.tabs[tabIndex] - guard !tab.execution.didEvaluateDefaultSort, - tab.tabType == .table, - !tab.sortState.isSorting, - !columns.isEmpty, - let tableName, !tableName.isEmpty, - parent.tabManager.selectedTabId == tabId else { - return - } - - let behavior = AppSettingsManager.shared.dataGrid.defaultSortBehavior - let hint = PluginManager.shared.defaultSortHint(for: connectionType, table: tableName) - let resolved = DefaultSortResolver.resolveSortState( - behavior: behavior, - pluginHint: hint, - primaryKeyColumns: resolvedPKs, - allColumns: columns - ) - - guard resolved.isSorting else { - parent.tabManager.mutate(at: tabIndex) { $0.execution.didEvaluateDefaultSort = true } - return - } - - parent.tabManager.mutate(at: tabIndex) { tab in - tab.execution.didEvaluateDefaultSort = true - tab.sortState = resolved - tab.pagination.reset() - } - parent.filterCoordinator.rebuildTableQuery(at: tabIndex) - parent.runQuery() - } - func launchPhase2Work( tableName: String, tabId: UUID, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index dd5a3ca36..c38997c43 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -90,6 +90,15 @@ extension MainContentCoordinator { 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)] + } + + func effectiveResultColumns(for tab: QueryTab) -> [String] { + selectColumns(for: tab) ?? cachedSchemaColumns(for: tab)?.columns ?? [] + } + private 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 new file mode 100644 index 000000000..12cdf9ef0 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+DefaultSort.swift @@ -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 [] + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index be7abec9d..03ddab63e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -180,6 +180,7 @@ final class MainContentCoordinator { @ObservationIgnored var schemaColumnsCache: [String: (columns: [String], primaryKeys: [String])] = [:] @ObservationIgnored var columnScopeRequeryTask: Task? + @ObservationIgnored var defaultSortResolveTask: Task? @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] @@ -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 } + 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/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift new file mode 100644 index 000000000..208192ff3 --- /dev/null +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -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)) + } +}