diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40f0ce817..850a04800 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.
- Reopening a table now restores the filter you had applied, instead of clearing it. Filters are remembered per connection. (#1347)
- 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.
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 8384536db..5a98cecb0 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..cb510ec27 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,57 +319,107 @@ 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 }
+ guard !parent.isTearingDown else { return }
+ guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return }
+
+ 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 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 outcome: RowCountOutcome
+ 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)
+ 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
}
-
- let quotedTable = mainDriver.quoteIdentifier(tableName)
+ case .exactCount:
+ guard let sql = prepared.sql else { return }
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
}
}
}
}
}
+ 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,
@@ -481,3 +489,16 @@ extension QueryExecutionCoordinator {
parent.runQuery()
}
}
+
+enum RowCountPlan: Equatable {
+ case skip
+ case clear
+ case approximate
+ case exactCount(filtered: Bool)
+ 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/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))
+ }
+}
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