Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Query execution no longer rewrites user SQL. Result safety cap is applied at the row level instead of via query rewrite. When a result is capped, the status bar shows the loaded row count with a Fetch All button. Load More on user-query tabs is removed; table-view pagination is unchanged.
- Driver protocol: removed fetchFirstPage, fetchNextPage, fetchRows, fetchRowCount and their parameterized variants. Replaced with executeUserQuery(query:rowCap:parameters:). PluginKit ABI bumped to 9. Separately distributed plugins (Oracle, DuckDB, MSSQL, MongoDB, BigQuery, LibSQL, Cassandra, Etcd, CloudflareD1, DynamoDB) require update before use with this version.
- Settings renamed: `enforceQueryResultLimit` is now `truncateQueryResults`, `queryResultLimit` is now `queryResultRowCap`. Custom values revert to default on first launch after upgrade.
- MCP server lazy-starts on first external request. Manual enable in Settings is no longer required
- Settings tab renamed from "MCP" to "Integrations" with new sections for connected clients, activity log, and pairing
- Integrations settings: rename MCP Server section to Integrations, restructure with searchable activity log, native list with keyboard navigation, accessibility labels, color-blind-safe status icons.
Expand Down Expand Up @@ -86,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- SELECT queries with a user-written LIMIT now return the requested row count. Previously the query engine stripped the user's LIMIT and substituted its own cap, so `LIMIT 10` could return up to 10,000 rows. Affected SQLite, DuckDB, LibSQL, ClickHouse, Redshift, CloudflareD1, and the MCP query path. Mirror bug on MSSQL and Oracle silently injected an ORDER BY into queries that lacked one. (#956)
- New tab from the empty "no tabs open" state opened a separate window-tab next to the placeholder instead of replacing it. The toolbar `+`, ⌘T, and the native NSWindow tab `+` now add the new query to the current empty window when its tab manager has no tabs.
- File associations for `.sql`, `.sqlite`, `.duckdb`, and related extensions disabled in Finder's Open With menu. The custom UTIs (`com.tablepro.sql`, `com.tablepro.sqlite-db`, `com.tablepro.duckdb`) were declared under `UTImportedTypeDeclarations` instead of `UTExportedTypeDeclarations`, so Launch Services treated them as "imported" claims and ranked them below other apps. SQL is now `LSHandlerRank: Owner`, SQLite is `Default`, DuckDB is `Owner`/`Editor`, and `com.tablepro.sqlite-db` conforms to `com.apple.sqlite3`.
- Crash on macOS 26 when opening SQL Preview (NSColor.cgColor calls deprecated colorSpaceName)
Expand Down
124 changes: 1 addition & 123 deletions Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Plugins/BigQueryDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>8</integer>
<integer>9</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/CSVExportPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>8</integer>
<integer>9</integer>
</dict>
</plist>
19 changes: 0 additions & 19 deletions Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
2 changes: 1 addition & 1 deletion Plugins/CassandraDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>TableProPluginKitVersion</key>
<integer>8</integer>
<integer>9</integer>
<key>NSPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).CassandraPlugin</string>
</dict>
Expand Down
53 changes: 0 additions & 53 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Plugins/ClickHouseDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>8</integer>
<integer>9</integer>
</dict>
</plist>
51 changes: 1 addition & 50 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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] {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Plugins/CloudflareD1DriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>8</integer>
<integer>9</integer>
</dict>
</plist>
Loading
Loading