diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ddcbe1..59efb453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix macOS HIG compliance: system colors, accessibility labels, theme tokens, localization - Fix idle ping spin loop caused by exhausted AsyncStream iterator (#618) +- Skip exact row count for large tables — use database statistics estimate (#519) ## [0.28.0] - 2026-04-07 diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 500195c8..c1993edf 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -122,6 +122,7 @@ struct DataGridSettings: Codable, Equatable { var showRowNumbers: Bool var autoShowInspector: Bool var enableSmartValueDetection: Bool + var countRowsIfEstimateLessThan: Int static let `default` = DataGridSettings( rowHeight: .normal, @@ -131,7 +132,8 @@ struct DataGridSettings: Codable, Equatable { showAlternateRows: true, showRowNumbers: true, autoShowInspector: false, - enableSmartValueDetection: true + enableSmartValueDetection: true, + countRowsIfEstimateLessThan: 100_000 ) init( @@ -142,7 +144,8 @@ struct DataGridSettings: Codable, Equatable { showAlternateRows: Bool = true, showRowNumbers: Bool = true, autoShowInspector: Bool = false, - enableSmartValueDetection: Bool = true + enableSmartValueDetection: Bool = true, + countRowsIfEstimateLessThan: Int = 100_000 ) { self.rowHeight = rowHeight self.dateFormat = dateFormat @@ -152,6 +155,7 @@ struct DataGridSettings: Codable, Equatable { self.showRowNumbers = showRowNumbers self.autoShowInspector = autoShowInspector self.enableSmartValueDetection = enableSmartValueDetection + self.countRowsIfEstimateLessThan = countRowsIfEstimateLessThan } init(from decoder: Decoder) throws { @@ -165,6 +169,7 @@ struct DataGridSettings: Codable, Equatable { showRowNumbers = try container.decodeIfPresent(Bool.self, forKey: .showRowNumbers) ?? true autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false enableSmartValueDetection = try container.decodeIfPresent(Bool.self, forKey: .enableSmartValueDetection) ?? true + countRowsIfEstimateLessThan = try container.decodeIfPresent(Int.self, forKey: .countRowsIfEstimateLessThan) ?? 100_000 } // MARK: - Validated Properties diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 98b4076b..c97d7ad4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -253,9 +253,23 @@ extension MainContentCoordinator { guard let mainDriver = DatabaseManager.shared.driver(for: connectionId) else { return } let count: Int? + let isApproximate: Bool if isNonSQL { count = try? await mainDriver.fetchApproximateRowCount(table: tableName) + isApproximate = true } else { + // Skip exact COUNT(*) if the approximate count exceeds the threshold. + // PostgreSQL COUNT(*) requires a full sequential scan (MVCC) and can take + // 10-20+ seconds on multi-million-row tables. Industry standard (TablePlus, + // pgAdmin, DBeaver) is to use estimates for large tables. + let threshold = await AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan + let approxCount = await MainActor.run { + self.tabManager.tabs.first { $0.id == tabId }?.pagination.totalRowCount + } + if let approx = approxCount, approx >= threshold { + return // Keep approximate count — skip expensive COUNT(*) + } + let quotedTable = mainDriver.quoteIdentifier(tableName) let countResult = try? await mainDriver.execute( query: "SELECT COUNT(*) FROM \(quotedTable)" @@ -266,6 +280,7 @@ extension MainContentCoordinator { } else { count = nil } + isApproximate = false } if let count { @@ -274,7 +289,7 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].pagination.totalRowCount = count - tabManager.tabs[idx].pagination.isApproximateRowCount = isNonSQL + tabManager.tabs[idx].pagination.isApproximateRowCount = isApproximate } } } @@ -346,9 +361,19 @@ extension MainContentCoordinator { guard let mainDriver = DatabaseManager.shared.driver(for: 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.tabManager.tabs.first { $0.id == tabId }?.pagination.totalRowCount + } + if let approx = approxCount, approx >= threshold { + return + } + let quotedTable = mainDriver.quoteIdentifier(tableName) let countResult = try? await mainDriver.execute( query: "SELECT COUNT(*) FROM \(quotedTable)" @@ -359,6 +384,7 @@ extension MainContentCoordinator { } else { count = nil } + isApproximate = false } if let count { @@ -366,7 +392,7 @@ extension MainContentCoordinator { guard let self else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].pagination.totalRowCount = count - tabManager.tabs[idx].pagination.isApproximateRowCount = isNonSQL + tabManager.tabs[idx].pagination.isApproximateRowCount = isApproximate } } } diff --git a/TablePro/Views/Settings/DataGridSettingsView.swift b/TablePro/Views/Settings/DataGridSettingsView.swift index 0fe14140..93cb155c 100644 --- a/TablePro/Views/Settings/DataGridSettingsView.swift +++ b/TablePro/Views/Settings/DataGridSettingsView.swift @@ -56,6 +56,15 @@ struct DataGridSettingsView: View { Text("5,000 rows").tag(5_000) Text("10,000 rows").tag(10_000) } + + Picker(String(localized: "Count rows if estimate less than:"), selection: $settings.countRowsIfEstimateLessThan) { + Text("1,000").tag(1_000) + Text("10,000").tag(10_000) + Text("100,000").tag(100_000) + Text("1,000,000").tag(1_000_000) + Text(String(localized: "Always count")).tag(Int.max) + } + .help(String(localized: "Tables with more estimated rows use approximate counts to avoid slow COUNT(*) queries")) } } .formStyle(.grouped) diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 3156c03c..995efa9e 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -210,6 +210,14 @@ Options: Larger page sizes use more memory but show more data at once. +### Row Count Estimation + +| Setting | Options | Default | +|---------|---------|---------| +| Count rows if estimate less than | 1,000 / 10,000 / 100,000 / 1,000,000 / Always count | 100,000 | + +Tables above this threshold show an estimated count (`~3,000,000 rows`) from database statistics instead of running `COUNT(*)`. Useful for large PostgreSQL tables where `COUNT(*)` requires a full table scan. + **Validation ranges:** - **Page Size**: 10 -- 100,000 rows (values outside this range are clamped)