From c6a00313155d982dce88851215018f78fe22e856 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 22:42:35 +0700 Subject: [PATCH 1/2] fix(datagrid): reflect active filters in row count and pagination --- CHANGELOG.md | 1 + .../PluginDatabaseDriver.swift | 11 ++ Plugins/BigQueryDriverPlugin/Info.plist | 4 +- Plugins/CSVExportPlugin/Info.plist | 2 +- Plugins/CassandraDriverPlugin/Info.plist | 2 +- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- Plugins/CloudflareD1DriverPlugin/Info.plist | 4 +- Plugins/DuckDBDriverPlugin/Info.plist | 2 +- Plugins/DynamoDBDriverPlugin/Info.plist | 4 +- Plugins/EtcdDriverPlugin/Info.plist | 4 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 4 +- Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- .../MongoDBPluginDriver.swift | 14 ++ Plugins/MySQLDriverPlugin/Info.plist | 2 +- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- Plugins/RedisDriverPlugin/Info.plist | 2 +- Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- .../PluginDatabaseDriver.swift | 3 + Plugins/XLSXExportPlugin/Info.plist | 2 +- .../Core/Coordinators/FilterCoordinator.swift | 2 + .../QueryExecutionCoordinator+Helpers.swift | 170 +++++++++--------- TablePro/Core/Database/DatabaseDriver.swift | 5 + .../Core/Plugins/PluginDriverAdapter.swift | 11 ++ TablePro/Core/Plugins/PluginManager.swift | 2 +- .../Services/Query/TableQueryBuilder.swift | 29 ++- TablePro/Models/Database/TableFilter.swift | 12 ++ .../TableQueryBuilderFilterTests.swift | 59 ++++++ 33 files changed, 252 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 795cd7da0..798516c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Filtering a table now updates the row count and page count in the bottom-right to match the filtered result, instead of showing the whole-table totals. - Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. - Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. - Importing from DBeaver no longer shows an unnecessary keychain permission warning. DBeaver stores passwords in its own file, so macOS never prompts. diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift index 6643cbdfc..5eaa50d46 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift @@ -112,6 +112,12 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { limit: Int, offset: Int ) -> String? + // Filtered row count (optional, for NoSQL plugins; SQL plugins use COUNT(*) WHERE) + func fetchFilteredRowCount( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? // Statement generation (optional, for NoSQL plugins) func generateStatements( @@ -299,6 +305,11 @@ public extension PluginDatabaseDriver { limit: Int, offset: Int ) -> String? { nil } + func fetchFilteredRowCount( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? { nil } func generateStatements( table: String, diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index 89bbcc8ae..f2c8db75d 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -2,9 +2,9 @@ - TableProPluginKitVersion - 14 TableProMinAppVersion 0.42.0 + TableProPluginKitVersion + 15 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 373e2ce46..8e55b776d 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesExportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 7f91654fd..232a3ad12 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 14 + 15 diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index cd9f43859..5655e8720 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index 89bbcc8ae..f2c8db75d 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -2,9 +2,9 @@ - TableProPluginKitVersion - 14 TableProMinAppVersion 0.42.0 + TableProPluginKitVersion + 15 diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index a44ede9ab..69961ffcc 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 14 + 15 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index 89bbcc8ae..f2c8db75d 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -2,9 +2,9 @@ - TableProPluginKitVersion - 14 TableProMinAppVersion 0.42.0 + TableProPluginKitVersion + 15 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index 89bbcc8ae..f2c8db75d 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -2,9 +2,9 @@ - TableProPluginKitVersion - 14 TableProMinAppVersion 0.42.0 + TableProPluginKitVersion + 15 diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 71bd431ca..115ffb8f4 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesExportFormatIds json diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index 89bbcc8ae..f2c8db75d 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -2,9 +2,9 @@ - TableProPluginKitVersion - 14 TableProMinAppVersion 0.42.0 + TableProPluginKitVersion + 15 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 55642bf8f..668c3c303 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index a44ede9ab..69961ffcc 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 14 + 15 diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index a44ede9ab..69961ffcc 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 14 + 15 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 9c6e9928d..8bb8ec57d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -291,6 +291,20 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return Int(count) } + func fetchFilteredRowCount( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? { + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + + let filterJson = MongoDBQueryBuilder().buildFilterDocument(from: filters, logicMode: logicMode) + let count = try await conn.countDocuments(database: currentDb, collection: table, filter: filterJson) + return Int(count) + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 720201c04..dc3b11ec1 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index a44ede9ab..69961ffcc 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 14 + 15 diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index afb93d548..1f6e89f99 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 7b0cea6c2..9eb561c94 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 14 + 15 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index b22f4731a..f7e013f53 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index 9f01f705f..3720b81c6 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 229522469..68d1dc934 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 211a5846f..13738401d 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -88,6 +88,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Query building (optional, for NoSQL plugins) func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? + // Filtered row count (optional, for NoSQL plugins; SQL plugins use COUNT(*) WHERE) + func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? // Statement generation (optional, for NoSQL plugins) func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? @@ -249,6 +251,7 @@ public extension PluginDatabaseDriver { func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } + func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? { nil } func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? { nil } func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index b9ea7d819..fe617488f 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 14 + 15 TableProProvidesExportFormatIds xlsx diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 3effcb76f..0d0fd14e1 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -66,6 +66,8 @@ final class FilterCoordinator { guard let self, confirmed else { return } guard capturedTabIndex < parent.tabManager.tabs.count else { return } + parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() } + let tab = parent.tabManager.tabs[capturedTabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) let newQuery = parent.queryBuilder.buildBaseQuery( diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 7a91315db..823aa3468 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -121,7 +121,8 @@ extension QueryExecutionCoordinator { tab.tableContext.tableName = tableName tab.tableContext.isEditable = isEditable - if let metadata, let approxCount = metadata.approximateRowCount, approxCount > 0 { + if let metadata, let approxCount = metadata.approximateRowCount, approxCount > 0, + !tab.filterState.hasAppliedFilters { tab.pagination.totalRowCount = approxCount tab.pagination.isApproximateRowCount = true } @@ -251,57 +252,14 @@ extension QueryExecutionCoordinator { connectionType: DatabaseType, schemaResult: SchemaResult? ) { - let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql - - Task(priority: .background) { [weak self, parent] in - guard let self else { return } - guard !parent.isTearingDown else { return } - guard let mainDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } - - let count: Int? - let isApproximate: Bool - if isNonSQL { - count = try? await mainDriver.fetchApproximateRowCount(table: tableName) - isApproximate = true - } else { - let threshold = await AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan - let approxCount = await MainActor.run { - self.parent.tabManager.tabs.first { $0.id == tabId }?.pagination.totalRowCount - } - if let approx = approxCount, approx >= threshold { - return - } - - let quotedTable = mainDriver.quoteIdentifier(tableName) - do { - let countResult = try await mainDriver.execute( - query: "SELECT COUNT(*) FROM \(quotedTable)" - ) - if let firstRow = countResult.rows.first, - let countStr = firstRow.first?.asText { - count = Int(countStr) - } else { - count = nil - } - } catch { - helpersLogger.warning("COUNT(*) query failed for \(tableName): \(error.localizedDescription)") - count = nil - } - isApproximate = false - } - - if let count { - await MainActor.run { [weak self] in - guard let self else { return } - guard capturedGeneration == parent.queryGeneration else { return } - parent.tabManager.mutate(tabId: tabId) { tab in - tab.pagination.totalRowCount = count - tab.pagination.isApproximateRowCount = isApproximate - } - } - } - } + resolveRowCount( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: connectionType + ) + let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql guard !isNonSQL else { return } guard let enumDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } Task(priority: .background) { [weak self, parent] in @@ -361,51 +319,85 @@ extension QueryExecutionCoordinator { tabId: UUID, capturedGeneration: Int, connectionType: DatabaseType + ) { + resolveRowCount( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: connectionType + ) + } + + func resolveRowCount( + tableName: String, + tabId: UUID, + capturedGeneration: Int, + connectionType: DatabaseType ) { let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql - Task { [weak self, parent] in + Task(priority: .background) { [weak self, parent] in guard let self else { return } - guard let mainDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } - - let count: Int? - let isApproximate: Bool - if isNonSQL { - count = try? await mainDriver.fetchApproximateRowCount(table: tableName) - isApproximate = true - } else { - let threshold = await AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan - let approxCount = await MainActor.run { - self.parent.tabManager.tabs.first { $0.id == tabId }?.pagination.totalRowCount + guard !parent.isTearingDown else { return } + guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } + + let unfilteredSQL = "SELECT COUNT(*) FROM \(driver.quoteIdentifier(tableName))" + let plan: RowCountPlan = await MainActor.run { + guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return .skip } + let filterState = tab.filterState + if isNonSQL { + return filterState.hasAppliedFilters + ? .filteredNonSQL(filters: filterState.appliedFilters, logicMode: filterState.filterLogicMode) + : .approximate } - if let approx = approxCount, approx >= threshold { - return + if filterState.hasAppliedFilters { + let sql = parent.queryBuilder.buildFilteredCountQuery( + tableName: tableName, + schemaName: tab.tableContext.schemaName, + filters: filterState.appliedFilters, + logicMode: filterState.filterLogicMode + ) + return .exactSQL(sql ?? unfilteredSQL) } + let threshold = AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan + if let approx = tab.pagination.totalRowCount, approx >= threshold { return .skip } + return .exactSQL(unfilteredSQL) + } - let quotedTable = mainDriver.quoteIdentifier(tableName) + let outcome: RowCountOutcome + switch plan { + case .skip: + return + case .approximate: + guard let count = try? await driver.fetchApproximateRowCount(table: tableName) else { return } + outcome = .count(count, isApproximate: true) + case let .filteredNonSQL(filters, logicMode): + if let count = try? await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) { + outcome = .count(count, isApproximate: false) + } else { + outcome = .clear + } + case let .exactSQL(sql): do { - let countResult = try await mainDriver.execute( - query: "SELECT COUNT(*) FROM \(quotedTable)" - ) - if let firstRow = countResult.rows.first, - let countStr = firstRow.first?.asText { - count = Int(countStr) - } else { - count = nil - } + let result = try await driver.execute(query: sql) + guard let countStr = result.rows.first?.first?.asText, let count = Int(countStr) else { return } + outcome = .count(count, isApproximate: false) } catch { - helpersLogger.warning("COUNT(*) query failed for \(tableName): \(error.localizedDescription)") - count = nil + helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)") + return } - isApproximate = false } - if let count { - await MainActor.run { [weak self] in - guard let self else { return } - parent.tabManager.mutate(tabId: tabId) { tab in - tab.pagination.totalRowCount = count + await MainActor.run { + guard capturedGeneration == parent.queryGeneration else { return } + parent.tabManager.mutate(tabId: tabId) { tab in + switch outcome { + case let .count(value, isApproximate): + tab.pagination.totalRowCount = value tab.pagination.isApproximateRowCount = isApproximate + case .clear: + tab.pagination.totalRowCount = nil + tab.pagination.isApproximateRowCount = false } } } @@ -481,3 +473,15 @@ extension QueryExecutionCoordinator { parent.runQuery() } } + +private enum RowCountPlan { + case skip + case approximate + case exactSQL(String) + case filteredNonSQL(filters: [TableFilter], logicMode: FilterLogicMode) +} + +private enum RowCountOutcome { + case count(Int, isApproximate: Bool) + case clear +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 402cad0ed..7004a3410 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -95,6 +95,10 @@ protocol DatabaseDriver: AnyObject { /// Returns nil if not available (e.g., SQLite). Used for instant pagination display. func fetchApproximateRowCount(table: String) async throws -> Int? + /// Fetch an exact row count for the table filtered by `filters`. + /// Returns nil when the driver can't count a filtered set, so the caller falls back. + func fetchFilteredRowCount(table: String, filters: [TableFilter], logicMode: FilterLogicMode) async throws -> Int? + /// Fetch the DDL (CREATE TABLE statement) for a specific table func fetchTableDDL(table: String) async throws -> String @@ -354,6 +358,7 @@ extension DatabaseDriver { } func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchFilteredRowCount(table: String, filters: [TableFilter], logicMode: FilterLogicMode) async throws -> Int? { nil } func supportedMaintenanceOperations() -> [String]? { nil } func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? { nil } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 990603e78..d4d9955b1 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -255,6 +255,17 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.fetchApproximateRowCount(table: table, schema: pluginDriver.currentSchema) } + func fetchFilteredRowCount(table: String, filters: [TableFilter], logicMode: FilterLogicMode) async throws -> Int? { + let tuples = filters + .filter { $0.isEnabled && !$0.columnName.isEmpty } + .map(\.asPluginFilterTuple) + return try await pluginDriver.fetchFilteredRowCount( + table: table, + filters: tuples, + logicMode: logicMode == .and ? "and" : "or" + ) + } + func fetchTableDDL(table: String) async throws -> String { try await pluginDriver.fetchTableDDL(table: table, schema: pluginDriver.currentSchema) } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 864fc0d6f..44c40bf7d 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -13,7 +13,7 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() - static let currentPluginKitVersion = 14 + static let currentPluginKitVersion = 15 static let currentInspectorKitVersion = 1 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index a234b2842..c7c65cbe5 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -104,15 +104,7 @@ struct TableQueryBuilder { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters .filter { $0.isEnabled && !$0.columnName.isEmpty } - .map { filter in - let value: String - if filter.filterOperator == .between, let second = filter.secondValue { - value = "\(filter.value),\(second)" - } else { - value = filter.value - } - return (filter.columnName, filter.filterOperator.rawValue, value) - } + .map(\.asPluginFilterTuple) if let result = pluginDriver.buildFilteredQuery( table: tableName, filters: filterTuples, logicMode: logicMode == .and ? "and" : "or", @@ -142,6 +134,25 @@ struct TableQueryBuilder { return query } + func buildFilteredCountQuery( + tableName: String, + schemaName: String? = nil, + filters: [TableFilter], + logicMode: FilterLogicMode = .and + ) -> String? { + guard let dialect else { return nil } + + let quotedTable = qualifiedTable(tableName, schema: schemaName) + let activeFilters = filters.filter { $0.isEnabled } + let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote) + let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode) + + guard !whereClause.isEmpty else { + return "SELECT COUNT(*) FROM \(quotedTable)" + } + return "SELECT COUNT(*) FROM \(quotedTable) \(whereClause)" + } + func buildSortedQuery( baseQuery: String, columnName: String, diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index 27a6be4c7..8ac5af283 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -172,6 +172,18 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable { } } +extension TableFilter { + var asPluginFilterTuple: (column: String, op: String, value: String) { + let resolvedValue: String + if filterOperator == .between, let second = secondValue { + resolvedValue = "\(value),\(second)" + } else { + resolvedValue = value + } + return (columnName, filterOperator.rawValue, resolvedValue) + } +} + /// Stores per-tab filter state (preserves filters when switching tabs) struct TabFilterState: Equatable, Hashable, Codable { var filters: [TableFilter] diff --git a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift index 7a782fd5a..dc88dc177 100644 --- a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift @@ -75,6 +75,65 @@ struct TableQueryBuilderFilteredQueryTests { } } +@Suite("Table Query Builder - Filtered Count") +struct TableQueryBuilderFilteredCountTests { + private static let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexp, booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + + private var builder: TableQueryBuilder { + TableQueryBuilder(databaseType: .mysql, dialect: Self.mysqlDialect) + } + + private func makeFilter(_ column: String, _ value: String, _ op: FilterOperator = .equal) -> TableFilter { + var filter = TableFilter() + filter.columnName = column + filter.filterOperator = op + filter.value = value + filter.isEnabled = true + return filter + } + + @Test("buildFilteredCountQuery wraps the filter in COUNT(*) WHERE without pagination") + func filteredCountProducesWhere() { + let query = builder.buildFilteredCountQuery(tableName: "users", filters: [makeFilter("name", "Alice")]) + #expect(query?.contains("SELECT COUNT(*) FROM") == true) + #expect(query?.contains("WHERE") == true) + #expect(query?.contains("name") == true) + #expect(query?.contains("Alice") == true) + #expect(query?.contains("LIMIT") == false) + #expect(query?.contains("ORDER BY") == false) + } + + @Test("buildFilteredCountQuery has no WHERE when no filters are enabled") + func filteredCountNoEnabledFilters() { + var disabled = makeFilter("name", "Alice") + disabled.isEnabled = false + let query = builder.buildFilteredCountQuery(tableName: "users", filters: [disabled]) + #expect(query?.contains("SELECT COUNT(*) FROM") == true) + #expect(query?.contains("WHERE") == false) + } + + @Test("buildFilteredCountQuery WHERE matches buildFilteredQuery WHERE") + func countWhereMatchesDataWhere() { + let filters = [makeFilter("age", "30", .greaterThan)] + let countQuery = builder.buildFilteredCountQuery(tableName: "users", filters: filters) ?? "" + let dataQuery = builder.buildFilteredQuery(tableName: "users", filters: filters) + + let countWhere = (countQuery.components(separatedBy: "WHERE").last ?? "").trimmingCharacters(in: .whitespaces) + #expect(!countWhere.isEmpty) + #expect(dataQuery.contains(countWhere)) + } + + @Test("buildFilteredCountQuery returns nil without a dialect") + func filteredCountNilWithoutDialect() { + let noDialect = TableQueryBuilder(databaseType: .mysql) + #expect(noDialect.buildFilteredCountQuery(tableName: "users", filters: [makeFilter("name", "Alice")]) == nil) + } +} + @Suite("Table Query Builder - NoSQL Nil Dialect Fallback") struct TableQueryBuilderNoSQLTests { // MongoDB has no SQL dialect — should produce bare SELECT without WHERE From 8fc9358079b79e98ef5346f62140e74829520182 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 23:01:45 +0700 Subject: [PATCH 2/2] refactor(datagrid): bound filtered row count on large tables and extract a testable count plan --- .../QueryExecutionCoordinator+Helpers.swift | 67 +++++++++------ .../Core/Coordinators/RowCountPlanTests.swift | 83 +++++++++++++++++++ 2 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 TableProTests/Core/Coordinators/RowCountPlanTests.swift diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 823aa3468..cb510ec27 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -341,33 +341,30 @@ extension QueryExecutionCoordinator { guard !parent.isTearingDown else { return } guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } - let unfilteredSQL = "SELECT COUNT(*) FROM \(driver.quoteIdentifier(tableName))" - let plan: RowCountPlan = await MainActor.run { - guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return .skip } - let filterState = tab.filterState - if isNonSQL { - return filterState.hasAppliedFilters - ? .filteredNonSQL(filters: filterState.appliedFilters, logicMode: filterState.filterLogicMode) - : .approximate - } - if filterState.hasAppliedFilters { - let sql = parent.queryBuilder.buildFilteredCountQuery( - tableName: tableName, - schemaName: tab.tableContext.schemaName, - filters: filterState.appliedFilters, - logicMode: filterState.filterLogicMode - ) - return .exactSQL(sql ?? unfilteredSQL) - } - let threshold = AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan - if let approx = tab.pagination.totalRowCount, approx >= threshold { return .skip } - return .exactSQL(unfilteredSQL) + let prepared: (plan: RowCountPlan, sql: String?) = await MainActor.run { + guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return (.skip, nil) } + let plan = Self.rowCountPlan( + isNonSQL: isNonSQL, + filterState: tab.filterState, + approximateRowCount: tab.pagination.totalRowCount, + threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan + ) + guard case let .exactCount(filtered) = plan else { return (plan, nil) } + let sql = parent.queryBuilder.buildFilteredCountQuery( + tableName: tableName, + schemaName: tab.tableContext.schemaName, + filters: filtered ? tab.filterState.appliedFilters : [], + logicMode: tab.filterState.filterLogicMode + ) + return (plan, sql) } let outcome: RowCountOutcome - switch plan { + switch prepared.plan { case .skip: return + case .clear: + outcome = .clear case .approximate: guard let count = try? await driver.fetchApproximateRowCount(table: tableName) else { return } outcome = .count(count, isApproximate: true) @@ -377,7 +374,8 @@ extension QueryExecutionCoordinator { } else { outcome = .clear } - case let .exactSQL(sql): + case .exactCount: + guard let sql = prepared.sql else { return } do { let result = try await driver.execute(query: sql) guard let countStr = result.rows.first?.first?.asText, let count = Int(countStr) else { return } @@ -404,6 +402,24 @@ extension QueryExecutionCoordinator { } } + static func rowCountPlan( + isNonSQL: Bool, + filterState: TabFilterState, + approximateRowCount: Int?, + threshold: Int + ) -> RowCountPlan { + if isNonSQL { + return filterState.hasAppliedFilters + ? .filteredNonSQL(filters: filterState.appliedFilters, logicMode: filterState.filterLogicMode) + : .approximate + } + let exceedsThreshold = (approximateRowCount ?? 0) >= threshold + if filterState.hasAppliedFilters { + return exceedsThreshold ? .clear : .exactCount(filtered: true) + } + return exceedsThreshold ? .skip : .exactCount(filtered: false) + } + func handleQueryExecutionError( _ error: Error, sql: String, @@ -474,10 +490,11 @@ extension QueryExecutionCoordinator { } } -private enum RowCountPlan { +enum RowCountPlan: Equatable { case skip + case clear case approximate - case exactSQL(String) + case exactCount(filtered: Bool) case filteredNonSQL(filters: [TableFilter], logicMode: FilterLogicMode) } diff --git a/TableProTests/Core/Coordinators/RowCountPlanTests.swift b/TableProTests/Core/Coordinators/RowCountPlanTests.swift new file mode 100644 index 000000000..0fb47756e --- /dev/null +++ b/TableProTests/Core/Coordinators/RowCountPlanTests.swift @@ -0,0 +1,83 @@ +// +// RowCountPlanTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("RowCountPlan") +@MainActor +struct RowCountPlanTests { + private func filtered() -> TabFilterState { + var state = TabFilterState() + state.appliedFilters = [TestFixtures.makeTableFilter()] + return state + } + + @Test("Unfiltered small table runs an exact unfiltered count") + func unfilteredSmall() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: TabFilterState(), approximateRowCount: 100, threshold: 100_000 + ) + #expect(plan == .exactCount(filtered: false)) + } + + @Test("Unfiltered large table skips the exact count and keeps the estimate") + func unfilteredLarge() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: TabFilterState(), approximateRowCount: 5_000_000, threshold: 100_000 + ) + #expect(plan == .skip) + } + + @Test("Unfiltered unknown size runs an exact count") + func unfilteredUnknownSize() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: TabFilterState(), approximateRowCount: nil, threshold: 100_000 + ) + #expect(plan == .exactCount(filtered: false)) + } + + @Test("Filtered small table runs an exact filtered count") + func filteredSmall() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: filtered(), approximateRowCount: 100, threshold: 100_000 + ) + #expect(plan == .exactCount(filtered: true)) + } + + @Test("Filtered large table clears the count instead of counting a huge table") + func filteredLarge() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: filtered(), approximateRowCount: 5_000_000, threshold: 100_000 + ) + #expect(plan == .clear) + } + + @Test("Filtered unknown size runs an exact filtered count") + func filteredUnknownSize() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: filtered(), approximateRowCount: nil, threshold: 100_000 + ) + #expect(plan == .exactCount(filtered: true)) + } + + @Test("Non-SQL unfiltered uses the approximate count") + func nonSQLUnfiltered() { + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: true, filterState: TabFilterState(), approximateRowCount: nil, threshold: 100_000 + ) + #expect(plan == .approximate) + } + + @Test("Non-SQL filtered defers to the driver filtered count") + func nonSQLFiltered() { + let state = filtered() + let plan = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: true, filterState: state, approximateRowCount: nil, threshold: 100_000 + ) + #expect(plan == .filteredNonSQL(filters: state.appliedFilters, logicMode: state.filterLogicMode)) + } +}