diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd6d16d9..f2f81a89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 654460ba8..3effcb76f 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) diff --git a/TablePro/Core/Services/Query/ColumnFetchScope.swift b/TablePro/Core/Services/Query/ColumnFetchScope.swift new file mode 100644 index 000000000..87c37ab3e --- /dev/null +++ b/TablePro/Core/Services/Query/ColumnFetchScope.swift @@ -0,0 +1,38 @@ +// +// ColumnFetchScope.swift +// TablePro +// + +import Foundation + +enum ColumnFetchScope { + static func selectColumns( + schemaColumns: [String], + hiddenColumns: Set, + 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, + schemaColumns: [String]?, + resultColumns: [String] + ) -> Set { + let valid: Set + if let schemaColumns, !schemaColumns.isEmpty { + valid = Set(schemaColumns) + } else { + valid = Set(resultColumns).union(hiddenColumns) + } + return hiddenColumns.intersection(valid) + } +} diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 9bc60ea65..a234b2842 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -64,6 +64,7 @@ struct TableQueryBuilder { schemaName: String? = nil, sortState: SortState? = nil, columns: [String] = [], + selectColumns: [String]? = nil, limit: Int = 200, offset: Int = 0 ) -> String { @@ -71,14 +72,14 @@ struct TableQueryBuilder { 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)" @@ -95,6 +96,7 @@ struct TableQueryBuilder { logicMode: FilterLogicMode = .and, sortState: SortState? = nil, columns: [String] = [], + selectColumns: [String]? = nil, limit: Int = 200, offset: Int = 0 ) -> String { @@ -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 } @@ -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" diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 9b79be674..93c8b8865 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -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, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift new file mode 100644 index 000000000..c7b0b8b54 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -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)" + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 121fd85a9..eea6e4ab8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -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) { @@ -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) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 97792f131..6b3e27471 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -141,6 +141,8 @@ extension MainContentCoordinator { restoreFiltersForTable(tableName) if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) { selectRedisDatabaseAndQuery(dbIndex) + } else if !selectedTabHiddenColumns.isEmpty { + requeryWithColumnScope() } else { runQuery() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f3f0c6221..fc57b6706 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -163,6 +163,9 @@ final class MainContentCoordinator { @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] + @ObservationIgnored var schemaColumnsCache: [String: (columns: [String], primaryKeys: [String])] = [:] + @ObservationIgnored var columnScopeRequeryTask: Task? + @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] // MARK: - Internal State @@ -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, @@ -617,6 +621,8 @@ final class MainContentCoordinator { tabSessionRegistry.removeAll() querySortCache.removeAll() displayFormatsCache.removeAll() + schemaColumnsCache.removeAll() + columnScopeRequeryTask?.cancel() tabManager.tabs.removeAll() tabManager.selectedTabId = nil diff --git a/TableProTests/Core/Services/ColumnFetchScopeTests.swift b/TableProTests/Core/Services/ColumnFetchScopeTests.swift new file mode 100644 index 000000000..d629b5f2f --- /dev/null +++ b/TableProTests/Core/Services/ColumnFetchScopeTests.swift @@ -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"]) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index ff4ab71e0..740215491 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -69,6 +69,12 @@ Sort applies to the full result. TablePro re-runs the query with `ORDER BY` appe /> +### Hiding Columns + +Toggle columns on or off from the columns button in the status bar, or a column header's right-click menu. Hidden columns are remembered per table. + +A hidden column is also left out of the query, so it isn't fetched. If a table is slow to open because one column holds a lot of data, hide that column and the table loads faster. Showing it again re-runs the query to load it. The primary key is always fetched, so editing and saving keep working even if you hide it. + ## Data Editing