diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38c920c93..5f6d448ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Query execution no longer rewrites user SQL. Result safety cap is applied at the row level instead of via query rewrite. When a result is capped, the status bar shows the loaded row count with a Fetch All button. Load More on user-query tabs is removed; table-view pagination is unchanged.
+- Driver protocol: removed fetchFirstPage, fetchNextPage, fetchRows, fetchRowCount and their parameterized variants. Replaced with executeUserQuery(query:rowCap:parameters:). PluginKit ABI bumped to 9. Separately distributed plugins (Oracle, DuckDB, MSSQL, MongoDB, BigQuery, LibSQL, Cassandra, Etcd, CloudflareD1, DynamoDB) require update before use with this version.
+- Settings renamed: `enforceQueryResultLimit` is now `truncateQueryResults`, `queryResultLimit` is now `queryResultRowCap`. Custom values revert to default on first launch after upgrade.
- MCP server lazy-starts on first external request. Manual enable in Settings is no longer required
- Settings tab renamed from "MCP" to "Integrations" with new sections for connected clients, activity log, and pairing
- Integrations settings: rename MCP Server section to Integrations, restructure with searchable activity log, native list with keyboard navigation, accessibility labels, color-blind-safe status icons.
@@ -86,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- SELECT queries with a user-written LIMIT now return the requested row count. Previously the query engine stripped the user's LIMIT and substituted its own cap, so `LIMIT 10` could return up to 10,000 rows. Affected SQLite, DuckDB, LibSQL, ClickHouse, Redshift, CloudflareD1, and the MCP query path. Mirror bug on MSSQL and Oracle silently injected an ORDER BY into queries that lacked one. (#956)
- New tab from the empty "no tabs open" state opened a separate window-tab next to the placeholder instead of replacing it. The toolbar `+`, ⌘T, and the native NSWindow tab `+` now add the new query to the current empty window when its tab manager has no tabs.
- File associations for `.sql`, `.sqlite`, `.duckdb`, and related extensions disabled in Finder's Open With menu. The custom UTIs (`com.tablepro.sql`, `com.tablepro.sqlite-db`, `com.tablepro.duckdb`) were declared under `UTImportedTypeDeclarations` instead of `UTExportedTypeDeclarations`, so Launch Services treated them as "imported" claims and ranked them below other apps. SQL is now `LSHandlerRank: Owner`, SQLite is `Default`, DuckDB is `Owner`/`Editor`, and `com.tablepro.sqlite-db` conforms to `com.apple.sqlite3`.
- Crash on macOS 26 when opening SQL Preview (NSColor.cgColor calls deprecated colorSpaceName)
diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
index 98d95d3d1..2b500a9a4 100644
--- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
+++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
@@ -239,122 +239,6 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
)
}
- func fetchRowCount(query: String) async throws -> Int {
- guard let conn = connection else {
- throw BigQueryError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if BigQueryQueryBuilder.isTaggedQuery(trimmed) {
- if let params = BigQueryQueryBuilder.decode(trimmed) {
- let dataset = resolveDataset(from: params)
- let columns = lock.withLock { _columnCache["\(dataset).\(params.table)"] } ?? []
- let resolvedParams = BigQueryQueryParams(
- table: params.table, dataset: dataset, sortColumns: params.sortColumns,
- limit: params.limit, offset: params.offset, filters: params.filters,
- logicMode: params.logicMode, searchText: params.searchText, searchColumns: params.searchColumns
- )
- let countSQL = BigQueryQueryBuilder.buildCountSQL(
- from: resolvedParams, projectId: conn.projectId, columns: columns
- )
- let result = try await conn.executeQuery(countSQL, defaultDataset: dataset)
- if let row = result.queryResponse.rows?.first, let cell = row.f?.first,
- case .string(let val) = cell.v, let count = Int(val)
- {
- return count
- }
- }
- return 0
- }
-
- let dataset = lock.withLock { _currentDataset }
- let countSQL = "SELECT COUNT(*) FROM (\(trimmed))"
- let result = try await conn.executeQuery(countSQL, defaultDataset: dataset)
- if let row = result.queryResponse.rows?.first, let cell = row.f?.first,
- case .string(let val) = cell.v, let count = Int(val)
- {
- return count
- }
- return 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let startTime = Date()
-
- guard let conn = connection else {
- throw BigQueryError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if BigQueryQueryBuilder.isTaggedQuery(trimmed) {
- if let decoded = BigQueryQueryBuilder.decode(trimmed) {
- let dataset = resolveDataset(from: decoded)
- let params = BigQueryQueryParams(
- table: decoded.table,
- dataset: dataset,
- sortColumns: decoded.sortColumns,
- limit: limit,
- offset: offset,
- filters: decoded.filters,
- logicMode: decoded.logicMode,
- searchText: decoded.searchText,
- searchColumns: decoded.searchColumns
- )
- let columns = lock.withLock { _columnCache["\(dataset).\(params.table)"] } ?? []
- let sql = BigQueryQueryBuilder.buildSQL(
- from: params, projectId: conn.projectId, columns: columns
- )
- let result = try await conn.executeQuery(sql, defaultDataset: dataset)
-
- if let schema = result.queryResponse.schema, let fields = schema.fields {
- let colNames = fields.map(\.name)
- let typeNames = BigQueryTypeMapper.columnTypeNames(from: schema)
- let rows = BigQueryTypeMapper.flattenRows(from: result.queryResponse, schema: schema)
- return PluginQueryResult(
- columns: colNames,
- columnTypeNames: typeNames,
- rows: rows,
- rowsAffected: 0,
- executionTime: Date().timeIntervalSince(startTime),
- statusMessage: buildCostMessage(result)
- )
- }
- }
- return PluginQueryResult.empty
- }
-
- // For ad-hoc SQL, wrap with LIMIT/OFFSET
- let dataset = lock.withLock { _currentDataset }
- let cleaned = trimmed.replacingOccurrences(
- of: ";\\s*\\z", with: "", options: .regularExpression
- )
- let strippedSQL = cleaned.replacingOccurrences(
- of: "\\s+LIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*\\z",
- with: "",
- options: [.regularExpression, .caseInsensitive]
- )
- let paginatedSQL = "\(strippedSQL) LIMIT \(limit) OFFSET \(offset)"
- let result = try await conn.executeQuery(paginatedSQL, defaultDataset: dataset)
-
- if let schema = result.queryResponse.schema, let fields = schema.fields {
- let colNames = fields.map(\.name)
- let typeNames = BigQueryTypeMapper.columnTypeNames(from: schema)
- let rows = BigQueryTypeMapper.flattenRows(from: result.queryResponse, schema: schema)
- return PluginQueryResult(
- columns: colNames,
- columnTypeNames: typeNames,
- rows: rows,
- rowsAffected: 0,
- executionTime: Date().timeIntervalSince(startTime),
- statusMessage: buildCostMessage(result)
- )
- }
-
- return PluginQueryResult.empty
- }
-
// MARK: - Query Cancellation
func cancelQuery() throws {
@@ -730,15 +614,9 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
from: resolvedParams, projectId: conn.projectId, columns: columns
)
} else {
- var cleaned = trimmed.replacingOccurrences(
+ sql = trimmed.replacingOccurrences(
of: ";\\s*\\z", with: "", options: .regularExpression
)
- cleaned = cleaned.replacingOccurrences(
- of: "\\s+LIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*\\z",
- with: "",
- options: [.regularExpression, .caseInsensitive]
- )
- sql = cleaned
}
let jobInfo = try await conn.executeJobAndWait(sql, defaultDataset: dataset)
diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/BigQueryDriverPlugin/Info.plist
+++ b/Plugins/BigQueryDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/CSVExportPlugin/Info.plist
+++ b/Plugins/CSVExportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift
index 173ef74c0..aeea0c3f0 100644
--- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift
+++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift
@@ -962,25 +962,6 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
}
}
- // MARK: - Pagination
-
- func fetchRowCount(query: String) async throws -> Int {
- // CQL does not support subqueries, so we can't wrap an arbitrary query in SELECT COUNT(*) FROM (...).
- // Return -1 to signal unknown count; the UI will hide the total page count.
- -1
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- // CQL does not support OFFSET. Only the first page (offset=0) can be fetched via simple LIMIT.
- // For offset>0, throw so the caller knows pagination is unsupported for arbitrary queries.
- if offset > 0 {
- throw CassandraPluginError.unsupportedOperation
- }
- let baseQuery = stripTrailingSemicolon(query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit)"
- return try await execute(query: paginatedQuery)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist
index 1d6eb773e..8f1035392 100644
--- a/Plugins/CassandraDriverPlugin/Info.plist
+++ b/Plugins/CassandraDriverPlugin/Info.plist
@@ -19,7 +19,7 @@
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
TableProPluginKitVersion
- 8
+ 9
NSPrincipalClass
$(PRODUCT_MODULE_NAME).CassandraPlugin
diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
index 4fb380ca2..cda76def5 100644
--- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
+++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
@@ -274,28 +274,6 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}
- func fetchRowCount(query: String) async throws -> Int {
- let countQuery = "SELECT count() FROM (\(query)) AS __cnt"
- let result = try await execute(query: countQuery)
- guard let row = result.rows.first,
- let cell = row.first,
- let str = cell,
- let count = Int(str) else {
- return 0
- }
- return count
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- var base = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while base.hasSuffix(";") {
- base = String(base.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- }
- base = stripLimitOffset(from: base)
- let paginated = "\(base) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginated)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
@@ -1049,37 +1027,6 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return result
}
- private func stripLimitOffset(from query: String) -> String {
- let ns = query as NSString
- let len = ns.length
- guard len > 0 else { return query }
-
- let upper = query.uppercased() as NSString
- var depth = 0
- var i = len - 1
-
- while i >= 4 {
- let ch = upper.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 4
- if start >= 0 {
- let candidate = upper.substring(with: NSRange(location: start, length: 5))
- if candidate == "LIMIT" {
- if start == 0 || CharacterSet.whitespacesAndNewlines
- .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) {
- return ns.substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- }
- i -= 1
- }
- return query
- }
-
/// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params.
private static func buildClickHouseParams(
query: String,
diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/ClickHouseDriverPlugin/Info.plist
+++ b/Plugins/ClickHouseDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
index 8dda2807a..dae481f74 100644
--- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
+++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
@@ -192,9 +192,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
throw CloudflareD1Error.notConnected
}
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let baseQuery = stripLimitOffset(from: trimmed)
- let payload = try await client.executeRaw(sql: baseQuery)
+ let payload = try await client.executeRaw(sql: query)
let columns = payload.results.columns ?? []
continuation.yield(.header(PluginStreamHeader(
@@ -212,22 +210,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
continuation.finish()
}
- // MARK: - Pagination
-
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) _t"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
@@ -806,37 +788,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
)
}
- private func stripLimitOffset(from query: String) -> String {
- let ns = query as NSString
- let len = ns.length
- guard len > 0 else { return query }
-
- let upper = query.uppercased() as NSString
- var depth = 0
- var i = len - 1
-
- while i >= 4 {
- let ch = upper.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 4
- if start >= 0 {
- let candidate = upper.substring(with: NSRange(location: start, length: 5))
- if candidate == "LIMIT" {
- if start == 0 || CharacterSet.whitespacesAndNewlines
- .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) {
- return ns.substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- }
- i -= 1
- }
- return query
- }
-
private func formatDDL(_ ddl: String) -> String {
guard ddl.uppercased().hasPrefix("CREATE TABLE") else {
return ddl
diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist
index f31523c1a..eaede480b 100644
--- a/Plugins/CloudflareD1DriverPlugin/Info.plist
+++ b/Plugins/CloudflareD1DriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
index 932ad7d8d..fa0688c1d 100644
--- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
+++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
@@ -702,13 +702,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Streaming
func streamRows(query: String) -> AsyncThrowingStream {
- let baseQuery = stripLimitOffset(from: query)
let actor = connectionActor
return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
Task {
do {
- try await actor.streamQuery(baseQuery, continuation: continuation)
+ try await actor.streamQuery(query, continuation: continuation)
} catch {
continuation.finish(throwing: error)
}
@@ -716,22 +715,6 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}
- // MARK: - Pagination
-
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) AS _count_subquery"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
@@ -1112,95 +1095,6 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
value.replacingOccurrences(of: "\"", with: "\"\"")
}
- private func stripLimitOffset(from query: String) -> String {
- var result = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- // Strip trailing semicolons
- while result.hasSuffix(";") {
- result = String(result.dropLast()).trimmingCharacters(in: .whitespaces)
- }
-
- // Only strip LIMIT/OFFSET at the top level (depth 0) from the end.
- // Strip OFFSET first (comes after LIMIT), then LIMIT.
- for keyword in ["OFFSET", "LIMIT"] {
- let upper = result.uppercased() as NSString
- if let pos = findLastTopLevelKeyword(keyword, upper: upper, length: upper.length) {
- result = (result as NSString).substring(to: pos)
- .trimmingCharacters(in: .whitespaces)
- }
- }
-
- return result
- }
-
- private func findLastTopLevelKeyword(
- _ keyword: String,
- upper: NSString,
- length: Int
- ) -> Int? {
- let keyLen = keyword.count
- let parenOpen = UInt16(UnicodeScalar("(").value)
- let parenClose = UInt16(UnicodeScalar(")").value)
- let singleQuote = UInt16(UnicodeScalar("'").value)
- let doubleQuote = UInt16(UnicodeScalar("\"").value)
-
- var depth = 0
- var inString = false
- var inIdentifier = false
- var i = length - 1
-
- while i >= 0 {
- let ch = upper.character(at: i)
-
- if inString {
- if ch == singleQuote {
- if i > 0 && upper.character(at: i - 1) == singleQuote {
- i -= 1
- } else {
- inString = false
- }
- }
- } else if inIdentifier {
- if ch == doubleQuote {
- if i > 0 && upper.character(at: i - 1) == doubleQuote {
- i -= 1
- } else {
- inIdentifier = false
- }
- }
- } else {
- if ch == singleQuote {
- inString = true
- } else if ch == doubleQuote {
- inIdentifier = true
- } else if ch == parenClose {
- depth += 1
- } else if ch == parenOpen {
- depth -= 1
- } else if depth == 0 {
- let start = i - keyLen + 1
- if start >= 0 {
- let candidate = upper.substring(with: NSRange(location: start, length: keyLen))
- if candidate == keyword {
- let beforeOk = start == 0 || {
- guard let scalar = Unicode.Scalar(upper.character(at: start - 1)) else {
- return false
- }
- return CharacterSet.whitespaces.contains(scalar)
- }()
- if beforeOk {
- return start
- }
- }
- }
- }
- }
- i -= 1
- }
-
- return nil
- }
-
private func fetchPrimaryKeyColumns(
table: String,
schema: String
diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/DuckDBDriverPlugin/Info.plist
+++ b/Plugins/DuckDBDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
index f272f9a7f..da7c3302f 100644
--- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
+++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
@@ -185,73 +185,6 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send
)
}
- func fetchRowCount(query: String) async throws -> Int {
- guard let conn = connection else {
- throw DynamoDBError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if let parsed = DynamoDBQueryBuilder.parseCountQuery(trimmed) {
- if let col = parsed.filterColumn, let op = parsed.filterOp, let val = parsed.filterValue {
- let filters = [DynamoDBFilterSpec(column: col, op: op, value: val)]
- return try await countFilteredScanItems(
- tableName: parsed.tableName, conn: conn,
- filters: filters, logicMode: "AND"
- )
- }
- return try await countItems(tableName: parsed.tableName, conn: conn)
- }
-
- if let parsed = DynamoDBQueryBuilder.parseScanQuery(trimmed) {
- if !parsed.filters.isEmpty {
- return try await countFilteredScanItems(
- tableName: parsed.tableName, conn: conn,
- filters: parsed.filters, logicMode: parsed.logicMode
- )
- }
- return try await countItems(tableName: parsed.tableName, conn: conn)
- }
-
- if let parsed = DynamoDBQueryBuilder.parseQueryQuery(trimmed) {
- return try await countQueryItems(parsed: parsed, conn: conn)
- }
-
- // For raw PartiQL, try to get count from table name
- if let tableName = DynamoDBPartiQLParser.extractTableName(trimmed) {
- return try await countItems(tableName: tableName, conn: conn)
- }
-
- return 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let startTime = Date()
-
- guard let conn = connection else {
- throw DynamoDBError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if DynamoDBQueryBuilder.isTaggedQuery(trimmed) {
- return try await executePaginatedTaggedQuery(
- trimmed, offset: offset, limit: limit, conn: conn, startTime: startTime
- )
- }
-
- // For raw PartiQL, execute and paginate client-side
- let fullResult = try await execute(query: trimmed)
- let pageRows = Array(fullResult.rows.dropFirst(offset).prefix(limit))
- return PluginQueryResult(
- columns: fullResult.columns,
- columnTypeNames: fullResult.columnTypeNames,
- rows: pageRows,
- rowsAffected: fullResult.rowsAffected,
- executionTime: Date().timeIntervalSince(startTime)
- )
- }
-
// MARK: - Query Cancellation
func cancelQuery() throws {
diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/DynamoDBDriverPlugin/Info.plist
+++ b/Plugins/DynamoDBDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
index accb468ce..c17852b65 100644
--- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
+++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
@@ -132,49 +132,6 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
try await execute(query: query)
}
- func fetchRowCount(query: String) async throws -> Int {
- guard let client = httpClient else {
- throw EtcdError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if let parsed = EtcdQueryBuilder.parseRangeQuery(trimmed) {
- return try await countKeys(prefix: parsed.prefix, filterType: parsed.filterType, filterValue: parsed.filterValue, client: client)
- }
-
- if let parsed = EtcdQueryBuilder.parseCountQuery(trimmed) {
- return try await countKeys(prefix: parsed.prefix, filterType: parsed.filterType, filterValue: parsed.filterValue, client: client)
- }
-
- return 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let startTime = Date()
-
- guard let client = httpClient else {
- throw EtcdError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if let parsed = EtcdQueryBuilder.parseRangeQuery(trimmed) {
- return try await fetchKeysPage(
- prefix: parsed.prefix,
- offset: offset,
- limit: limit,
- sortAscending: parsed.sortAscending,
- filterType: parsed.filterType,
- filterValue: parsed.filterValue,
- client: client,
- startTime: startTime
- )
- }
-
- return try await execute(query: query)
- }
-
// MARK: - Streaming
func streamRows(query: String) -> AsyncThrowingStream {
diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/EtcdDriverPlugin/Info.plist
+++ b/Plugins/EtcdDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/JSONExportPlugin/Info.plist
+++ b/Plugins/JSONExportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist
index f31523c1a..eaede480b 100644
--- a/Plugins/LibSQLDriverPlugin/Info.plist
+++ b/Plugins/LibSQLDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
index 43a8f9fca..0a10d7f97 100644
--- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
+++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
@@ -170,9 +170,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
throw LibSQLError.notConnected
}
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let baseQuery = stripLimitOffset(from: trimmed)
- let result = try await client.execute(sql: baseQuery)
+ let result = try await client.execute(sql: query)
let columns = result.cols.map(\.name)
let columnTypeNames = result.cols.map { $0.decltype ?? "" }
@@ -190,22 +188,6 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
continuation.finish()
}
- // MARK: - Pagination
-
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) _t"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
@@ -700,37 +682,6 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}
- private func stripLimitOffset(from query: String) -> String {
- let ns = query as NSString
- let len = ns.length
- guard len > 0 else { return query }
-
- let upper = query.uppercased() as NSString
- var depth = 0
- var i = len - 1
-
- while i >= 4 {
- let ch = upper.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 4
- if start >= 0 {
- let candidate = upper.substring(with: NSRange(location: start, length: 5))
- if candidate == "LIMIT" {
- if start == 0 || CharacterSet.whitespacesAndNewlines
- .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) {
- return ns.substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- }
- i -= 1
- }
- return query
- }
-
private func formatDDL(_ ddl: String) -> String {
guard ddl.uppercased().hasPrefix("CREATE TABLE") else {
return ddl
diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/MQLExportPlugin/Info.plist
+++ b/Plugins/MQLExportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/MSSQLDriverPlugin/Info.plist
+++ b/Plugins/MSSQLDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
index cd715527f..38d34cd06 100644
--- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
+++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
@@ -812,16 +812,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
guard let conn = freeTDSConn else {
return AsyncThrowingStream { $0.finish(throwing: MSSQLPluginError.notConnected) }
}
- var baseQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while baseQuery.hasSuffix(";") {
- baseQuery = String(baseQuery.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- }
- baseQuery = stripMSSQLOffsetFetch(from: baseQuery)
- let queryToRun = baseQuery
return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
let streamTask = Task {
do {
- try await conn.streamQuery(queryToRun, continuation: continuation)
+ try await conn.streamQuery(query, continuation: continuation)
} catch {
continuation.finish(throwing: error)
}
@@ -860,29 +854,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return try await execute(query: sql)
}
- func fetchRowCount(query: String) async throws -> Int {
- let countQuery = "SELECT COUNT_BIG(*) FROM (\(query)) AS __cnt"
- let result = try await execute(query: countQuery)
- guard let row = result.rows.first,
- let cell = row.first,
- let str = cell,
- let count = Int(str) else {
- return 0
- }
- return count
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- var base = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while base.hasSuffix(";") {
- base = String(base.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- }
- base = stripMSSQLOffsetFetch(from: base)
- let orderBy = hasTopLevelOrderBy(base) ? "" : " ORDER BY (SELECT NULL)"
- let paginated = "\(base)\(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
- return try await execute(query: paginated)
- }
-
func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "'", with: "''")
let escapedTable = table.replacingOccurrences(of: "'", with: "''")
@@ -1601,28 +1572,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return raw.replacingOccurrences(of: "'", with: "''")
}
- private func hasTopLevelOrderBy(_ query: String) -> Bool {
- let ns = query.uppercased() as NSString
- let len = ns.length
- guard len >= 8 else { return false }
- var depth = 0
- var i = len - 1
- while i >= 7 {
- let ch = ns.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x59 {
- let start = i - 7
- if start >= 0 {
- let candidate = ns.substring(with: NSRange(location: start, length: 8))
- if candidate == "ORDER BY" { return true }
- }
- }
- i -= 1
- }
- return false
- }
-
// MARK: - Create Table DDL
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? {
@@ -1795,30 +1744,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return stmts.isEmpty ? nil : stmts
}
- private func stripMSSQLOffsetFetch(from query: String) -> String {
- let ns = query.uppercased() as NSString
- let len = ns.length
- guard len >= 6 else { return query }
- var depth = 0
- var i = len - 1
- while i >= 5 {
- let ch = ns.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 5
- if start >= 0 {
- let candidate = ns.substring(with: NSRange(location: start, length: 6))
- if candidate == "OFFSET" {
- return (query as NSString).substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- i -= 1
- }
- return query
- }
}
// MARK: - Errors
diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/MongoDBDriverPlugin/Info.plist
+++ b/Plugins/MongoDBDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
index f1f81f23d..5241b7b75 100644
--- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
+++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
@@ -135,60 +135,6 @@ final class MongoDBPluginDriver: PluginDatabaseDriver {
mongoConnection?.cancelCurrentQuery()
}
- // MARK: - Paginated Query Support
-
- func fetchRowCount(query: String) async throws -> Int {
- guard let conn = mongoConnection else {
- throw MongoDBPluginError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let db = currentDb
- let operation = try MongoShellParser.parse(trimmed)
-
- switch operation {
- case .find(let collection, let filter, _):
- let count = try await conn.countDocuments(database: db, collection: collection, filter: filter)
- return Int(count)
- case .findOne:
- return 1
- case .aggregate(let collection, let pipeline):
- let result = try await conn.aggregate(database: db, collection: collection, pipeline: pipeline)
- return result.docs.count
- case .countDocuments(let collection, let filter):
- let count = try await conn.countDocuments(database: db, collection: collection, filter: filter)
- return Int(count)
- default:
- return 0
- }
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let startTime = Date()
-
- guard let conn = mongoConnection else {
- throw MongoDBPluginError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let db = currentDb
- let operation = try MongoShellParser.parse(trimmed)
-
- switch operation {
- case .find(let collection, let filter, var options):
- options.skip = offset
- options.limit = limit
- let result = try await conn.find(
- database: db, collection: collection, filter: filter,
- sort: options.sort, projection: options.projection,
- skip: offset, limit: limit
- )
- return buildPluginResult(from: result.docs, startTime: startTime, isTruncated: result.isTruncated)
- default:
- return try await executeOperation(operation, connection: conn, startTime: startTime)
- }
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/MySQLDriverPlugin/Info.plist
+++ b/Plugins/MySQLDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
index fbecc7911..a9c242f24 100644
--- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
+++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
@@ -45,8 +45,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
private static let tableNameRegex = try? NSRegularExpression(pattern: "(?i)\\bFROM\\s+[`\"']?([\\w]+)[`\"']?")
- private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+(\\s*,\\s*\\d+)?")
- private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+")
init(config: DriverConnectionConfig) {
self.config = config
@@ -485,83 +483,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}
- // MARK: - Progressive Loading
-
- func fetchFirstPage(query: String, limit: Int) async throws -> PluginPagedResult {
- guard limit > 0 else {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
-
- // If query already has a LIMIT clause, run as-is
- if let regex = Self.limitRegex,
- regex.firstMatch(in: query, range: NSRange(query.startIndex..., in: query)) != nil
- {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
-
- // Inject LIMIT (limit+1) to detect if more rows exist
- let baseQuery = stripLimitOffset(from: query)
- let probeQuery = "\(baseQuery) LIMIT \(limit + 1)"
- let result = try await execute(query: probeQuery)
-
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
-
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
// MARK: - Streaming
func streamRows(query: String) -> AsyncThrowingStream {
guard let conn = mariadbConnection else {
return AsyncThrowingStream { $0.finish(throwing: MariaDBPluginError.notConnected) }
}
- let baseQuery = stripLimitOffset(from: query)
- return conn.streamQuery(baseQuery)
- }
-
- // MARK: - Paginated Query Support
-
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) AS cnt FROM (\(baseQuery)) AS __count_subquery__"
- let result = try await execute(query: countQuery)
-
- guard let firstRow = result.rows.first,
- let countStr = firstRow[safe: 0] ?? nil,
- let count = Int(countStr)
- else { return 0 }
-
- return count
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
+ return conn.streamQuery(query)
}
// MARK: - Database Operations
@@ -1018,19 +946,4 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return columns
}
- private func stripLimitOffset(from query: String) -> String {
- var result = query
-
- if let regex = Self.limitRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
-
- if let regex = Self.offsetRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
-
- return result.trimmingCharacters(in: .whitespacesAndNewlines)
- }
}
diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/OracleDriverPlugin/Info.plist
+++ b/Plugins/OracleDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift
index 536ca4fb9..ea08b096f 100644
--- a/Plugins/OracleDriverPlugin/OraclePlugin.swift
+++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift
@@ -230,18 +230,10 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return AsyncThrowingStream { $0.finish(throwing: OracleError.notConnected) }
}
- var effectiveQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while effectiveQuery.hasSuffix(";") {
- effectiveQuery = String(effectiveQuery.dropLast())
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- effectiveQuery = stripOracleOffsetFetch(from: effectiveQuery)
-
return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
- let queryToRun = effectiveQuery
let streamTask = Task {
do {
- try await conn.streamQuery(queryToRun, continuation: continuation)
+ try await conn.streamQuery(query, continuation: continuation)
} catch {
continuation.finish(throwing: error)
}
@@ -252,29 +244,6 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}
- func fetchRowCount(query: String) async throws -> Int {
- let countQuery = "SELECT COUNT(*) FROM (\(query))"
- let result = try await execute(query: countQuery)
- guard let row = result.rows.first,
- let cell = row.first,
- let str = cell,
- let count = Int(str) else {
- return 0
- }
- return count
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- var base = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while base.hasSuffix(";") {
- base = String(base.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- }
- base = stripOracleOffsetFetch(from: base)
- let orderBy = hasTopLevelOrderBy(base) ? "" : " ORDER BY 1"
- let paginated = "\(base)\(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
- return try await execute(query: paginated)
- }
-
// MARK: - Schema Operations
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
@@ -1135,53 +1104,6 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return raw.replacingOccurrences(of: "'", with: "''")
}
- private func hasTopLevelOrderBy(_ query: String) -> Bool {
- let ns = query.uppercased() as NSString
- let len = ns.length
- guard len >= 8 else { return false }
- var depth = 0
- var i = len - 1
- while i >= 7 {
- let ch = ns.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x59 {
- let start = i - 7
- if start >= 0 {
- let candidate = ns.substring(with: NSRange(location: start, length: 8))
- if candidate == "ORDER BY" { return true }
- }
- }
- i -= 1
- }
- return false
- }
-
- private func stripOracleOffsetFetch(from query: String) -> String {
- let ns = query.uppercased() as NSString
- let len = ns.length
- guard len >= 6 else { return query }
- var depth = 0
- var i = len - 1
- while i >= 5 {
- let ch = ns.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 5
- if start >= 0 {
- let candidate = ns.substring(with: NSRange(location: start, length: 6))
- if candidate == "OFFSET" {
- return (query as NSString).substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- i -= 1
- }
- return query
- }
-
private static let fromTableRegex = try? NSRegularExpression(
pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#,
options: .caseInsensitive
diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/PostgreSQLDriverPlugin/Info.plist
+++ b/Plugins/PostgreSQLDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
index f06849013..0cae0f6c0 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
@@ -16,8 +16,6 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private var _currentSchema: String = "public"
private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLPluginDriver")
- private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+")
- private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+")
var currentSchema: String? { _currentSchema }
var supportsSchemas: Bool { true }
@@ -124,66 +122,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
guard let pqConn = libpqConnection else {
return AsyncThrowingStream { $0.finish(throwing: LibPQPluginError.notConnected) }
}
- let baseQuery = stripLimitOffset(from: query)
- return pqConn.streamQuery(baseQuery)
- }
-
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) AS __count_subquery__"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
-
- func fetchFirstPage(query: String, limit: Int) async throws -> PluginPagedResult {
- guard limit > 0 else {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
-
- if let regex = Self.limitRegex,
- regex.firstMatch(in: query, range: NSRange(query.startIndex..., in: query)) != nil
- {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
-
- let baseQuery = stripLimitOffset(from: query)
- let probeQuery = "\(baseQuery) LIMIT \(limit + 1)"
- let result = try await execute(query: probeQuery)
-
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
-
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
+ return pqConn.streamQuery(query)
}
// MARK: - Reconnect
@@ -1312,18 +1251,4 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return stmts.isEmpty ? nil : stmts
}
- // MARK: - Helpers
-
- private func stripLimitOffset(from query: String) -> String {
- var result = query
- if let regex = Self.limitRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
- if let regex = Self.offsetRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
- return result.trimmingCharacters(in: .whitespacesAndNewlines)
- }
}
diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
index e5ee149d9..3c3c4a3b1 100644
--- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
@@ -16,8 +16,6 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private var _currentSchema: String = "public"
private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "RedshiftPluginDriver")
- private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+")
- private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+")
var currentSchema: String? { _currentSchema }
var supportsSchemas: Bool { true }
@@ -117,62 +115,13 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) AS __count_subquery__"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
-
- func fetchFirstPage(query: String, limit: Int) async throws -> PluginPagedResult {
- guard limit > 0 else {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
+ // MARK: - Streaming
- if let regex = Self.limitRegex,
- regex.firstMatch(in: query, range: NSRange(query.startIndex..., in: query)) != nil
- {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
+ func streamRows(query: String) -> AsyncThrowingStream {
+ guard let pqConn = libpqConnection else {
+ return AsyncThrowingStream { $0.finish(throwing: LibPQPluginError.notConnected) }
}
-
- let baseQuery = stripLimitOffset(from: query)
- let probeQuery = "\(baseQuery) LIMIT \(limit + 1)"
- let result = try await execute(query: probeQuery)
-
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
-
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
+ return pqConn.streamQuery(query)
}
// MARK: - Reconnect
@@ -723,18 +672,4 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
"""
}
- // MARK: - Helpers
-
- private func stripLimitOffset(from query: String) -> String {
- var result = query
- if let regex = Self.limitRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
- if let regex = Self.offsetRegex {
- result = regex.stringByReplacingMatches(
- in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
- return result.trimmingCharacters(in: .whitespacesAndNewlines)
- }
}
diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist
index a010dfca7..2b8a3c0bb 100644
--- a/Plugins/RedisDriverPlugin/Info.plist
+++ b/Plugins/RedisDriverPlugin/Info.plist
@@ -19,7 +19,7 @@
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
TableProPluginKitVersion
- 8
+ 9
NSPrincipalClass
$(PRODUCT_MODULE_NAME).RedisPlugin
diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift
index fe9ab2a61..fbdb9ef28 100644
--- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift
+++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift
@@ -94,69 +94,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
try await execute(query: query)
}
- func fetchRowCount(query: String) async throws -> Int {
- guard let conn = redisConnection else {
- throw RedisPluginError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let operation = try RedisCommandParser.parse(trimmed)
-
- switch operation {
- case .scan(_, let pattern, _):
- let keys = try await scanAllKeys(connection: conn, pattern: pattern, maxKeys: Self.maxScanKeys)
- return keys.count
-
- case .keys(let pattern):
- let result = try await conn.executeCommand(["KEYS", pattern])
- return result.arrayValue?.count ?? 0
-
- case .dbsize:
- let result = try await conn.executeCommand(["DBSIZE"])
- return result.intValue ?? 0
-
- default:
- return 0
- }
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let startTime = Date()
- redisConnection?.resetCancellation()
-
- guard let conn = redisConnection else {
- throw RedisPluginError.notConnected
- }
-
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- let operation = try RedisCommandParser.parse(trimmed)
-
- switch operation {
- case .scan(_, let pattern, _):
- let dbIndex = conn.currentDatabase()
- let cacheKey = "\(dbIndex):\(pattern ?? "*")"
- let allKeys: [String]
- if cachedScanPattern == cacheKey, let cached = cachedScanKeys {
- allKeys = cached
- } else {
- allKeys = try await scanAllKeys(
- connection: conn, pattern: pattern, maxKeys: Self.maxScanKeys
- )
- cachedScanPattern = cacheKey
- cachedScanKeys = allKeys
- }
- let pageEnd = min(offset + limit, allKeys.count)
- guard offset < allKeys.count else {
- return buildEmptyKeyResult(startTime: startTime)
- }
- let pageKeys = Array(allKeys[offset ..< pageEnd])
- return try await buildKeyBrowseResult(keys: pageKeys, connection: conn, startTime: startTime)
-
- default:
- return try await executeOperation(operation, connection: conn, startTime: startTime)
- }
- }
-
// MARK: - Query Cancellation
func cancelQuery() throws {
diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/SQLExportPlugin/Info.plist
+++ b/Plugins/SQLExportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/SQLImportPlugin/Info.plist
+++ b/Plugins/SQLImportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/SQLiteDriverPlugin/Info.plist
+++ b/Plugins/SQLiteDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
index 6f34f75a0..2bc91570b 100644
--- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
+++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
@@ -434,8 +434,6 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
nonisolated(unsafe) private var _dbHandleForInterrupt: OpaquePointer?
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLitePluginDriver")
- private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+")
- private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+")
var currentSchema: String? { nil }
var serverVersion: String? { String(cString: sqlite3_libversion()) }
@@ -560,20 +558,62 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
["PRAGMA foreign_keys = ON"]
}
- // MARK: - Pagination
+ // MARK: - User Query
+
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult {
+ if let parameters {
+ let raw = try await executeParameterized(query: query, parameters: parameters)
+ guard let cap = rowCap, cap > 0, raw.rows.count > cap else { return raw }
+ return PluginQueryResult(
+ columns: raw.columns,
+ columnTypeNames: raw.columnTypeNames,
+ rows: Array(raw.rows.prefix(cap)),
+ rowsAffected: raw.rowsAffected,
+ executionTime: raw.executionTime,
+ isTruncated: true,
+ statusMessage: raw.statusMessage
+ )
+ }
- func fetchRowCount(query: String) async throws -> Int {
- let baseQuery = stripLimitOffset(from: query)
- let countQuery = "SELECT COUNT(*) FROM (\(baseQuery))"
- let result = try await execute(query: countQuery)
- guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 }
- return Int(countStr ?? "0") ?? 0
- }
+ let startTime = Date()
+ var columns: [String] = []
+ var columnTypeNames: [String] = []
+ var rows: [[String?]] = []
+ var truncated = false
+
+ let stream = streamRows(query: query)
+ for try await element in stream {
+ switch element {
+ case .header(let header):
+ columns = header.columns
+ columnTypeNames = header.columnTypeNames
+ case .rows(let batch):
+ if let cap = rowCap, cap > 0 {
+ let remaining = cap - rows.count
+ if remaining <= 0 {
+ truncated = true
+ } else if batch.count > remaining {
+ rows.append(contentsOf: batch.prefix(remaining))
+ truncated = true
+ } else {
+ rows.append(contentsOf: batch)
+ }
+ } else {
+ rows.append(contentsOf: batch)
+ }
+ if truncated { break }
+ }
+ if truncated { break }
+ }
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let baseQuery = stripLimitOffset(from: query)
- let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
- return try await execute(query: paginatedQuery)
+ return PluginQueryResult(
+ columns: columns,
+ columnTypeNames: columnTypeNames,
+ rows: rows,
+ rowsAffected: 0,
+ executionTime: Date().timeIntervalSince(startTime),
+ isTruncated: truncated
+ )
}
// MARK: - Schema Operations
@@ -887,22 +927,6 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return path
}
- private func stripLimitOffset(from query: String) -> String {
- var result = query
-
- if let limitRegex = Self.limitRegex {
- let range = NSRange(result.startIndex..., in: result)
- result = limitRegex.stringByReplacingMatches(in: result, range: range, withTemplate: "")
- }
-
- if let offsetRegex = Self.offsetRegex {
- let range = NSRange(result.startIndex..., in: result)
- result = offsetRegex.stringByReplacingMatches(in: result, range: range, withTemplate: "")
- }
-
- return result.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
// MARK: - Create Table DDL
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? {
diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
index e7ae3ee3c..d5f56e1d0 100644
--- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
+++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
@@ -38,8 +38,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
// Queries
func execute(query: String) async throws -> PluginQueryResult
- func fetchRowCount(query: String) async throws -> Int
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult
// Schema
func fetchTables(schema: String?) async throws -> [PluginTableInfo]
@@ -139,12 +138,6 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
// Streaming row fetch for export
func streamRows(query: String) -> AsyncThrowingStream
-
- // Progressive loading
- func fetchFirstPage(query: String, limit: Int) async throws -> PluginPagedResult
- func fetchNextPage(query: String, offset: Int, limit: Int) async throws -> PluginPagedResult
- func fetchFirstPageParameterized(query: String, parameters: [String?], limit: Int) async throws -> PluginPagedResult
- func fetchNextPageParameterized(query: String, parameters: [String?], offset: Int, limit: Int) async throws -> PluginPagedResult
}
public extension PluginDatabaseDriver {
@@ -524,147 +517,24 @@ public extension PluginDatabaseDriver {
return hasDigit
}
- func fetchFirstPage(query: String, limit: Int) async throws -> PluginPagedResult {
- guard limit > 0 else {
- let result = try await execute(query: query)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult {
+ let raw: PluginQueryResult
+ if let parameters {
+ raw = try await executeParameterized(query: query, parameters: parameters)
+ } else {
+ raw = try await execute(query: query)
}
- let result = try await fetchRows(query: query, offset: 0, limit: limit + 1)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
- func fetchNextPage(query: String, offset: Int, limit: Int) async throws -> PluginPagedResult {
- let result = try await fetchRows(query: query, offset: offset, limit: limit + 1)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: offset + rows.count
- )
- }
-
- func fetchFirstPageParameterized(query: String, parameters: [String?], limit: Int) async throws -> PluginPagedResult {
- guard limit > 0 else {
- let result = try await executeParameterized(query: query, parameters: parameters)
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
+ guard let cap = rowCap, cap > 0, raw.rows.count > cap else {
+ return raw
}
- let sanitized = Self.sanitizeQueryForWrapping(query)
- let wrappedQuery = "SELECT * FROM (\(sanitized)) _t LIMIT \(limit + 1) OFFSET 0"
- let result = try await executeParameterized(query: wrappedQuery, parameters: parameters)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
+ return PluginQueryResult(
+ columns: raw.columns,
+ columnTypeNames: raw.columnTypeNames,
+ rows: Array(raw.rows.prefix(cap)),
+ rowsAffected: raw.rowsAffected,
+ executionTime: raw.executionTime,
+ isTruncated: true,
+ statusMessage: raw.statusMessage
)
}
-
- func fetchNextPageParameterized(query: String, parameters: [String?], offset: Int, limit: Int) async throws -> PluginPagedResult {
- let sanitized = Self.sanitizeQueryForWrapping(query)
- let wrappedQuery = "SELECT * FROM (\(sanitized)) _t LIMIT \(limit + 1) OFFSET \(offset)"
- let result = try await executeParameterized(query: wrappedQuery, parameters: parameters)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PluginPagedResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: offset + rows.count
- )
- }
-
- func fetchRowCount(query: String) async throws -> Int {
- let sanitized = Self.sanitizeQueryForWrapping(query)
- let result = try await execute(query: "SELECT COUNT(*) FROM (\(sanitized)) _t")
- guard let firstRow = result.rows.first, let value = firstRow.first, let countStr = value else {
- return 0
- }
- return Int(countStr) ?? 0
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- let sanitized = Self.sanitizeQueryForWrapping(query)
- return try await execute(query: "\(sanitized) LIMIT \(limit) OFFSET \(offset)")
- }
-
- private static func sanitizeQueryForWrapping(_ query: String) -> String {
- var result = query.trimmingCharacters(in: .whitespacesAndNewlines)
- while result.hasSuffix(";") {
- result = String(result.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- }
- return result
- }
-
- func streamRows(query: String) -> AsyncThrowingStream {
- AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
- let task = Task {
- do {
- let batchSize = 1_000
- let firstPage = try await fetchRows(query: query, offset: 0, limit: batchSize)
- continuation.yield(.header(PluginStreamHeader(
- columns: firstPage.columns,
- columnTypeNames: firstPage.columnTypeNames,
- estimatedRowCount: nil
- )))
- if !firstPage.rows.isEmpty {
- continuation.yield(.rows(firstPage.rows))
- }
- if firstPage.rows.count < batchSize {
- continuation.finish()
- return
- }
- await Task.yield()
- var offset = firstPage.rows.count
- while true {
- try Task.checkCancellation()
- let page = try await fetchRows(query: query, offset: offset, limit: batchSize)
- if page.rows.isEmpty { break }
- continuation.yield(.rows(page.rows))
- offset += page.rows.count
- if page.rows.count < batchSize { break }
- await Task.yield()
- }
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
- }
- continuation.onTermination = { @Sendable _ in
- task.cancel()
- }
- }
- }
}
diff --git a/Plugins/TableProPluginKit/PluginPagedResult.swift b/Plugins/TableProPluginKit/PluginPagedResult.swift
deleted file mode 100644
index d43fef208..000000000
--- a/Plugins/TableProPluginKit/PluginPagedResult.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-import Foundation
-
-public struct PluginPagedResult: Sendable {
- public let columns: [String]
- public let columnTypeNames: [String]
- public let rows: [[String?]]
- public let executionTime: TimeInterval
- public let hasMore: Bool
- public let nextOffset: Int
-
- public init(
- columns: [String],
- columnTypeNames: [String],
- rows: [[String?]],
- executionTime: TimeInterval,
- hasMore: Bool,
- nextOffset: Int
- ) {
- self.columns = columns
- self.columnTypeNames = columnTypeNames
- self.rows = rows
- self.executionTime = executionTime
- self.hasMore = hasMore
- self.nextOffset = nextOffset
- }
-}
diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist
index 3cd90bcf7..f6d13edb8 100644
--- a/Plugins/XLSXExportPlugin/Info.plist
+++ b/Plugins/XLSXExportPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 8
+ 9
diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift
index 146927cab..09880e787 100644
--- a/TablePro/Core/Database/DatabaseDriver.swift
+++ b/TablePro/Core/Database/DatabaseDriver.swift
@@ -51,20 +51,13 @@ protocol DatabaseDriver: AnyObject {
/// - Returns: Query result
func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult
- /// Fetch total row count for a query (wraps with COUNT(*))
- func fetchRowCount(query: String) async throws -> Int
-
- /// Fetch rows with LIMIT/OFFSET pagination
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult
-
- /// Fetch first page of results with progressive loading support
- func fetchFirstPage(query: String, limit: Int) async throws -> PagedQueryResult
-
- /// Fetch subsequent pages of results
- func fetchNextPage(query: String, offset: Int, limit: Int) async throws -> PagedQueryResult
-
- func fetchFirstPageParameterized(query: String, parameters: [Any?], limit: Int) async throws -> PagedQueryResult
- func fetchNextPageParameterized(query: String, parameters: [Any?], offset: Int, limit: Int) async throws -> PagedQueryResult
+ /// Execute user-supplied SQL with optional row cap and parameters.
+ /// - Parameters:
+ /// - query: SQL passed through unchanged
+ /// - rowCap: Maximum rows to return; nil means no cap
+ /// - parameters: Optional parameter list; nil means no parameter binding
+ /// - Returns: Query result with `isTruncated` set when the cap clipped rows
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult
// MARK: - Schema Operations
@@ -350,86 +343,6 @@ extension DatabaseDriver {
var supportsTransactions: Bool { true }
- func fetchFirstPage(query: String, limit: Int) async throws -> PagedQueryResult {
- guard limit > 0 else {
- let result = try await execute(query: query)
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
- let result = try await fetchRows(query: query, offset: 0, limit: limit + 1)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
- func fetchNextPage(query: String, offset: Int, limit: Int) async throws -> PagedQueryResult {
- let result = try await fetchRows(query: query, offset: offset, limit: limit + 1)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: offset + rows.count
- )
- }
-
- func fetchFirstPageParameterized(query: String, parameters: [Any?], limit: Int) async throws -> PagedQueryResult {
- guard limit > 0 else {
- let result = try await executeParameterized(query: query, parameters: parameters)
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: result.rows,
- executionTime: result.executionTime,
- hasMore: false,
- nextOffset: result.rows.count
- )
- }
- let wrappedQuery = "SELECT * FROM (\(query)) _t LIMIT \(limit + 1) OFFSET 0"
- let result = try await executeParameterized(query: wrappedQuery, parameters: parameters)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: rows.count
- )
- }
-
- func fetchNextPageParameterized(query: String, parameters: [Any?], offset: Int, limit: Int) async throws -> PagedQueryResult {
- let wrappedQuery = "SELECT * FROM (\(query)) _t LIMIT \(limit + 1) OFFSET \(offset)"
- let result = try await executeParameterized(query: wrappedQuery, parameters: parameters)
- let hasMore = result.rows.count > limit
- let rows = hasMore ? Array(result.rows.prefix(limit)) : result.rows
- return PagedQueryResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: rows,
- executionTime: result.executionTime,
- hasMore: hasMore,
- nextOffset: offset + rows.count
- )
- }
-
func cancelQuery() throws {
// No-op by default
}
diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift
index f7958bffe..f60ff6e29 100644
--- a/TablePro/Core/MCP/MCPConnectionBridge.swift
+++ b/TablePro/Core/MCP/MCPConnectionBridge.swift
@@ -168,8 +168,7 @@ actor MCPConnectionBridge {
let normalizedQuery = Self.stripTrailingSemicolons(query)
let isWrite = QueryClassifier.isWriteQuery(normalizedQuery, databaseType: databaseType)
let hasReturning = normalizedQuery.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil
- let shouldUseFetchRows = !isWrite || hasReturning
- let effectiveLimit = maxRows + 1
+ let shouldCap = !isWrite || hasReturning
let startTime = CFAbsoluteTimeGetCurrent()
@@ -178,15 +177,17 @@ actor MCPConnectionBridge {
) {
try await withThrowingTaskGroup(of: QueryResult.self) { group in
group.addTask {
- if shouldUseFetchRows {
- try await driver.fetchRows(query: normalizedQuery, offset: 0, limit: effectiveLimit)
- } else {
- try await driver.execute(query: normalizedQuery)
+ if shouldCap {
+ return try await driver.executeUserQuery(
+ query: normalizedQuery,
+ rowCap: maxRows,
+ parameters: nil
+ )
}
+ return try await driver.execute(query: normalizedQuery)
}
group.addTask {
try await Task.sleep(for: .seconds(timeoutSeconds))
- // Cancel the driver query before throwing
try? driver.cancelQuery()
throw MCPError.timeout("Query timed out after \(timeoutSeconds) seconds")
}
@@ -199,11 +200,10 @@ actor MCPConnectionBridge {
}
let executionTimeMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1_000
- let isTruncated = result.rows.count > maxRows
- let rows = isTruncated ? Array(result.rows.prefix(maxRows)) : result.rows
+ let isTruncated = result.isTruncated
let jsonColumns: [JSONValue] = result.columns.map { .string($0) }
- let jsonRows: [JSONValue] = rows.map { row in
+ let jsonRows: [JSONValue] = result.rows.map { row in
.array(row.map { cell in
if let value = cell {
return .string(value)
@@ -215,7 +215,7 @@ actor MCPConnectionBridge {
var response: [String: JSONValue] = [
"columns": .array(jsonColumns),
"rows": .array(jsonRows),
- "row_count": .int(rows.count),
+ "row_count": .int(result.rows.count),
"rows_affected": .int(result.rowsAffected),
"execution_time_ms": .double(executionTimeMs),
"is_truncated": .bool(isTruncated)
diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift
index d8e3cb35d..e1db5f5e2 100644
--- a/TablePro/Core/Plugins/PluginDriverAdapter.swift
+++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift
@@ -131,43 +131,22 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
return mapQueryResult(pluginResult)
}
- func fetchRowCount(query: String) async throws -> Int {
- try await pluginDriver.fetchRowCount(query: query)
- }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult {
- let pluginResult = try await pluginDriver.fetchRows(query: query, offset: offset, limit: limit)
- return mapQueryResult(pluginResult)
- }
-
- // MARK: - Progressive Loading
-
- func fetchFirstPage(query: String, limit: Int) async throws -> PagedQueryResult {
- let pluginResult = try await pluginDriver.fetchFirstPage(query: query, limit: limit)
- return mapPagedResult(pluginResult)
- }
-
- func fetchNextPage(query: String, offset: Int, limit: Int) async throws -> PagedQueryResult {
- let pluginResult = try await pluginDriver.fetchNextPage(query: query, offset: offset, limit: limit)
- return mapPagedResult(pluginResult)
- }
-
- func fetchFirstPageParameterized(query: String, parameters: [Any?], limit: Int) async throws -> PagedQueryResult {
- let stringParams = parameters.map { param -> String? in
- guard let p = param else { return nil }
- return Self.stringValue(for: p)
- }
- let pluginResult = try await pluginDriver.fetchFirstPageParameterized(query: query, parameters: stringParams, limit: limit)
- return mapPagedResult(pluginResult)
- }
-
- func fetchNextPageParameterized(query: String, parameters: [Any?], offset: Int, limit: Int) async throws -> PagedQueryResult {
- let stringParams = parameters.map { param -> String? in
- guard let p = param else { return nil }
- return Self.stringValue(for: p)
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult {
+ let stringParams: [String?]?
+ if let parameters {
+ stringParams = parameters.map { param -> String? in
+ guard let p = param else { return nil }
+ return Self.stringValue(for: p)
+ }
+ } else {
+ stringParams = nil
}
- let pluginResult = try await pluginDriver.fetchNextPageParameterized(query: query, parameters: stringParams, offset: offset, limit: limit)
- return mapPagedResult(pluginResult)
+ let pluginResult = try await pluginDriver.executeUserQuery(
+ query: query,
+ rowCap: rowCap,
+ parameters: stringParams
+ )
+ return mapQueryResult(pluginResult)
}
// MARK: - Schema Operations
@@ -533,17 +512,6 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
// MARK: - Result Mapping
- private func mapPagedResult(_ pluginResult: PluginPagedResult) -> PagedQueryResult {
- PagedQueryResult(
- columns: pluginResult.columns,
- columnTypes: pluginResult.columnTypeNames.map { mapColumnType(rawTypeName: $0) },
- rows: pluginResult.rows,
- executionTime: pluginResult.executionTime,
- hasMore: pluginResult.hasMore,
- nextOffset: pluginResult.nextOffset
- )
- }
-
private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult {
let columnTypes = pluginResult.columnTypeNames.map { mapColumnType(rawTypeName: $0) }
var result = QueryResult(
diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift
index 8ce577358..b9796bf4b 100644
--- a/TablePro/Core/Plugins/PluginManager.swift
+++ b/TablePro/Core/Plugins/PluginManager.swift
@@ -12,7 +12,7 @@ import TableProPluginKit
@MainActor @Observable
final class PluginManager {
static let shared = PluginManager()
- static let currentPluginKitVersion = 8
+ static let currentPluginKitVersion = 9
private static let disabledPluginsKey = "com.TablePro.disabledPlugins"
private static let legacyDisabledPluginsKey = "disabledPlugins"
diff --git a/TablePro/Core/Services/Infrastructure/SettingsValidation.swift b/TablePro/Core/Services/Infrastructure/SettingsValidation.swift
index 7ff9f5fb3..5168de677 100644
--- a/TablePro/Core/Services/Infrastructure/SettingsValidation.swift
+++ b/TablePro/Core/Services/Infrastructure/SettingsValidation.swift
@@ -95,6 +95,6 @@ enum SettingsValidationRules {
// Int validation
static let defaultPageSizeRange = 10...100_000
- static let queryResultLimitRange: ClosedRange = 100...500_000
+ static let queryResultRowCapRange: ClosedRange = 100...500_000
static let minNonNegative = 0
}
diff --git a/TablePro/Models/Query/PagedQueryResult.swift b/TablePro/Models/Query/PagedQueryResult.swift
deleted file mode 100644
index 1af4be3ed..000000000
--- a/TablePro/Models/Query/PagedQueryResult.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-import Foundation
-
-struct PagedQueryResult {
- let columns: [String]
- let columnTypes: [ColumnType]
- let rows: [[String?]]
- let executionTime: TimeInterval
- let hasMore: Bool
- let nextOffset: Int
-}
diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift
index e13b336b6..206e59c63 100644
--- a/TablePro/Models/Query/QueryTabState.swift
+++ b/TablePro/Models/Query/QueryTabState.swift
@@ -96,9 +96,8 @@ struct PaginationState: Equatable {
var isLoading: Bool = false // Loading indicator
var isApproximateRowCount: Bool = false // True when totalRowCount is from fast estimate
- // Progressive loading state (query tabs)
+ // Result truncation state (query tabs)
var hasMoreRows: Bool = false
- var loadMoreOffset: Int = 0
var isLoadingMore: Bool = false
var baseQueryForMore: String?
var baseQueryParameterValues: [String?]?
@@ -194,10 +193,9 @@ struct PaginationState: Equatable {
isLoading = false
}
- /// Reset progressive loading state
+ /// Reset result truncation state
mutating func resetLoadMore() {
hasMoreRows = false
- loadMoreOffset = 0
isLoadingMore = false
baseQueryForMore = nil
baseQueryParameterValues = nil
diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift
index 61527c766..53b89368b 100644
--- a/TablePro/Models/Settings/AppSettings.swift
+++ b/TablePro/Models/Settings/AppSettings.swift
@@ -123,8 +123,8 @@ struct DataGridSettings: Codable, Equatable {
var autoShowInspector: Bool
var enableSmartValueDetection: Bool
var countRowsIfEstimateLessThan: Int
- var queryResultLimit: Int
- var enforceQueryResultLimit: Bool
+ var queryResultRowCap: Int
+ var truncateQueryResults: Bool
static let `default` = DataGridSettings(
rowHeight: .normal,
@@ -136,8 +136,8 @@ struct DataGridSettings: Codable, Equatable {
autoShowInspector: false,
enableSmartValueDetection: true,
countRowsIfEstimateLessThan: 100_000,
- queryResultLimit: 10_000,
- enforceQueryResultLimit: true
+ queryResultRowCap: 10_000,
+ truncateQueryResults: true
)
init(
@@ -150,8 +150,8 @@ struct DataGridSettings: Codable, Equatable {
autoShowInspector: Bool = false,
enableSmartValueDetection: Bool = true,
countRowsIfEstimateLessThan: Int = 100_000,
- queryResultLimit: Int = 10_000,
- enforceQueryResultLimit: Bool = true
+ queryResultRowCap: Int = 10_000,
+ truncateQueryResults: Bool = true
) {
self.rowHeight = rowHeight
self.dateFormat = dateFormat
@@ -162,13 +162,12 @@ struct DataGridSettings: Codable, Equatable {
self.autoShowInspector = autoShowInspector
self.enableSmartValueDetection = enableSmartValueDetection
self.countRowsIfEstimateLessThan = countRowsIfEstimateLessThan
- self.queryResultLimit = queryResultLimit
- self.enforceQueryResultLimit = enforceQueryResultLimit
+ self.queryResultRowCap = queryResultRowCap
+ self.truncateQueryResults = truncateQueryResults
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
- // Old fontFamily/fontSize keys are ignored (moved to ThemeFonts)
rowHeight = try container.decodeIfPresent(DataGridRowHeight.self, forKey: .rowHeight) ?? .normal
dateFormat = try container.decodeIfPresent(DateFormatOption.self, forKey: .dateFormat) ?? .iso8601
nullDisplay = try container.decodeIfPresent(String.self, forKey: .nullDisplay) ?? "NULL"
@@ -178,8 +177,8 @@ struct DataGridSettings: Codable, Equatable {
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
- queryResultLimit = try container.decodeIfPresent(Int.self, forKey: .queryResultLimit) ?? 10_000
- enforceQueryResultLimit = try container.decodeIfPresent(Bool.self, forKey: .enforceQueryResultLimit) ?? true
+ queryResultRowCap = try container.decodeIfPresent(Int.self, forKey: .queryResultRowCap) ?? 10_000
+ truncateQueryResults = try container.decodeIfPresent(Bool.self, forKey: .truncateQueryResults) ?? true
}
// MARK: - Validated Properties
@@ -227,18 +226,18 @@ struct DataGridSettings: Codable, Equatable {
return nil
}
- /// Validated queryResultLimit (100 to 500,000; 0 means unlimited)
- var validatedQueryResultLimit: Int {
- if queryResultLimit == 0 { return 0 }
- return queryResultLimit.clamped(to: SettingsValidationRules.queryResultLimitRange)
+ /// Validated queryResultRowCap (100 to 500,000; 0 means unlimited)
+ var validatedQueryResultRowCap: Int {
+ if queryResultRowCap == 0 { return 0 }
+ return queryResultRowCap.clamped(to: SettingsValidationRules.queryResultRowCapRange)
}
- /// Validation error for queryResultLimit (for UI feedback)
- var queryResultLimitValidationError: String? {
- let range = SettingsValidationRules.queryResultLimitRange
- if queryResultLimit != 0 && (queryResultLimit < range.lowerBound || queryResultLimit > range.upperBound) {
+ /// Validation error for queryResultRowCap (for UI feedback)
+ var queryResultRowCapValidationError: String? {
+ let range = SettingsValidationRules.queryResultRowCapRange
+ if queryResultRowCap != 0 && (queryResultRowCap < range.lowerBound || queryResultRowCap > range.upperBound) {
return String(
- format: String(localized: "Query result limit must be between %@ and %@"),
+ format: String(localized: "Query result row cap must be between %@ and %@"),
range.lowerBound.formatted(),
range.upperBound.formatted()
)
diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings
index bdb760dc2..fd265165c 100644
--- a/TablePro/Resources/Localizable.xcstrings
+++ b/TablePro/Resources/Localizable.xcstrings
@@ -66,9 +66,6 @@
}
}
}
- },
- " ago" : {
-
},
"--" : {
"localizations" : {
@@ -249,6 +246,9 @@
}
}
}
+ },
+ "“%@” will be disconnected and any in-flight requests will be cancelled." : {
+
},
"\"%@\" will be permanently deleted." : {
"localizations" : {
@@ -271,6 +271,9 @@
}
}
}
+ },
+ "“%@” will be permanently deleted. External clients using this token will lose access immediately." : {
+
},
"\"%@\" will be removed from your system. This action cannot be undone." : {
"localizations" : {
@@ -985,6 +988,7 @@
}
},
"%@ of %@%@ rows" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1110,6 +1114,7 @@
}
},
"%@ rows (more available)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -1392,6 +1397,9 @@
}
}
}
+ },
+ "%1$@, %2$@" : {
+
},
"%d connections found" : {
"localizations" : {
@@ -3459,9 +3467,6 @@
}
}
}
- },
- "Access Level" : {
-
},
"Account" : {
"localizations" : {
@@ -3807,6 +3812,9 @@
}
}
}
+ },
+ "Activity is retained for 90 days." : {
+
},
"Activity Log" : {
@@ -4266,6 +4274,9 @@
}
}
}
+ },
+ "Add the JSON below inside the file and save" : {
+
},
"Add validation rules to ensure data integrity" : {
"localizations" : {
@@ -4492,6 +4503,9 @@
}
}
}
+ },
+ "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, and other MCP clients. Effective scope is the minimum of the requesting token's scope and the External Clients level." : {
+
},
"AI Provider" : {
"extractionState" : "stale",
@@ -6953,6 +6967,9 @@
}
}
}
+ },
+ "Built-in CLI" : {
+
},
"Built-in plugins cannot be uninstalled" : {
"localizations" : {
@@ -7334,6 +7351,9 @@
}
}
}
+ },
+ "Cap user query results at the configured row count" : {
+
},
"Capabilities" : {
"localizations" : {
@@ -7379,6 +7399,9 @@
}
}
}
+ },
+ "Capped results show a Fetch All button to load the full set" : {
+
},
"Caption" : {
"extractionState" : "stale",
@@ -7865,6 +7888,12 @@
}
}
}
+ },
+ "Claude Code" : {
+
+ },
+ "Claude Desktop" : {
+
},
"Clear" : {
"localizations" : {
@@ -8092,7 +8121,6 @@
}
},
"Clear search" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -8225,6 +8253,12 @@
}
}
}
+ },
+ "Click \"+ Add new global MCP server\"" : {
+
+ },
+ "Click \"Edit Config\" to open claude_desktop_config.json" : {
+
},
"Click + to add a relationship between this table and another" : {
"localizations" : {
@@ -8431,6 +8465,7 @@
}
},
"Client:" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -8678,6 +8713,12 @@
}
}
}
+ },
+ "Code expired" : {
+
+ },
+ "Code expires in %d seconds" : {
+
},
"collapse" : {
"localizations" : {
@@ -9471,6 +9512,9 @@
}
}
}
+ },
+ "Connect a Client" : {
+
},
"Connect Anyway" : {
"localizations" : {
@@ -10601,6 +10645,9 @@
}
}
}
+ },
+ "Copy ID" : {
+
},
"Copy JSON" : {
@@ -10763,6 +10810,9 @@
}
}
}
+ },
+ "Copy token" : {
+
},
"Copy Token" : {
@@ -10855,6 +10905,9 @@
}
}
}
+ },
+ "Could not export activity log" : {
+
},
"Could not fetch plugin registry" : {
"localizations" : {
@@ -13517,6 +13570,12 @@
}
}
}
+ },
+ "Delete token?" : {
+
+ },
+ "Delete…" : {
+
},
"Deleted" : {
"localizations" : {
@@ -13539,6 +13598,9 @@
}
}
}
+ },
+ "Deleted connection (%@)" : {
+
},
"Deleted Text" : {
"localizations" : {
@@ -13612,6 +13674,9 @@
},
"Description" : {
+ },
+ "Deselect All" : {
+
},
"Destructive Changes" : {
"localizations" : {
@@ -13921,6 +13986,9 @@
}
}
}
+ },
+ "Disconnect client?" : {
+
},
"Disconnected" : {
"localizations" : {
@@ -15519,6 +15587,7 @@
}
},
"Enforce query result limit" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -16753,6 +16822,12 @@
}
}
}
+ },
+ "Export Activity Log" : {
+
+ },
+ "Export activity to CSV" : {
+
},
"Export as PNG" : {
"localizations" : {
@@ -17195,6 +17270,9 @@
}
}
}
+ },
+ "Export the filtered activity log to CSV" : {
+
},
"Export to File..." : {
@@ -17220,6 +17298,9 @@
}
}
}
+ },
+ "Export…" : {
+
},
"Exports data as mongosh-compatible scripts. Drop, Indexes, and Data options are configured per collection in the collection list." : {
"extractionState" : "stale",
@@ -17294,6 +17375,12 @@
},
"External access is disabled for this connection" : {
+ },
+ "External Clients" : {
+
+ },
+ "External integrations and MCP client requests will appear here." : {
+
},
"Extra Large" : {
"extractionState" : "stale",
@@ -19544,7 +19631,13 @@
"Generate" : {
},
- "Generate New Token" : {
+ "Generate a token so external clients can connect with their own credentials." : {
+
+ },
+ "Generate token" : {
+
+ },
+ "Generate Token" : {
},
"Generated WHERE Clause" : {
@@ -21912,6 +22005,21 @@
},
"Integrations" : {
+ },
+ "Integrations: Failed" : {
+
+ },
+ "Integrations: Running" : {
+
+ },
+ "Integrations: Running (%d clients)" : {
+
+ },
+ "Integrations: Starting..." : {
+
+ },
+ "Integrations: Stopped" : {
+
},
"Interactive Data Grid" : {
"localizations" : {
@@ -23564,6 +23672,7 @@
}
},
"Limit initial query results and load more on demand" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -23771,6 +23880,7 @@
}
},
"Load More" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24607,6 +24717,7 @@
}
},
"MCP Configuration" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24651,6 +24762,7 @@
}
},
"MCP Server" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24673,6 +24785,7 @@
}
},
"MCP Server: Failed" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24695,6 +24808,7 @@
}
},
"MCP Server: Running" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24717,6 +24831,7 @@
}
},
"MCP Server: Running (%d clients)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24739,6 +24854,7 @@
}
},
"MCP Server: Starting..." : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24761,6 +24877,7 @@
}
},
"MCP Server: Stopped" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24783,6 +24900,7 @@
}
},
"MCP Setup" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -28424,6 +28542,9 @@
}
}
}
+ },
+ "Open Claude Desktop, go to Settings > Developer" : {
+
},
"Open Connection" : {
"localizations" : {
@@ -28468,6 +28589,9 @@
}
}
}
+ },
+ "Open Cursor, go to Settings > MCP" : {
+
},
"Open database" : {
"extractionState" : "stale",
@@ -29676,6 +29800,9 @@
},
"Paste Cells" : {
+ },
+ "Paste the JSON below and save" : {
+
},
"Paste your CREATE TABLE statement below:" : {
"extractionState" : "stale",
@@ -31902,6 +32029,7 @@
},
"Query Result Limit" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -31924,6 +32052,7 @@
}
},
"Query result limit must be between %@ and %@" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -31951,7 +32080,21 @@
}
}
},
+ "Query Result Row Cap" : {
+
+ },
+ "Query result row cap must be between %@ and %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Query result row cap must be between %1$@ and %2$@"
+ }
+ }
+ }
+ },
"Query results exceeding this limit show Load More / Fetch All buttons" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -33814,6 +33957,9 @@
},
"Resource" : {
+ },
+ "Restart Claude Desktop" : {
+
},
"Restart TablePro for the language change to take full effect." : {
"localizations" : {
@@ -34029,6 +34175,9 @@
},
"Reveal token" : {
+ },
+ "Revoke" : {
+
},
"Revoked" : {
@@ -34223,6 +34372,9 @@
}
}
}
+ },
+ "Row cap:" : {
+
},
"Row Details" : {
"extractionState" : "stale",
@@ -34293,6 +34445,7 @@
}
},
"Row limit:" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -34491,6 +34644,9 @@
}
}
}
+ },
+ "Run the command below in your terminal" : {
+
},
"Running on port %d" : {
"localizations" : {
@@ -35302,6 +35458,9 @@
}
}
}
+ },
+ "Search activity" : {
+
},
"Search columns..." : {
"localizations" : {
@@ -35324,6 +35483,9 @@
}
}
}
+ },
+ "Search connections" : {
+
},
"Search databases..." : {
"localizations" : {
@@ -36214,6 +36376,9 @@
}
}
}
+ },
+ "Server Configuration" : {
+
},
"Server Dashboard" : {
"localizations" : {
@@ -36553,6 +36718,9 @@
}
}
}
+ },
+ "Settings" : {
+
},
"Settings were changed" : {
"localizations" : {
@@ -37046,6 +37214,9 @@
}
}
}
+ },
+ "Showing %@ rows" : {
+
},
"Showing first 50,000 keys" : {
"localizations" : {
@@ -38875,6 +39046,36 @@
}
}
}
+ },
+ "Status: active" : {
+
+ },
+ "Status: error" : {
+
+ },
+ "Status: expired" : {
+
+ },
+ "Status: failed" : {
+
+ },
+ "Status: revoked" : {
+
+ },
+ "Status: running" : {
+
+ },
+ "Status: starting" : {
+
+ },
+ "Status: stopped" : {
+
+ },
+ "Status: success" : {
+
+ },
+ "Status: warning" : {
+
},
"Steps to Reproduce" : {
@@ -42364,6 +42565,9 @@
},
"Token Name" : {
+ },
+ "Too many pending pairing codes. Try again later." : {
+
},
"Too many submissions. Please try again later." : {
@@ -42680,6 +42884,9 @@
}
}
}
+ },
+ "Truncate query results" : {
+
},
"Truncate Table" : {
"localizations" : {
diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift
index 68c63347e..8d3a65698 100644
--- a/TablePro/Views/Main/Child/MainEditorContentView.swift
+++ b/TablePro/Views/Main/Child/MainEditorContentView.swift
@@ -706,7 +706,6 @@ struct MainEditorContentView: View {
onLimitChange: onLimitChange,
onOffsetChange: onOffsetChange,
onPaginationGo: onPaginationGo,
- onLoadMore: { coordinator.loadMoreRows() },
onFetchAll: { coordinator.fetchAllRows() }
)
}
diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift
index 803ca3fec..c82a8dea6 100644
--- a/TablePro/Views/Main/Child/MainStatusBarView.swift
+++ b/TablePro/Views/Main/Child/MainStatusBarView.swift
@@ -48,8 +48,7 @@ struct MainStatusBarView: View {
let onOffsetChange: (Int) -> Void
let onPaginationGo: () -> Void
- // Progressive loading callbacks
- var onLoadMore: (() -> Void)?
+ // Truncated result callback
var onFetchAll: (() -> Void)?
var body: some View {
@@ -95,21 +94,12 @@ struct MainStatusBarView: View {
}
if snapshot.tabType == .query && snapshot.pagination.hasMoreRows && !snapshot.pagination.isLoadingMore {
- Text("—")
- .font(.caption)
- .foregroundStyle(.quaternary)
- Button {
- onLoadMore?()
- } label: {
- Text("Load More")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .foregroundStyle(.tint)
-
Text("·")
.font(.caption)
.foregroundStyle(.quaternary)
+ Text("truncated")
+ .font(.caption)
+ .foregroundStyle(.secondary)
Button {
onFetchAll?()
} label: {
@@ -117,7 +107,7 @@ struct MainStatusBarView: View {
.font(.caption)
}
.buttonStyle(.plain)
- .foregroundStyle(.secondary)
+ .foregroundStyle(.tint)
}
if let statusMessage = snapshot.statusMessage {
@@ -221,12 +211,7 @@ struct MainStatusBarView: View {
}
} else if snapshot.tabType == .query && pagination.hasMoreRows {
let formattedCount = loadedCount.formatted(.number.grouping(.automatic))
- if let total = total, total > 0 {
- let formattedTotal = total.formatted(.number.grouping(.automatic))
- let prefix = pagination.isApproximateRowCount ? "~" : ""
- return String(format: String(localized: "%@ of %@%@ rows"), formattedCount, prefix, formattedTotal)
- }
- return String(format: String(localized: "%@ rows (more available)"), formattedCount)
+ return String(format: String(localized: "Showing %@ rows"), formattedCount)
} else if snapshot.tabType == .table, let total = total, total > 0 {
let formattedTotal = total.formatted(.number.grouping(.automatic))
let prefix = pagination.isApproximateRowCount ? "~" : ""
diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift
index 295d6e18e..f95246e80 100644
--- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift
+++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift
@@ -32,104 +32,6 @@ extension MainContentCoordinator {
}
}
- // MARK: - Load More Rows
-
- func loadMoreRows() {
- guard let (tab, tabIndex) = tabManager.selectedTabAndIndex,
- !tab.pagination.isLoadingMore,
- !tab.execution.isExecuting,
- tab.pagination.hasMoreRows,
- let baseQuery = tab.pagination.baseQueryForMore else { return }
-
- let tabId = tab.id
- let offset = tab.pagination.loadMoreOffset
- let limit = AppSettingsManager.shared.dataGrid.validatedQueryResultLimit
- let capturedGeneration = queryGeneration
- let storedParamValues = tab.pagination.baseQueryParameterValues
-
- tabManager.tabs[tabIndex].pagination.isLoadingMore = true
- toolbarState.setExecuting(true)
-
- currentQueryTask = Task { [weak self] in
- guard let self, !isTearingDown else { return }
-
- do {
- guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
- throw DatabaseError.notConnected
- }
- let fetchStart = CFAbsoluteTimeGetCurrent()
- progressLog.info("[loadMore] offset=\(offset) limit=\(limit)")
- let pagedResult: PagedQueryResult
- if let paramValues = storedParamValues {
- let anyParams: [Any?] = paramValues.map { $0 as Any? }
- pagedResult = try await driver.fetchNextPageParameterized(
- query: baseQuery,
- parameters: anyParams,
- offset: offset,
- limit: limit
- )
- } else {
- pagedResult = try await driver.fetchNextPage(
- query: baseQuery,
- offset: offset,
- limit: limit
- )
- }
- let fetchElapsed = CFAbsoluteTimeGetCurrent() - fetchStart
- progressLog.info("[loadMore] rows=\(pagedResult.rows.count) hasMore=\(pagedResult.hasMore) fetchTime=\(String(format: "%.3f", fetchElapsed))s")
-
- guard !Task.isCancelled else { return }
-
- await MainActor.run { [weak self] in
- guard let self, !isTearingDown else { return }
- guard capturedGeneration == queryGeneration else {
- if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) {
- tabManager.tabs[idx].pagination.isLoadingMore = false
- }
- toolbarState.setExecuting(false)
- return
- }
- guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
- toolbarState.setExecuting(false)
- return
- }
-
- var tab = tabManager.tabs[idx]
- var pageOffset = 0
- let appendDelta = mutateActiveTableRows(for: tab.id) { rows in
- pageOffset = rows.count
- return rows.appendPage(pagedResult.rows, startingAt: rows.count)
- }
- let newCount = pageOffset + pagedResult.rows.count
- tab.schemaVersion += 1
- tab.pagination.loadMoreOffset = pagedResult.nextOffset
- tab.pagination.hasMoreRows = pagedResult.hasMore
- tab.pagination.isLoadingMore = false
- if !pagedResult.hasMore {
- tab.pagination.baseQueryForMore = nil
- }
- tabManager.tabs[idx] = tab
- dataTabDelegate?.tableViewCoordinator?.applyDelta(appendDelta)
- toolbarState.setExecuting(false)
- if capturedGeneration == queryGeneration {
- currentQueryTask = nil
- }
- progressLog.info("[loadMore] applied totalRows=\(newCount)")
- }
- } catch {
- await MainActor.run { [weak self] in
- guard let self else { return }
- if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) {
- tabManager.tabs[idx].pagination.isLoadingMore = false
- }
- toolbarState.setExecuting(false)
- currentQueryTask = nil
- Self.logger.error("Load more failed: \(error.localizedDescription, privacy: .public)")
- }
- }
- }
- }
-
// MARK: - Fetch All Rows
func fetchAllRows() {
@@ -193,13 +95,12 @@ extension MainContentCoordinator {
let start = CFAbsoluteTimeGetCurrent()
progressLog.info("[fetchAll] executing full query: \(baseQuery.prefix(100), privacy: .public)")
- let result: QueryResult
- if let paramValues = storedParamValues {
- let anyParams: [Any?] = paramValues.map { $0 as Any? }
- result = try await driver.executeParameterized(query: baseQuery, parameters: anyParams)
- } else {
- result = try await driver.execute(query: baseQuery)
- }
+ let anyParams: [Any?]? = storedParamValues.map { $0.map { $0 as Any? } }
+ let result = try await driver.executeUserQuery(
+ query: baseQuery,
+ rowCap: nil,
+ parameters: anyParams
+ )
let fetchTime = CFAbsoluteTimeGetCurrent() - start
progressLog.info("[fetchAll] rows=\(result.rows.count) fetchTime=\(String(format: "%.3f", fetchTime))s")
diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift
index 4359ba0f8..5a3d82f61 100644
--- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift
+++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift
@@ -13,14 +13,7 @@ import TableProPluginKit
private let progressLog = Logger(subsystem: "com.TablePro", category: "ProgressiveLoad")
-/// Context for progressive query result loading
-internal struct QueryPageContext {
- let hasMore: Bool
- let nextOffset: Int
- let baseQuery: String
-}
-
-/// Result of the data fetch phase (either progressive or full)
+/// Result of the data fetch phase
internal struct QueryFetchResult {
let columns: [String]
let columnTypes: [ColumnType]
@@ -28,114 +21,69 @@ internal struct QueryFetchResult {
let executionTime: TimeInterval
let rowsAffected: Int
let statusMessage: String?
- let pageContext: QueryPageContext?
+ let isTruncated: Bool
}
// MARK: - Query Execution Helpers
extension MainContentCoordinator {
- /// Execute a query using either progressive loading or standard execution
+ /// Execute a user-supplied SQL query, applying an optional row cap on the result set
nonisolated static func fetchQueryData(
driver: DatabaseDriver,
sql: String,
- useProgressiveLoading: Bool,
- progressiveLimit: Int
+ rowCap: Int?
) async throws -> QueryFetchResult {
- if useProgressiveLoading && progressiveLimit > 0 {
- let start = CFAbsoluteTimeGetCurrent()
- progressLog.info("[fetchFirstPage] sql=\(sql.prefix(100), privacy: .public) limit=\(progressiveLimit)")
- let pagedResult = try await driver.fetchFirstPage(query: sql, limit: progressiveLimit)
- let elapsed = CFAbsoluteTimeGetCurrent() - start
- progressLog.info("[fetchFirstPage] rows=\(pagedResult.rows.count) hasMore=\(pagedResult.hasMore) driverTime=\(String(format: "%.3f", pagedResult.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
- let pageContext: QueryPageContext? = pagedResult.hasMore
- ? QueryPageContext(hasMore: true, nextOffset: pagedResult.nextOffset, baseQuery: sql)
- : nil
- return QueryFetchResult(
- columns: pagedResult.columns,
- columnTypes: pagedResult.columnTypes,
- rows: pagedResult.rows,
- executionTime: pagedResult.executionTime,
- rowsAffected: 0,
- statusMessage: nil,
- pageContext: pageContext
- )
- } else {
- let start = CFAbsoluteTimeGetCurrent()
- progressLog.info("[execute] sql=\(sql.prefix(100), privacy: .public)")
- let result = try await driver.execute(query: sql)
- let elapsed = CFAbsoluteTimeGetCurrent() - start
- progressLog.info("[execute] rows=\(result.rows.count) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
- return QueryFetchResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: result.rows,
- executionTime: result.executionTime,
- rowsAffected: result.rowsAffected,
- statusMessage: result.statusMessage,
- pageContext: nil
- )
- }
+ let start = CFAbsoluteTimeGetCurrent()
+ progressLog.info("[executeUserQuery] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil")")
+ let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: nil)
+ let elapsed = CFAbsoluteTimeGetCurrent() - start
+ progressLog.info("[executeUserQuery] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
+ return QueryFetchResult(
+ columns: result.columns,
+ columnTypes: result.columnTypes,
+ rows: result.rows,
+ executionTime: result.executionTime,
+ rowsAffected: result.rowsAffected,
+ statusMessage: result.statusMessage,
+ isTruncated: result.isTruncated
+ )
}
nonisolated static func fetchQueryDataParameterized(
driver: DatabaseDriver,
sql: String,
parameters: [Any?],
- useProgressiveLoading: Bool,
- progressiveLimit: Int
+ rowCap: Int?
) async throws -> QueryFetchResult {
- if useProgressiveLoading && progressiveLimit > 0 {
- let start = CFAbsoluteTimeGetCurrent()
- progressLog.info("[fetchFirstPageParameterized] sql=\(sql.prefix(100), privacy: .public) limit=\(progressiveLimit) params=\(parameters.count)")
- let pagedResult = try await driver.fetchFirstPageParameterized(
- query: sql,
- parameters: parameters,
- limit: progressiveLimit
- )
- let elapsed = CFAbsoluteTimeGetCurrent() - start
- progressLog.info("[fetchFirstPageParameterized] rows=\(pagedResult.rows.count) hasMore=\(pagedResult.hasMore) driverTime=\(String(format: "%.3f", pagedResult.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
- let pageContext: QueryPageContext? = pagedResult.hasMore
- ? QueryPageContext(hasMore: true, nextOffset: pagedResult.nextOffset, baseQuery: sql)
- : nil
- return QueryFetchResult(
- columns: pagedResult.columns,
- columnTypes: pagedResult.columnTypes,
- rows: pagedResult.rows,
- executionTime: pagedResult.executionTime,
- rowsAffected: 0,
- statusMessage: nil,
- pageContext: pageContext
- )
- } else {
- let start = CFAbsoluteTimeGetCurrent()
- progressLog.info("[executeParameterized] sql=\(sql.prefix(100), privacy: .public) params=\(parameters.count)")
- let result = try await driver.executeParameterized(query: sql, parameters: parameters)
- let elapsed = CFAbsoluteTimeGetCurrent() - start
- progressLog.info("[executeParameterized] rows=\(result.rows.count) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
- return QueryFetchResult(
- columns: result.columns,
- columnTypes: result.columnTypes,
- rows: result.rows,
- executionTime: result.executionTime,
- rowsAffected: result.rowsAffected,
- statusMessage: result.statusMessage,
- pageContext: nil
- )
- }
+ let start = CFAbsoluteTimeGetCurrent()
+ progressLog.info("[executeUserQueryParameterized] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil") params=\(parameters.count)")
+ let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: parameters)
+ let elapsed = CFAbsoluteTimeGetCurrent() - start
+ progressLog.info("[executeUserQueryParameterized] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
+ return QueryFetchResult(
+ columns: result.columns,
+ columnTypes: result.columnTypes,
+ rows: result.rows,
+ executionTime: result.executionTime,
+ rowsAffected: result.rowsAffected,
+ statusMessage: result.statusMessage,
+ isTruncated: result.isTruncated
+ )
}
- /// Determine whether progressive loading should be used for this query
- func resolveProgressiveLoading(sql: String, tabType: TabType) -> (useProgressive: Bool, limit: Int) {
+ /// Decide whether to apply the configured row cap to a user query.
+ /// Returns the cap value if truncation is enabled and the query is a non-write SELECT/WITH on a query tab.
+ func resolveRowCap(sql: String, tabType: TabType) -> Int? {
let dataGridSettings = AppSettingsManager.shared.dataGrid
let trimmedUpper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let isSelectQuery = trimmedUpper.hasPrefix("SELECT ") || trimmedUpper.hasPrefix("WITH ")
- if tabType == .query && isSelectQuery && !isWriteQuery(sql) && !isDDLQuery(sql)
- && dataGridSettings.enforceQueryResultLimit
- {
- return (true, dataGridSettings.validatedQueryResultLimit)
+ guard tabType == .query, isSelectQuery, !isWriteQuery(sql), !isDDLQuery(sql),
+ dataGridSettings.truncateQueryResults
+ else {
+ return nil
}
- return (false, 0)
+ return dataGridSettings.validatedQueryResultRowCap
}
/// Parsed schema metadata ready to apply to a tab
@@ -242,7 +190,7 @@ extension MainContentCoordinator {
hasSchema: Bool,
sql: String,
connection conn: DatabaseConnection,
- queryPageContext: QueryPageContext? = nil,
+ isTruncated: Bool = false,
queryParameterValues: [QueryParameter]? = nil
) {
guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
@@ -314,11 +262,9 @@ extension MainContentCoordinator {
updatedTab.display.resultSets = pinned + [rs]
updatedTab.display.activeResultSetId = rs.id
- // Update progressive loading state
- if let context = queryPageContext {
- updatedTab.pagination.hasMoreRows = context.hasMore
- updatedTab.pagination.loadMoreOffset = context.nextOffset
- updatedTab.pagination.baseQueryForMore = context.hasMore ? context.baseQuery : nil
+ if isTruncated {
+ updatedTab.pagination.hasMoreRows = true
+ updatedTab.pagination.baseQueryForMore = sql
updatedTab.pagination.isLoadingMore = false
} else {
updatedTab.pagination.resetLoadMore()
diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift
index 8cde0a0b6..ebee8cc83 100644
--- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift
+++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift
@@ -90,10 +90,7 @@ extension MainContentCoordinator {
let conn = connection
let tabId = tabManager.tabs[index].id
- let (useProgressiveLoading, progressiveLimit) = resolveProgressiveLoading(
- sql: sql,
- tabType: tab.tabType
- )
+ let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType)
let effectiveSQL = sql
let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: effectiveSQL)
@@ -139,17 +136,10 @@ extension MainContentCoordinator {
driver: queryDriver,
sql: effectiveSQL,
parameters: parameters,
- useProgressiveLoading: useProgressiveLoading,
- progressiveLimit: progressiveLimit
+ rowCap: rowCap
)
}
- let safeColumns = fetchResult.columns
- let safeColumnTypes = fetchResult.columnTypes
- let safeRows = fetchResult.rows
let safeExecutionTime = fetchResult.executionTime
- let safeRowsAffected = fetchResult.rowsAffected
- let safeStatusMessage = fetchResult.statusMessage
- let pageContext = fetchResult.pageContext
guard !Task.isCancelled else {
parallelSchemaTask?.cancel()
@@ -434,7 +424,7 @@ extension MainContentCoordinator {
hasSchema: schemaResult != nil,
sql: sql,
connection: connection,
- queryPageContext: fetchResult.pageContext,
+ isTruncated: fetchResult.isTruncated,
queryParameterValues: originalParameters
)
diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift
index cf8b99a1f..840f398bf 100644
--- a/TablePro/Views/Main/MainContentCoordinator.swift
+++ b/TablePro/Views/Main/MainContentCoordinator.swift
@@ -959,7 +959,7 @@ final class MainContentCoordinator {
let conn = connection
let tabId = tabManager.tabs[index].id
- let (useProgressiveLoading, progressiveLimit) = resolveProgressiveLoading(sql: sql, tabType: tab.tabType)
+ let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType)
let effectiveSQL = sql
let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: effectiveSQL)
@@ -1005,8 +1005,7 @@ final class MainContentCoordinator {
fetchResult = try await Self.fetchQueryData(
driver: queryDriver,
sql: effectiveSQL,
- useProgressiveLoading: useProgressiveLoading,
- progressiveLimit: progressiveLimit
+ rowCap: rowCap
)
}
let safeColumns = fetchResult.columns
@@ -1015,7 +1014,7 @@ final class MainContentCoordinator {
let safeExecutionTime = fetchResult.executionTime
let safeRowsAffected = fetchResult.rowsAffected
let safeStatusMessage = fetchResult.statusMessage
- let pageContext = fetchResult.pageContext
+ let isTruncated = fetchResult.isTruncated
guard !Task.isCancelled else {
parallelSchemaTask?.cancel()
@@ -1067,7 +1066,7 @@ final class MainContentCoordinator {
hasSchema: schemaResult != nil,
sql: sql,
connection: conn,
- queryPageContext: pageContext
+ isTruncated: isTruncated
)
}
diff --git a/TablePro/Views/Settings/Sections/DataGridSection.swift b/TablePro/Views/Settings/Sections/DataGridSection.swift
index 63f6f8190..be24f0fd8 100644
--- a/TablePro/Views/Settings/Sections/DataGridSection.swift
+++ b/TablePro/Views/Settings/Sections/DataGridSection.swift
@@ -62,11 +62,11 @@ struct DataGridSection: View {
}
Section {
- Toggle("Enforce query result limit", isOn: $settings.enforceQueryResultLimit)
- .help(String(localized: "Limit initial query results and load more on demand"))
+ Toggle("Truncate query results", isOn: $settings.truncateQueryResults)
+ .help(String(localized: "Cap user query results at the configured row count"))
- if settings.enforceQueryResultLimit {
- Picker("Row limit:", selection: $settings.queryResultLimit) {
+ if settings.truncateQueryResults {
+ Picker("Row cap:", selection: $settings.queryResultRowCap) {
Text("100").tag(100)
Text("1,000").tag(1_000)
Text("5,000").tag(5_000)
@@ -76,17 +76,17 @@ struct DataGridSection: View {
Text("500,000").tag(500_000)
}
- if let error = settings.queryResultLimitValidationError {
+ if let error = settings.queryResultRowCapValidationError {
Text(error)
.font(.caption)
.foregroundStyle(Color(nsColor: .systemRed))
}
}
} header: {
- Text("Query Result Limit")
+ Text("Query Result Row Cap")
} footer: {
- if settings.enforceQueryResultLimit, settings.queryResultLimitValidationError == nil {
- Text("Query results exceeding this limit show Load More / Fetch All buttons")
+ if settings.truncateQueryResults, settings.queryResultRowCapValidationError == nil {
+ Text("Capped results show a Fetch All button to load the full set")
}
}
}
diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift
index ebae96b45..f904327f3 100644
--- a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift
+++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift
@@ -39,9 +39,7 @@ private final class MockFallbackDriver: DatabaseDriver, @unchecked Sendable {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}
- func fetchRowCount(query: String) async throws -> Int { 0 }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult {
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}
diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift
index cf750cfd8..34f4b1943 100644
--- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift
+++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift
@@ -40,9 +40,7 @@ private final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}
- func fetchRowCount(query: String) async throws -> Int { 0 }
-
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult {
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult {
QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil)
}
diff --git a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift
index 5049ea3dc..83c43fff2 100644
--- a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift
+++ b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift
@@ -32,37 +32,6 @@ struct CloudflareD1DriverHelperTests {
return string.range(of: uuidPattern, options: .regularExpression) != nil
}
- private static func stripLimitOffset(from query: String) -> String {
- let ns = query as NSString
- let len = ns.length
- guard len > 0 else { return query }
-
- let upper = query.uppercased() as NSString
- var depth = 0
- var i = len - 1
-
- while i >= 4 {
- let ch = upper.character(at: i)
- if ch == 0x29 { depth += 1 }
- else if ch == 0x28 { depth -= 1 }
- else if depth == 0 && ch == 0x54 {
- let start = i - 4
- if start >= 0 {
- let candidate = upper.substring(with: NSRange(location: start, length: 5))
- if candidate == "LIMIT" {
- if start == 0 || CharacterSet.whitespacesAndNewlines
- .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) {
- return ns.substring(to: start)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
- }
- }
- i -= 1
- }
- return query
- }
-
private static func formatDDL(_ ddl: String) -> String {
guard ddl.uppercased().hasPrefix("CREATE TABLE") else {
return ddl
@@ -213,44 +182,6 @@ struct CloudflareD1DriverHelperTests {
#expect(!Self.isUuid("550e8400-e29b-41d4-a716-446655440000-extra"))
}
- // MARK: - stripLimitOffset
-
- @Test("Strips LIMIT clause from end")
- func stripsLimit() {
- let result = Self.stripLimitOffset(from: "SELECT * FROM users LIMIT 10")
- #expect(result == "SELECT * FROM users")
- }
-
- @Test("Strips LIMIT and OFFSET from end")
- func stripsLimitOffset() {
- let result = Self.stripLimitOffset(from: "SELECT * FROM users LIMIT 10 OFFSET 20")
- #expect(result == "SELECT * FROM users")
- }
-
- @Test("Returns query unchanged without LIMIT")
- func noLimitUnchanged() {
- let query = "SELECT * FROM users WHERE id = 1"
- #expect(Self.stripLimitOffset(from: query) == query)
- }
-
- @Test("Does not strip LIMIT inside subquery")
- func preservesSubqueryLimit() {
- let query = "SELECT * FROM (SELECT * FROM users LIMIT 5) AS sub LIMIT 10"
- let result = Self.stripLimitOffset(from: query)
- #expect(result.contains("LIMIT 5"))
- }
-
- @Test("Handles empty query")
- func emptyQuery() {
- #expect(Self.stripLimitOffset(from: "") == "")
- }
-
- @Test("Handles case-insensitive LIMIT")
- func caseInsensitiveLimit() {
- let result = Self.stripLimitOffset(from: "SELECT * FROM users limit 10")
- #expect(result == "SELECT * FROM users")
- }
-
// MARK: - formatDDL
@Test("Formats CREATE TABLE with column indentation")
diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift
new file mode 100644
index 000000000..af2e0c08f
--- /dev/null
+++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift
@@ -0,0 +1,159 @@
+//
+// ExecuteUserQueryTests.swift
+// TableProTests
+//
+
+import Foundation
+import Testing
+import TableProPluginKit
+@testable import TablePro
+
+@Suite("executeUserQuery applies row cap and respects user SQL")
+struct ExecuteUserQueryTests {
+
+ @Test("Caps result at rowCap and marks isTruncated when there are more rows than the cap")
+ func capsAndMarksTruncated() async throws {
+ let rows = (1...100).map { ["row_\($0)"] }
+ let driver = StubPluginDriver(rows: rows)
+
+ let result = try await driver.executeUserQuery(query: "SELECT * FROM t", rowCap: 5, parameters: nil)
+
+ #expect(result.rows.count == 5)
+ #expect(result.isTruncated)
+ #expect(result.rows.first?.first == "row_1")
+ #expect(result.rows.last?.first == "row_5")
+ }
+
+ @Test("Returns full result without truncation flag when row count is below cap")
+ func belowCapNotTruncated() async throws {
+ let rows = (1...3).map { ["row_\($0)"] }
+ let driver = StubPluginDriver(rows: rows)
+
+ let result = try await driver.executeUserQuery(query: "SELECT * FROM t", rowCap: 5, parameters: nil)
+
+ #expect(result.rows.count == 3)
+ #expect(!result.isTruncated)
+ }
+
+ @Test("Returns full result when rowCap is nil")
+ func unlimitedCap() async throws {
+ let rows = (1...100).map { ["row_\($0)"] }
+ let driver = StubPluginDriver(rows: rows)
+
+ let result = try await driver.executeUserQuery(query: "SELECT * FROM t", rowCap: nil, parameters: nil)
+
+ #expect(result.rows.count == 100)
+ #expect(!result.isTruncated)
+ }
+
+ @Test("Treats rowCap of 0 as unlimited and returns the full result")
+ func zeroCapMeansUnlimited() async throws {
+ let rows = (1...100).map { ["row_\($0)"] }
+ let driver = StubPluginDriver(rows: rows)
+
+ let result = try await driver.executeUserQuery(query: "SELECT * FROM t", rowCap: 0, parameters: nil)
+
+ #expect(result.rows.count == 100)
+ #expect(!result.isTruncated)
+ }
+
+ @Test("Passes user SQL through unchanged regardless of cap")
+ func passesUserSqlUnchanged() async throws {
+ let driver = StubPluginDriver(rows: [["x"]])
+ let userSql = "SELECT uuid FROM TMTask WHERE status IN (2,3) ORDER BY stopDate DESC LIMIT 10"
+
+ _ = try await driver.executeUserQuery(query: userSql, rowCap: 10_000, parameters: nil)
+
+ #expect(driver.lastExecutedQuery == userSql)
+ #expect(!driver.lastExecutedQuery!.contains("OFFSET"))
+ #expect(driver.lastExecutedQuery!.contains("LIMIT 10"))
+ }
+
+ @Test("Passes user SQL with CTE unchanged")
+ func passesCteUnchanged() async throws {
+ let driver = StubPluginDriver(rows: [["x"]])
+ let userSql = "WITH cte AS (SELECT * FROM t LIMIT 5) SELECT * FROM cte"
+
+ _ = try await driver.executeUserQuery(query: userSql, rowCap: 10_000, parameters: nil)
+
+ #expect(driver.lastExecutedQuery == userSql)
+ }
+
+ @Test("Routes parameterized queries through executeParameterized with the same SQL")
+ func parameterizedRoutesCorrectly() async throws {
+ let driver = StubPluginDriver(rows: [["x"]])
+ let userSql = "SELECT * FROM t WHERE id = ? LIMIT 3"
+
+ _ = try await driver.executeUserQuery(query: userSql, rowCap: 100, parameters: ["42"])
+
+ #expect(driver.lastExecutedQuery == userSql)
+ #expect(driver.lastParameters == ["42"])
+ }
+
+ @Test("Preserves status message and execution metadata when truncating")
+ func preservesMetadata() async throws {
+ let rows = (1...10).map { ["row_\($0)"] }
+ let driver = StubPluginDriver(rows: rows, statusMessage: "warning: cache miss")
+
+ let result = try await driver.executeUserQuery(query: "SELECT * FROM t", rowCap: 3, parameters: nil)
+
+ #expect(result.rows.count == 3)
+ #expect(result.isTruncated)
+ #expect(result.statusMessage == "warning: cache miss")
+ #expect(result.rowsAffected == 0)
+ }
+}
+
+private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
+ private(set) var lastExecutedQuery: String?
+ private(set) var lastParameters: [String?]?
+ private let rowsToReturn: [[String?]]
+ private let statusMessage: String?
+
+ init(rows: [[String?]], statusMessage: String? = nil) {
+ self.rowsToReturn = rows
+ self.statusMessage = statusMessage
+ }
+
+ func connect() async throws {}
+ func disconnect() {}
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ lastExecutedQuery = query
+ return PluginQueryResult(
+ columns: ["col1"],
+ columnTypeNames: ["TEXT"],
+ rows: rowsToReturn,
+ rowsAffected: 0,
+ executionTime: 0.001,
+ statusMessage: statusMessage
+ )
+ }
+
+ func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult {
+ lastExecutedQuery = query
+ lastParameters = parameters
+ return PluginQueryResult(
+ columns: ["col1"],
+ columnTypeNames: ["TEXT"],
+ rows: rowsToReturn,
+ rowsAffected: 0,
+ executionTime: 0.001,
+ statusMessage: statusMessage
+ )
+ }
+
+ func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] }
+ func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] }
+ func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] }
+ func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] }
+ func fetchTableDDL(table: String, schema: String?) async throws -> String { "" }
+ func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" }
+ func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata {
+ PluginTableMetadata(tableName: table)
+ }
+ func fetchDatabases() async throws -> [String] { [] }
+ func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata {
+ PluginDatabaseMetadata(name: database)
+ }
+}
diff --git a/TableProTests/Core/Database/PostgreSQLDriverTests.swift b/TableProTests/Core/Database/PostgreSQLDriverTests.swift
index 3db181920..07c05e90e 100644
--- a/TableProTests/Core/Database/PostgreSQLDriverTests.swift
+++ b/TableProTests/Core/Database/PostgreSQLDriverTests.swift
@@ -186,8 +186,7 @@ private final class MockPostgreSQLDriver: DatabaseDriver {
func execute(query: String) async throws -> QueryResult { .empty }
func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { .empty }
- func fetchRowCount(query: String) async throws -> Int { 0 }
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult { .empty }
+ func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { .empty }
func fetchTables() async throws -> [TableInfo] { [] }
func fetchColumns(table: String) async throws -> [ColumnInfo] { [] }
diff --git a/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift b/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift
index 4cc09d133..197fdf6d7 100644
--- a/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift
+++ b/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift
@@ -32,11 +32,6 @@ private final class StubExplainDriver: PluginDatabaseDriver {
PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
}
- func fetchRowCount(query: String) async throws -> Int { 0 }
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
- }
-
func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] }
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] }
func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] }
diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift
index 500465ea6..177f93507 100644
--- a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift
+++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift
@@ -32,11 +32,6 @@ private final class StubTableOpsDriver: PluginDatabaseDriver {
PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
}
- func fetchRowCount(query: String) async throws -> Int { 0 }
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
- }
-
func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] }
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] }
func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] }
diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
index 98c8fa885..6a1b12080 100644
--- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
+++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
@@ -64,10 +64,6 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable
func execute(query: String) async throws -> PluginQueryResult {
PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
}
- func fetchRowCount(query: String) async throws -> Int { 0 }
- func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
- PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
- }
func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] }
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] }
func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] }
diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx
index 1227a83cb..ed6e6b1b1 100644
--- a/docs/features/data-grid.mdx
+++ b/docs/features/data-grid.mdx
@@ -29,7 +29,7 @@ The header shows row count, execution time, and affected rows for UPDATE/DELETE
Toggle between **Data**, **Structure**, and **JSON** in the status bar. Query tabs show Data and JSON only.
-JSON mode renders all rows as a JSON array with Text and Tree views. Select rows in Data mode first to show only those rows in JSON. Copy JSON button copies to clipboard. View mode is per-tab.
+JSON mode renders all rows as a JSON array. Toggle between Text and Tree views in the toolbar. To export only specific rows, select them in Data mode first, then switch to JSON. The Copy JSON button writes to the clipboard. View mode is remembered per tab.
## Column Features
@@ -47,7 +47,7 @@ Click a column header to sort:
- **Second click**: Sort descending (Z-A, 9-0)
- **Third click**: Remove sort
-Sorting is local to the loaded page. For server-side sorting, use `ORDER BY` in your query.
+Sort applies to the full result. TablePro re-runs the query with `ORDER BY` appended; if your query already has an `ORDER BY`, it is replaced.
{/* Screenshot: Column header with sort indicator */}
@@ -201,7 +201,7 @@ Double-click a JSON cell to open the viewer. Switch between **Text** and **Tree*
Text mode shows syntax-highlighted JSON with brace matching. Tree mode shows a collapsible outline you can search, expand/collapse all, and right-click to copy values or key paths like `$.users[0].email`.
-Values are compacted on save. Invalid JSON falls back to text mode.
+JSON is minified on save. Invalid JSON falls back to text mode.
{/* Screenshot: JSON editor with validation */}
@@ -219,7 +219,7 @@ Values are compacted on save. Invalid JSON falls back to text mode.
### Hex Editor (BLOB/Binary)
-BLOB, BINARY, and VARBINARY cells open a hex editor popover. Edit as space-separated hex bytes (e.g., `48 65 6C 6C 6F`). Live validation flags invalid input in red. The Cell Inspector sidebar also provides hex editing.
+BLOB, BINARY, and VARBINARY cells open a hex editor popover. Edit as space-separated hex bytes (e.g., `48 65 6C 6C 6F`). Invalid input is highlighted in red as you type. The Cell Inspector sidebar also offers hex editing.
BLOBs larger than 10 KB are read-only. Use an external hex editor for large binary data.
@@ -333,7 +333,7 @@ Select cells and press `Cmd+C` for TSV, `Cmd+Shift+C` for CSV, or `Cmd+Option+J`
## Pagination
-Large result sets are paginated. Configure page size in **Settings** > **Editor** (Small: 100, Medium: 500, Large: 1,000, Custom: 10–100,000). Smaller pages load faster.
+Table tabs paginate large result sets. Set page size in **Settings** > **Editor** (Small: 100, Medium: 500, Large: 1,000, Custom: any value from 10 to 100,000). Smaller pages load faster.
{/* Screenshot: Pagination controls with page navigation */}
@@ -366,37 +366,38 @@ Right-click a column header > **Display As** to override per column. Overrides p
NULL values show as styled "NULL" text (customizable in **Settings** > **Editor**). Configure date format, row height, and alternate row colors in the same settings panel.
-## Progressive Loading
+## Result Truncation
-Query tabs load rows in batches. The first execution returns up to 10,000 rows (configurable in Settings). If more rows exist, the status bar shows the count and two actions:
+Query tabs cap results at 10,000 rows by default to keep the UI responsive on large queries. When the cap kicks in, the status bar shows a truncation marker and a Fetch All button:
-
+
-- **Load More** - appends the next batch to the grid
-- **Fetch All** - loads all remaining rows in one query. Shows a confirmation first.
+- **Showing N rows (truncated)** means the query returned more than the cap and the grid is showing the first N rows
+- **Fetch All** re-runs the query without a cap. A confirmation appears first because large result sets use significant memory.
-The status bar shows how many rows are loaded and the estimated total, for example: `10,000 of ~1,238,120 rows`.
+Your `LIMIT` and `OFFSET` are passed to the database unchanged. `SELECT ... LIMIT 10` returns 10 rows whether the cap is set or not. The cap only kicks in when the database returns more rows than your cap allows, which happens on queries with no `LIMIT` or with a `LIMIT` larger than the cap.
-Table tabs use page-based navigation and are not affected by this setting.
+Configure the cap in **Settings** > **Editor**:
-### Sorting with partial results
+- **Truncate query results**: turns the cap on or off
+- **Row cap**: 100 to 500,000, or 0 for unlimited
-When only part of the result is loaded and you click a column header to sort, TablePro re-runs the query with `ORDER BY` added. This gives correct sort order across the full table, not just the loaded rows.
+Table tabs are not affected by this setting. They use [Pagination](#pagination) instead.
### Cancelling
-Press `Cmd+.` or click the stop button in the toolbar to cancel a running query or a Load More / Fetch All operation.
+Press `Cmd+.` or click the stop button in the toolbar to cancel a running query or a Fetch All operation.
## MongoDB Collections