Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down
51 changes: 0 additions & 51 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-check the target tab before running the query

Because loadSchemaColumns awaits 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 unconditional runQuery() executes whatever tab is currently selected rather than the tabId being resolved, and in the reused-tab case can apply the stale resolver to the new table; the previous post-load path guarded selectedTabId == tabId before 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 👍 / 👎.

}

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 []
}
}
12 changes: 12 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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> = []

Expand Down Expand Up @@ -657,6 +658,7 @@ final class MainContentCoordinator {
displayFormatsCache.removeAll()
schemaColumnsCache.removeAll()
columnScopeRequeryTask?.cancel()
defaultSortResolveTask?.cancel()

tabManager.tabs.removeAll()
tabManager.selectedTabId = nil
Expand Down Expand Up @@ -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 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer marking default sort evaluated until resolver completes

If the user triggers another table execution/refresh while loadSchemaColumns is still in flight, the next executeTableTabQueryDirectly() cancels defaultSortResolveTask at line 858, but this flag has already been set, so shouldResolveDefaultSort returns false and the tab runs its original unsorted query; because the flag remains true, the default sort is never retried for that table load. Consider setting this only after the resolver finishes or clearing it when the resolve task is canceled.

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 }

Expand Down
129 changes: 129 additions & 0 deletions TableProTests/Views/Main/DefaultSortInitialQueryTests.swift
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))
}
}
Loading