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]

### Changed

- Hiding a column now also leaves it out of the query, so a table with one heavy column loads faster. Toggling a column re-runs the query, and the primary key is always fetched so editing keeps working

### Fixed

- Reassigning the Execute Query, Execute All Statements, and Cancel Query shortcuts now takes effect, and the Query menu shows the new keys (#1357)
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class FilterCoordinator {
logicMode: tab.filterState.filterLogicMode,
sortState: tab.sortState,
columns: buffer.columns,
selectColumns: parent.selectColumns(for: tab),
limit: tab.pagination.pageSize,
offset: tab.pagination.currentOffset
)
Expand Down Expand Up @@ -72,6 +73,7 @@ final class FilterCoordinator {
schemaName: tab.tableContext.schemaName,
sortState: tab.sortState,
columns: buffer.columns,
selectColumns: parent.selectColumns(for: tab),
limit: tab.pagination.pageSize,
offset: tab.pagination.currentOffset
)
Expand Down Expand Up @@ -106,6 +108,7 @@ final class FilterCoordinator {
logicMode: tab.filterState.filterLogicMode,
sortState: tab.sortState,
columns: buffer.columns,
selectColumns: parent.selectColumns(for: tab),
limit: tab.pagination.pageSize,
offset: tab.pagination.currentOffset
)
Expand All @@ -115,6 +118,7 @@ final class FilterCoordinator {
schemaName: tab.tableContext.schemaName,
sortState: tab.sortState,
columns: buffer.columns,
selectColumns: parent.selectColumns(for: tab),
limit: tab.pagination.pageSize,
offset: tab.pagination.currentOffset
)
Expand Down
38 changes: 38 additions & 0 deletions TablePro/Core/Services/Query/ColumnFetchScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ColumnFetchScope.swift
// TablePro
//

import Foundation

enum ColumnFetchScope {
static func selectColumns(
schemaColumns: [String],
hiddenColumns: Set<String>,
primaryKeyColumns: [String]
) -> [String]? {
guard !hiddenColumns.isEmpty, !schemaColumns.isEmpty else { return nil }
let primaryKeys = Set(primaryKeyColumns)
let kept = schemaColumns.filter { !hiddenColumns.contains($0) || primaryKeys.contains($0) }
guard !kept.isEmpty, kept.count < schemaColumns.count else { return nil }
return kept
}

/// Drops hidden-column entries for columns that no longer exist. A hidden
/// column is intentionally absent from the (scoped) result, so prune against
/// the full schema when known; fall back to the result plus the current
/// hidden set so a still-hidden column is never dropped just for being omitted.
static func prunedHiddenColumns(
_ hiddenColumns: Set<String>,
schemaColumns: [String]?,
resultColumns: [String]
) -> Set<String> {
let valid: Set<String>
if let schemaColumns, !schemaColumns.isEmpty {
valid = Set(schemaColumns)
} else {
valid = Set(resultColumns).union(hiddenColumns)
}
return hiddenColumns.intersection(valid)
}
}
15 changes: 11 additions & 4 deletions TablePro/Core/Services/Query/TableQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,22 @@ struct TableQueryBuilder {
schemaName: String? = nil,
sortState: SortState? = nil,
columns: [String] = [],
selectColumns: [String]? = nil,
limit: Int = 200,
offset: Int = 0
) -> String {
if let pluginDriver {
let sortCols = sortColumnsAsTuples(sortState)
if let result = pluginDriver.buildBrowseQuery(
table: tableName, sortColumns: sortCols,
columns: columns, limit: limit, offset: offset
columns: selectColumns ?? columns, limit: limit, offset: offset
) {
return result
}
}

let quotedTable = qualifiedTable(tableName, schema: schemaName)
var query = "SELECT * FROM \(quotedTable)"
var query = "SELECT \(selectClause(selectColumns)) FROM \(quotedTable)"

if let orderBy = orderByOrOffsetFetchDefault(sortState: sortState, columns: columns) {
query += " \(orderBy)"
Expand All @@ -95,6 +96,7 @@ struct TableQueryBuilder {
logicMode: FilterLogicMode = .and,
sortState: SortState? = nil,
columns: [String] = [],
selectColumns: [String]? = nil,
limit: Int = 200,
offset: Int = 0
) -> String {
Expand All @@ -114,14 +116,14 @@ struct TableQueryBuilder {
if let result = pluginDriver.buildFilteredQuery(
table: tableName, filters: filterTuples,
logicMode: logicMode == .and ? "and" : "or",
sortColumns: sortCols, columns: columns, limit: limit, offset: offset
sortColumns: sortCols, columns: selectColumns ?? columns, limit: limit, offset: offset
) {
return result
}
}

let quotedTable = qualifiedTable(tableName, schema: schemaName)
var query = "SELECT * FROM \(quotedTable)"
var query = "SELECT \(selectClause(selectColumns)) FROM \(quotedTable)"

if let dialect {
let activeFilters = filters.filter { $0.isEnabled }
Expand Down Expand Up @@ -201,6 +203,11 @@ struct TableQueryBuilder {

// MARK: - Private Helpers

private func selectClause(_ selectColumns: [String]?) -> String {
guard let selectColumns, !selectColumns.isEmpty else { return "*" }
return selectColumns.map { quote($0) }.joined(separator: ", ")
}

private func buildPaginationClause(limit: Int, offset: Int) -> String {
if let dialect, dialect.paginationStyle == .offsetFetch {
return "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ struct MainEditorContentView: View {
snapshot: StatusBarSnapshot(tab: tab, tableRows: resolvedRows),
filterState: tab.filterState,
hiddenColumns: tab.columnLayout.hiddenColumns,
allColumns: resolvedRows.columns,
allColumns: coordinator.columnsForVisibilityPicker(for: tab, resultColumns: resolvedRows.columns),
selectedRowIndices: selectionState.indices,
viewMode: resultsViewModeBinding(for: tab),
onFirstPage: onFirstPage,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// MainContentCoordinator+ColumnFetchScope.swift
// TablePro
//

import Foundation
import os

private let columnScopeLog = Logger(subsystem: "com.TablePro", category: "ColumnFetchScope")

extension MainContentCoordinator {
func selectColumns(for tab: QueryTab) -> [String]? {
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 }

return ColumnFetchScope.selectColumns(
schemaColumns: schema.columns,
hiddenColumns: tab.columnLayout.hiddenColumns,
primaryKeyColumns: schema.primaryKeys
)
}

func requeryWithColumnScope(debounced: Bool = false) {
columnScopeRequeryTask?.cancel()
columnScopeRequeryTask = Task { @MainActor [weak self] in
guard let self else { return }
if debounced {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
}
guard let (tab, tabIndex) = self.tabManager.selectedTabAndIndex,
tab.tabType == .table,
let tableName = tab.tableContext.tableName else { return }
await self.loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName)
guard !Task.isCancelled, tabIndex < self.tabManager.tabs.count else { return }
self.filterCoordinator.rebuildTableQuery(at: tabIndex)
self.runQuery()
}
}

func loadSchemaColumns(for tableName: String, schema: String?) async {
let key = schemaColumnsKey(tableName, schema: schema)
guard schemaColumnsCache[key] == nil else { return }
guard let driver = services.databaseManager.driver(for: connectionId) else {
columnScopeLog.error("loadSchemaColumns: no driver for connection; cannot scope columns for table=\(tableName, privacy: .public)")
return
}
do {
let columns = 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
}
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 {
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)],
!schema.columns.isEmpty else { return nil }
return schema.columns
}

private func schemaColumnsKey(_ tableName: String, schema: String?) -> String {
"\(connectionId):\(activeDatabaseName):\(schema ?? ""):\(tableName)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ extension MainContentCoordinator {

func hideColumn(_ columnName: String) {
mutateSelectedTabHiddenColumns { $0.insert(columnName) }
requeryWithColumnScope(debounced: true)
}

func showColumn(_ columnName: String) {
mutateSelectedTabHiddenColumns { $0.remove(columnName) }
requeryWithColumnScope(debounced: true)
}

func toggleColumnVisibility(_ columnName: String) {
Expand All @@ -27,19 +29,28 @@ extension MainContentCoordinator {
hidden.insert(columnName)
}
}
requeryWithColumnScope(debounced: true)
}

func showAllColumns() {
mutateSelectedTabHiddenColumns { $0.removeAll() }
requeryWithColumnScope(debounced: true)
}

func hideAllColumns(_ columns: [String]) {
mutateSelectedTabHiddenColumns { $0 = Set(columns) }
requeryWithColumnScope(debounced: true)
}

func pruneHiddenColumns(currentColumns: [String]) {
let currentSet = Set(currentColumns)
mutateSelectedTabHiddenColumns { $0 = $0.intersection(currentSet) }
let current = selectedTabHiddenColumns
let pruned = ColumnFetchScope.prunedHiddenColumns(
current,
schemaColumns: selectedTabSchemaColumns(),
resultColumns: currentColumns
)
guard pruned != current else { return }
mutateSelectedTabHiddenColumns { $0 = pruned }
}

func restoreLastHiddenColumnsForTable(_ tableName: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ extension MainContentCoordinator {
restoreFiltersForTable(tableName)
if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) {
selectRedisDatabaseAndQuery(dbIndex)
} else if !selectedTabHiddenColumns.isEmpty {
requeryWithColumnScope()
} else {
runQuery()
}
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ final class MainContentCoordinator {

@ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:]

@ObservationIgnored var schemaColumnsCache: [String: (columns: [String], primaryKeys: [String])] = [:]
@ObservationIgnored var columnScopeRequeryTask: Task<Void, Never>?

@ObservationIgnored var pendingScrollToTopAfterReplace: Set<UUID> = []

// MARK: - Internal State
Expand Down Expand Up @@ -498,6 +501,7 @@ final class MainContentCoordinator {

func refreshTables() async {
guard let driver = services.databaseManager.driver(for: connectionId) else { return }
schemaColumnsCache.removeAll()
await services.schemaService.reload(
connectionId: connectionId,
driver: driver,
Expand Down Expand Up @@ -617,6 +621,8 @@ final class MainContentCoordinator {
tabSessionRegistry.removeAll()
querySortCache.removeAll()
displayFormatsCache.removeAll()
schemaColumnsCache.removeAll()
columnScopeRequeryTask?.cancel()

tabManager.tabs.removeAll()
tabManager.selectedTabId = nil
Expand Down
80 changes: 80 additions & 0 deletions TableProTests/Core/Services/ColumnFetchScopeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation
@testable import TablePro
import Testing

@Suite("ColumnFetchScope")
struct ColumnFetchScopeTests {
private let columns = ["id", "name", "email", "payload"]

@Test("No hidden columns means no scoping (SELECT *)")
func noHiddenColumns() {
#expect(ColumnFetchScope.selectColumns(schemaColumns: columns, hiddenColumns: [], primaryKeyColumns: ["id"]) == nil)
}

@Test("Hidden column is dropped, order preserved")
func dropsHiddenColumn() {
let result = ColumnFetchScope.selectColumns(
schemaColumns: columns,
hiddenColumns: ["payload"],
primaryKeyColumns: ["id"]
)
#expect(result == ["id", "name", "email"])
}

@Test("Primary key is retained even when hidden")
func retainsHiddenPrimaryKey() {
let result = ColumnFetchScope.selectColumns(
schemaColumns: columns,
hiddenColumns: ["id", "payload"],
primaryKeyColumns: ["id"]
)
#expect(result == ["id", "name", "email"])
}

@Test("Empty schema means no scoping")
func emptySchema() {
#expect(ColumnFetchScope.selectColumns(schemaColumns: [], hiddenColumns: ["payload"], primaryKeyColumns: []) == nil)
}

@Test("Hiding everything with no primary key produces no scoping rather than empty SELECT")
func hidingEverythingNoPrimaryKey() {
#expect(ColumnFetchScope.selectColumns(schemaColumns: columns, hiddenColumns: Set(columns), primaryKeyColumns: []) == nil)
}

@Test("Hiding columns not present in the schema is a no-op")
func hiddenColumnsNotInSchema() {
#expect(ColumnFetchScope.selectColumns(schemaColumns: columns, hiddenColumns: ["ghost"], primaryKeyColumns: ["id"]) == nil)
}

// MARK: - prunedHiddenColumns

@Test("Prune keeps a hidden column that is still in the schema but absent from the scoped result")
func pruneKeepsSchemaColumnAbsentFromResult() {
let pruned = ColumnFetchScope.prunedHiddenColumns(
["payload"],
schemaColumns: columns,
resultColumns: ["id", "name", "email"]
)
#expect(pruned == ["payload"])
}

@Test("Prune drops a hidden column that no longer exists in the schema")
func pruneDropsColumnGoneFromSchema() {
let pruned = ColumnFetchScope.prunedHiddenColumns(
["payload", "ghost"],
schemaColumns: columns,
resultColumns: ["id", "name", "email"]
)
#expect(pruned == ["payload"])
}

@Test("Prune without a known schema keeps both hidden and result columns")
func pruneWithoutSchemaKeepsHiddenAndResult() {
let pruned = ColumnFetchScope.prunedHiddenColumns(
["payload"],
schemaColumns: nil,
resultColumns: ["id", "name"]
)
#expect(pruned == ["payload"])
}
}
Loading
Loading