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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions TablePro/Core/Services/Query/SchemaColumnStore.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>] = [:]
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

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 Prevent stale schema loads from repopulating the cache

When refreshTables() or teardown calls schemaColumns.removeAll(), this store cancels in-flight loads and clears entries, but any metadata fetch that does not promptly observe cancellation can still resume and write its old result here. That means a schema refresh can be followed by the previous fetchColumns result reappearing under the same key, so later default-sort or hidden-column logic can use stale column/PK metadata until another invalidation; the generation check only controls loads cleanup and does not guard the cache write.

Useful? React with 👍 / 👎.

}
}
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()
}
}
1 change: 0 additions & 1 deletion TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
1 change: 0 additions & 1 deletion TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -7558,6 +7558,7 @@
}
},
"Applied when opening a table. Click a column header to override." : {
"extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
Expand Down Expand Up @@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)"
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,8 @@ extension MainContentCoordinator {
restoreFiltersForTable(tableName)
if isInPlace, let dbIndex = Int(currentDatabase) {
selectRedisDatabaseAndQuery(dbIndex)
} else if !selectedTabHiddenColumns.isEmpty {
requeryWithColumnScope()
} else {
runQuery()
lazyLoadCurrentTabIfNeeded()
}
}

Expand Down Expand Up @@ -274,7 +272,10 @@ extension MainContentCoordinator {
}
restoreLastHiddenColumnsForTable(tableName)
restoreFiltersForTable(tableName)
executeSelectedTableTabQuery()
if let tabId = tabManager.selectedTab?.id {
cancelTableLoad(for: tabId)
}
lazyLoadCurrentTabIfNeeded()
}

// MARK: - Preview Tabs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// 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 }

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)

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, hint: hint)
if sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty {
filterCoordinator.rebuildTableQuery(at: index)
}
return true
}

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 hint {
case .suppress:
return false
case .forceColumns:
return true
case .useAppDefault:
return AppSettingsManager.shared.dataGrid.defaultSortBehavior != .none
}
}

private func applyResolvedDefaultSort(at index: Int, hint: DefaultSortHint) -> Bool {
let tab = tabManager.tabs[index]
guard wantsDefaultSort(for: tab, hint: hint) else { return false }

let resolved = DefaultSortResolver.resolveSortState(
behavior: AppSettingsManager.shared.dataGrid.defaultSortBehavior,
pluginHint: hint,
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 []
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading