From 7ef824d8bbca35c5599950443829497cc3bab1ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:23:49 +0700 Subject: [PATCH 01/13] refactor(driver): replace paged query API with executeUserQuery --- .../PluginDatabaseDriver.swift | 164 ++---------------- TablePro/Core/Database/DatabaseDriver.swift | 101 +---------- .../Core/Plugins/PluginDriverAdapter.swift | 62 ++----- TablePro/Core/Plugins/PluginManager.swift | 2 +- 4 files changed, 40 insertions(+), 289 deletions(-) diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index e7ae3ee3c..c401d6218 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, 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/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/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" From c60382d739b419edf8ab3990a49708d6bf0d088f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:27:07 +0700 Subject: [PATCH 02/13] refactor(plugins): drop strip-and-replace pagination from built-in SQL drivers --- Plugins/CSVExportPlugin/Info.plist | 2 +- .../ClickHousePlugin.swift | 53 ----------- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- Plugins/MySQLDriverPlugin/Info.plist | 2 +- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 89 +------------------ Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- .../PostgreSQLPluginDriver.swift | 77 +--------------- .../RedshiftPluginDriver.swift | 74 --------------- Plugins/RedisDriverPlugin/Info.plist | 2 +- Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 84 ++++++++++------- Plugins/XLSXExportPlugin/Info.plist | 2 +- 17 files changed, 68 insertions(+), 333 deletions(-) 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/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/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/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/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/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/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..5d9f23208 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,64 +115,6 @@ 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 - ) - } - - 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) - } - // MARK: - Reconnect private func isConnectionLostError(_ error: NSError) -> Bool { @@ -723,18 +663,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/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..ce6a33929 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, 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 { + 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/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 From 206dd2ee1c5e0c111e012bdf287d7c3a2ee6e846 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:33:17 +0700 Subject: [PATCH 03/13] refactor(plugins): migrate separately distributed drivers to executeUserQuery --- .../BigQueryPluginDriver.swift | 124 +----------------- Plugins/BigQueryDriverPlugin/Info.plist | 2 +- .../CassandraPlugin.swift | 19 --- Plugins/CassandraDriverPlugin/Info.plist | 2 +- .../CloudflareD1PluginDriver.swift | 51 +------ Plugins/CloudflareD1DriverPlugin/Info.plist | 2 +- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 108 +-------------- Plugins/DuckDBDriverPlugin/Info.plist | 2 +- .../DynamoDBPluginDriver.swift | 67 ---------- Plugins/DynamoDBDriverPlugin/Info.plist | 2 +- .../EtcdDriverPlugin/EtcdPluginDriver.swift | 43 ------ Plugins/EtcdDriverPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 2 +- .../LibSQLPluginDriver.swift | 51 +------ Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 77 +---------- .../MongoDBPluginDriver.swift | 54 -------- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/OracleDriverPlugin/OraclePlugin.swift | 80 +---------- .../RedisDriverPlugin/RedisPluginDriver.swift | 63 --------- 20 files changed, 15 insertions(+), 740 deletions(-) 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/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/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/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/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/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/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/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 { From b398175f8eb3cb754e2882d69a7a3138ecb0034c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:38:10 +0700 Subject: [PATCH 04/13] refactor(coordinator): use executeUserQuery for query editor and Fetch All --- TablePro/Core/MCP/MCPConnectionBridge.swift | 22 +-- .../MainContentCoordinator+LoadMore.swift | 111 +------------- .../MainContentCoordinator+QueryHelpers.swift | 144 ++++++------------ ...inContentCoordinator+QueryParameters.swift | 16 +- .../Views/Main/MainContentCoordinator.swift | 9 +- .../SQLSchemaProviderFallbackTests.swift | 4 +- .../Autocomplete/SQLSchemaProviderTests.swift | 4 +- .../Core/Database/PostgreSQLDriverTests.swift | 3 +- .../Plugins/ExplainQueryPluginTests.swift | 5 - .../PluginDriverAdapterTableOpsTests.swift | 5 - .../SchemaStatementGeneratorPluginTests.swift | 4 - 11 files changed, 72 insertions(+), 255 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index d0dfbfb69..8b4976c5a 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -172,8 +172,7 @@ actor MCPConnectionBridge { let (driver, databaseType) = try await resolveDriver(connectionId) let isWrite = QueryClassifier.isWriteQuery(query, databaseType: databaseType) let hasReturning = query.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil - let shouldUseFetchRows = !isWrite || hasReturning - let effectiveLimit = maxRows + 1 + let shouldCap = !isWrite || hasReturning let startTime = CFAbsoluteTimeGetCurrent() @@ -182,15 +181,17 @@ actor MCPConnectionBridge { ) { try await withThrowingTaskGroup(of: QueryResult.self) { group in group.addTask { - if shouldUseFetchRows { - try await driver.fetchRows(query: query, offset: 0, limit: effectiveLimit) - } else { - try await driver.execute(query: query) + if shouldCap { + return try await driver.executeUserQuery( + query: query, + rowCap: maxRows, + parameters: nil + ) } + return try await driver.execute(query: query) } 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") } @@ -203,11 +204,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) @@ -219,7 +219,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/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 8590d2e5d..5624e91a3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1009,7 +1009,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) @@ -1055,8 +1055,7 @@ final class MainContentCoordinator { fetchResult = try await Self.fetchQueryData( driver: queryDriver, sql: effectiveSQL, - useProgressiveLoading: useProgressiveLoading, - progressiveLimit: progressiveLimit + rowCap: rowCap ) } let safeColumns = fetchResult.columns @@ -1065,7 +1064,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() @@ -1117,7 +1116,7 @@ final class MainContentCoordinator { hasSchema: schemaResult != nil, sql: sql, connection: conn, - queryPageContext: pageContext + isTruncated: isTruncated ) } 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/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] { [] } From 555e6bf51b4815f8f043205b45dbd4afefefe4d5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:39:27 +0700 Subject: [PATCH 05/13] refactor(ui): replace Load More with truncation banner on query tabs --- TablePro/Models/Query/QueryTabState.swift | 6 ++--- .../Main/Child/MainEditorContentView.swift | 1 - .../Views/Main/Child/MainStatusBarView.swift | 27 +++++-------------- 3 files changed, 8 insertions(+), 26 deletions(-) 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/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a1c8a2214..690f021cd 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 ? "~" : "" From 1fc3d81037c7bb3c0451e62e3966e8c9050931d4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:41:10 +0700 Subject: [PATCH 06/13] refactor(settings): rename queryResultLimit to queryResultRowCap --- .../Infrastructure/SettingsValidation.swift | 2 +- TablePro/Models/Settings/AppSettings.swift | 39 +++++++++---------- .../Settings/Sections/DataGridSection.swift | 16 ++++---- 3 files changed, 28 insertions(+), 29 deletions(-) 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/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/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") } } } From 3021d4edb43872f5682b0bd57ec60c9e7fdcde6b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:41:49 +0700 Subject: [PATCH 07/13] docs: add CHANGELOG entries for #956 fix and query API refactor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f6307c3..6ed4d8fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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. - Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. `SQLFavoriteStorage` is now an actor so its first access no longer blocks the main thread on SQLite setup. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). @@ -67,6 +70,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) - Crash on macOS 26 when opening SQL Preview (NSColor.cgColor calls deprecated colorSpaceName) - Connection form: `usePrivateKey=true` from URL no longer disables Test/Create buttons - Transient connections from URL clean up keychain entries on connection failure From 3eba24de90082d38ee27a4d34266181a2864d804 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:42:53 +0700 Subject: [PATCH 08/13] test(query): add #956 regression coverage for executeUserQuery --- .../Core/Database/ExecuteUserQueryTests.swift | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 TableProTests/Core/Database/ExecuteUserQueryTests.swift diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift new file mode 100644 index 000000000..510fd7bce --- /dev/null +++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift @@ -0,0 +1,151 @@ +// +// ExecuteUserQueryTests.swift +// TableProTests +// +// Regression coverage for the executeUserQuery driver path that replaced +// the removed strip-and-replace pagination API. See issue #956. +// + +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("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) + } +} From 4d5b540da0c1f0b9d9df358ec4e0ffdf1e16a1f1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 19:43:52 +0700 Subject: [PATCH 09/13] refactor: remove unused PagedResult types and stale stripLimitOffset tests --- .../TableProPluginKit/PluginPagedResult.swift | 26 ------- TablePro/Models/Query/PagedQueryResult.swift | 10 --- .../CloudflareD1DriverHelperTests.swift | 69 ------------------- 3 files changed, 105 deletions(-) delete mode 100644 Plugins/TableProPluginKit/PluginPagedResult.swift delete mode 100644 TablePro/Models/Query/PagedQueryResult.swift 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/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/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") From 4885dbf612e127b985a6b742cbbf0ec7ccd484b6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 20:24:47 +0700 Subject: [PATCH 10/13] fix(plugins): add streamRows override to RedshiftPluginDriver --- .../PostgreSQLDriverPlugin/RedshiftPluginDriver.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 5d9f23208..3c3c4a3b1 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -115,6 +115,15 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + // MARK: - Streaming + + func streamRows(query: String) -> AsyncThrowingStream { + guard let pqConn = libpqConnection else { + return AsyncThrowingStream { $0.finish(throwing: LibPQPluginError.notConnected) } + } + return pqConn.streamQuery(query) + } + // MARK: - Reconnect private func isConnectionLostError(_ error: NSError) -> Bool { From 3d2dfd3a878db48ee3fc275a7d952d640b096535 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 20:24:53 +0700 Subject: [PATCH 11/13] docs: rewrite data grid result truncation section for #956 --- docs/features/data-grid.mdx | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) 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: - + Progressive loading status bar Progressive loading status bar -- **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 From c4624cf17a2c09ac449c0c88470b5ab49af88ffa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 20:24:54 +0700 Subject: [PATCH 12/13] chore: refresh Localizable.xcstrings --- TablePro/Resources/Localizable.xcstrings | 223 ++++++++++++++++++++++- 1 file changed, 215 insertions(+), 8 deletions(-) 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" : { From a6b40856a6f7864672c67709d4eb306f6e07ed06 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 1 May 2026 20:42:33 +0700 Subject: [PATCH 13/13] fix(driver): treat rowCap of 0 as unlimited in executeUserQuery --- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 4 ++-- .../TableProPluginKit/PluginDatabaseDriver.swift | 2 +- .../Core/Database/ExecuteUserQueryTests.swift | 14 +++++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index ce6a33929..2bc91570b 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -563,7 +563,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { 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, raw.rows.count > cap else { return raw } + guard let cap = rowCap, cap > 0, raw.rows.count > cap else { return raw } return PluginQueryResult( columns: raw.columns, columnTypeNames: raw.columnTypeNames, @@ -588,7 +588,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns = header.columns columnTypeNames = header.columnTypeNames case .rows(let batch): - if let cap = rowCap { + if let cap = rowCap, cap > 0 { let remaining = cap - rows.count if remaining <= 0 { truncated = true diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index c401d6218..d5f56e1d0 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -524,7 +524,7 @@ public extension PluginDatabaseDriver { } else { raw = try await execute(query: query) } - guard let cap = rowCap, raw.rows.count > cap else { + guard let cap = rowCap, cap > 0, raw.rows.count > cap else { return raw } return PluginQueryResult( diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift index 510fd7bce..af2e0c08f 100644 --- a/TableProTests/Core/Database/ExecuteUserQueryTests.swift +++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift @@ -2,9 +2,6 @@ // ExecuteUserQueryTests.swift // TableProTests // -// Regression coverage for the executeUserQuery driver path that replaced -// the removed strip-and-replace pagination API. See issue #956. -// import Foundation import Testing @@ -49,6 +46,17 @@ struct ExecuteUserQueryTests { #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"]])