From 5d8fb76ed8f499ae5777cb1ec516e2feb4371496 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 21 May 2026 20:48:05 +0700 Subject: [PATCH 1/4] feat(datagrid): scaffold per-table fetched-column selection (foundation) --- .../Services/Query/TableQueryBuilder.swift | 15 +++++--- .../Storage/FetchedColumnsPersistence.swift | 36 +++++++++++++++++++ TablePro/Models/Query/QueryTabState.swift | 1 + 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 TablePro/Core/Storage/FetchedColumnsPersistence.swift 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/Core/Storage/FetchedColumnsPersistence.swift b/TablePro/Core/Storage/FetchedColumnsPersistence.swift new file mode 100644 index 000000000..6c8ca83e3 --- /dev/null +++ b/TablePro/Core/Storage/FetchedColumnsPersistence.swift @@ -0,0 +1,36 @@ +// +// FetchedColumnsPersistence.swift +// TablePro +// + +import Foundation + +enum FetchedColumnsPersistence { + static func key(tableName: String, connectionId: UUID) -> String { + "com.TablePro.columns.unfetchedColumns.\(connectionId.uuidString).\(tableName)" + } + + static func loadUnfetchedColumns( + for tableName: String, + connectionId: UUID, + defaults: UserDefaults = .standard + ) -> Set { + let storageKey = key(tableName: tableName, connectionId: connectionId) + guard let array = defaults.stringArray(forKey: storageKey) else { return [] } + return Set(array) + } + + static func saveUnfetchedColumns( + _ unfetchedColumns: Set, + for tableName: String, + connectionId: UUID, + defaults: UserDefaults = .standard + ) { + let storageKey = key(tableName: tableName, connectionId: connectionId) + if unfetchedColumns.isEmpty { + defaults.removeObject(forKey: storageKey) + } else { + defaults.set(Array(unfetchedColumns), forKey: storageKey) + } + } +} diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index dca911d13..bf7344212 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -232,6 +232,7 @@ struct ColumnLayoutState: Equatable { var columnWidths: [String: CGFloat] = [:] var columnOrder: [String]? var hiddenColumns: Set = [] + var unfetchedColumns: Set = [] } struct TabExecutionState: Equatable { From 861b541a1e2e060a5f0f8098737470d03bf37215 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 21 May 2026 23:42:31 +0700 Subject: [PATCH 2/4] feat(datagrid): omit hidden columns from the table browse query --- CHANGELOG.md | 4 ++ .../Core/Coordinators/FilterCoordinator.swift | 4 ++ .../Services/Query/ColumnFetchScope.swift | 20 ++++++ .../Storage/FetchedColumnsPersistence.swift | 36 ----------- TablePro/Models/Query/QueryTabState.swift | 1 - .../Main/Child/MainEditorContentView.swift | 2 +- ...nContentCoordinator+ColumnFetchScope.swift | 61 +++++++++++++++++++ ...nContentCoordinator+ColumnVisibility.swift | 5 ++ .../MainContentCoordinator+Navigation.swift | 2 + .../Views/Main/MainContentCoordinator.swift | 6 ++ .../Core/Services/ColumnFetchScopeTests.swift | 48 +++++++++++++++ docs/features/data-grid.mdx | 6 ++ 12 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 TablePro/Core/Services/Query/ColumnFetchScope.swift delete mode 100644 TablePro/Core/Storage/FetchedColumnsPersistence.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift create mode 100644 TableProTests/Core/Services/ColumnFetchScopeTests.swift 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..28b6bcdb6 --- /dev/null +++ b/TablePro/Core/Services/Query/ColumnFetchScope.swift @@ -0,0 +1,20 @@ +// +// 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 + } +} diff --git a/TablePro/Core/Storage/FetchedColumnsPersistence.swift b/TablePro/Core/Storage/FetchedColumnsPersistence.swift deleted file mode 100644 index 6c8ca83e3..000000000 --- a/TablePro/Core/Storage/FetchedColumnsPersistence.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// FetchedColumnsPersistence.swift -// TablePro -// - -import Foundation - -enum FetchedColumnsPersistence { - static func key(tableName: String, connectionId: UUID) -> String { - "com.TablePro.columns.unfetchedColumns.\(connectionId.uuidString).\(tableName)" - } - - static func loadUnfetchedColumns( - for tableName: String, - connectionId: UUID, - defaults: UserDefaults = .standard - ) -> Set { - let storageKey = key(tableName: tableName, connectionId: connectionId) - guard let array = defaults.stringArray(forKey: storageKey) else { return [] } - return Set(array) - } - - static func saveUnfetchedColumns( - _ unfetchedColumns: Set, - for tableName: String, - connectionId: UUID, - defaults: UserDefaults = .standard - ) { - let storageKey = key(tableName: tableName, connectionId: connectionId) - if unfetchedColumns.isEmpty { - defaults.removeObject(forKey: storageKey) - } else { - defaults.set(Array(unfetchedColumns), forKey: storageKey) - } - } -} diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index bf7344212..dca911d13 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -232,7 +232,6 @@ struct ColumnLayoutState: Equatable { var columnWidths: [String: CGFloat] = [:] var columnOrder: [String]? var hiddenColumns: Set = [] - var unfetchedColumns: Set = [] } struct TabExecutionState: Equatable { 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..0fd347e3a --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -0,0 +1,61 @@ +// +// MainContentCoordinator+ColumnFetchScope.swift +// TablePro +// + +import Foundation + +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)] 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) + guard !Task.isCancelled, tabIndex < self.tabManager.tabs.count else { return } + self.filterCoordinator.rebuildTableQuery(at: tabIndex) + self.runQuery() + } + } + + func loadSchemaColumns(for tableName: String) async { + let key = schemaColumnsKey(tableName) + guard schemaColumnsCache[key] == nil else { return } + guard let provider = services.schemaProviderRegistry.provider(for: connectionId) else { return } + let columns = await provider.getColumns(for: tableName) + guard !columns.isEmpty else { return } + schemaColumnsCache[key] = (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name)) + } + + 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.columns.isEmpty { + return schema.columns + } + let missingHidden = tab.columnLayout.hiddenColumns.subtracting(resultColumns) + return missingHidden.isEmpty ? resultColumns : resultColumns + missingHidden.sorted() + } + + private func schemaColumnsKey(_ tableName: String) -> String { + "\(connectionId):\(activeDatabaseName):\(tableName)" + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 121fd85a9..8d8466ce8 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,14 +29,17 @@ 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]) { 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..4196d96ad --- /dev/null +++ b/TableProTests/Core/Services/ColumnFetchScopeTests.swift @@ -0,0 +1,48 @@ +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) + } +} 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 From da8a0c75f3e8baec6b673ca8cf9f70211bc4f3fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 00:04:05 +0700 Subject: [PATCH 3/4] fix(datagrid): prune hidden columns against the schema, not the scoped result --- ...nContentCoordinator+ColumnFetchScope.swift | 24 +++++++++++++++++-- ...nContentCoordinator+ColumnVisibility.swift | 7 ++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index 0fd347e3a..492a7c22c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -4,6 +4,9 @@ // import Foundation +import os + +private let columnScopeLog = Logger(subsystem: "com.TablePro", category: "ColumnFetchScope") extension MainContentCoordinator { func selectColumns(for tab: QueryTab) -> [String]? { @@ -40,9 +43,15 @@ extension MainContentCoordinator { func loadSchemaColumns(for tableName: String) async { let key = schemaColumnsKey(tableName) guard schemaColumnsCache[key] == nil else { return } - guard let provider = services.schemaProviderRegistry.provider(for: connectionId) else { return } + guard let provider = services.schemaProviderRegistry.provider(for: connectionId) else { + columnScopeLog.error("loadSchemaColumns: no schema provider for connection; cannot scope columns for table=\(tableName, privacy: .public)") + return + } let columns = await provider.getColumns(for: tableName) - guard !columns.isEmpty else { return } + guard !columns.isEmpty else { + columnScopeLog.error("loadSchemaColumns: provider returned 0 columns for table=\(tableName, privacy: .public); cannot scope") + return + } schemaColumnsCache[key] = (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name)) } @@ -55,6 +64,17 @@ extension MainContentCoordinator { return missingHidden.isEmpty ? resultColumns : resultColumns + missingHidden.sorted() } + /// Columns that count as "still part of the table" when pruning stale hidden + /// entries. Hidden columns are intentionally absent from the (scoped) result, + /// so prune against the full schema, never the fetched result. + func validColumnsForPruning(currentColumns: [String]) -> Set { + if let tableName = tabManager.selectedTab?.tableContext.tableName, + let schema = schemaColumnsCache[schemaColumnsKey(tableName)], !schema.columns.isEmpty { + return Set(schema.columns) + } + return Set(currentColumns).union(selectedTabHiddenColumns) + } + private func schemaColumnsKey(_ tableName: String) -> String { "\(connectionId):\(activeDatabaseName):\(tableName)" } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 8d8466ce8..5a5c678c4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -43,8 +43,11 @@ extension MainContentCoordinator { } func pruneHiddenColumns(currentColumns: [String]) { - let currentSet = Set(currentColumns) - mutateSelectedTabHiddenColumns { $0 = $0.intersection(currentSet) } + let valid = validColumnsForPruning(currentColumns: currentColumns) + let current = selectedTabHiddenColumns + let pruned = current.intersection(valid) + guard pruned != current else { return } + mutateSelectedTabHiddenColumns { $0 = pruned } } func restoreLastHiddenColumnsForTable(_ tableName: String) { From 65663eb0fa3dc73d224834a02938110afbe53822 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 00:13:23 +0700 Subject: [PATCH 4/4] fix(datagrid): resolve scoped columns per schema and cover column pruning with tests --- .../Services/Query/ColumnFetchScope.swift | 18 +++++++ ...nContentCoordinator+ColumnFetchScope.swift | 49 ++++++++++--------- ...nContentCoordinator+ColumnVisibility.swift | 7 ++- .../Core/Services/ColumnFetchScopeTests.swift | 32 ++++++++++++ 4 files changed, 81 insertions(+), 25 deletions(-) diff --git a/TablePro/Core/Services/Query/ColumnFetchScope.swift b/TablePro/Core/Services/Query/ColumnFetchScope.swift index 28b6bcdb6..87c37ab3e 100644 --- a/TablePro/Core/Services/Query/ColumnFetchScope.swift +++ b/TablePro/Core/Services/Query/ColumnFetchScope.swift @@ -17,4 +17,22 @@ enum ColumnFetchScope { 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/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index 492a7c22c..c7b0b8b54 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -13,7 +13,7 @@ extension MainContentCoordinator { guard tab.tabType == .table, let tableName = tab.tableContext.tableName, !tab.columnLayout.hiddenColumns.isEmpty, - let schema = schemaColumnsCache[schemaColumnsKey(tableName)] else { return nil } + let schema = schemaColumnsCache[schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)] else { return nil } return ColumnFetchScope.selectColumns( schemaColumns: schema.columns, @@ -33,49 +33,52 @@ extension MainContentCoordinator { guard let (tab, tabIndex) = self.tabManager.selectedTabAndIndex, tab.tabType == .table, let tableName = tab.tableContext.tableName else { return } - await self.loadSchemaColumns(for: tableName) + 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) async { - let key = schemaColumnsKey(tableName) + func loadSchemaColumns(for tableName: String, schema: String?) async { + let key = schemaColumnsKey(tableName, schema: schema) guard schemaColumnsCache[key] == nil else { return } - guard let provider = services.schemaProviderRegistry.provider(for: connectionId) else { - columnScopeLog.error("loadSchemaColumns: no schema provider for connection; cannot scope columns for table=\(tableName, privacy: .public)") + 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 } - let columns = await provider.getColumns(for: tableName) - guard !columns.isEmpty else { - columnScopeLog.error("loadSchemaColumns: provider returned 0 columns for table=\(tableName, privacy: .public); cannot scope") - 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)") } - schemaColumnsCache[key] = (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name)) } 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.columns.isEmpty { + 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() } - /// Columns that count as "still part of the table" when pruning stale hidden - /// entries. Hidden columns are intentionally absent from the (scoped) result, - /// so prune against the full schema, never the fetched result. - func validColumnsForPruning(currentColumns: [String]) -> Set { - if let tableName = tabManager.selectedTab?.tableContext.tableName, - let schema = schemaColumnsCache[schemaColumnsKey(tableName)], !schema.columns.isEmpty { - return Set(schema.columns) - } - return Set(currentColumns).union(selectedTabHiddenColumns) + /// 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) -> String { - "\(connectionId):\(activeDatabaseName):\(tableName)" + 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 5a5c678c4..eea6e4ab8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -43,9 +43,12 @@ extension MainContentCoordinator { } func pruneHiddenColumns(currentColumns: [String]) { - let valid = validColumnsForPruning(currentColumns: currentColumns) let current = selectedTabHiddenColumns - let pruned = current.intersection(valid) + let pruned = ColumnFetchScope.prunedHiddenColumns( + current, + schemaColumns: selectedTabSchemaColumns(), + resultColumns: currentColumns + ) guard pruned != current else { return } mutateSelectedTabHiddenColumns { $0 = pruned } } diff --git a/TableProTests/Core/Services/ColumnFetchScopeTests.swift b/TableProTests/Core/Services/ColumnFetchScopeTests.swift index 4196d96ad..d629b5f2f 100644 --- a/TableProTests/Core/Services/ColumnFetchScopeTests.swift +++ b/TableProTests/Core/Services/ColumnFetchScopeTests.swift @@ -45,4 +45,36 @@ struct ColumnFetchScopeTests { 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"]) + } }