diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2422a828..9b735ab99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- CockroachDB support over the PostgreSQL wire protocol: browse tables, schemas, columns, indexes, and foreign keys, run queries, and view EXPLAIN plans. The new Connection Options field passes libpq options such as CockroachDB Cloud cluster routing. (#1226)
- AI Chat: OpenAI provider now uses the Responses API for GPT-5 and Codex models, with reasoning shown in a collapsible Thinking panel above each reply. (#1112)
- AI Chat: image input via drag-and-drop or paste into the composer. HEIC, TIFF, and BMP convert to PNG or JPEG. EXIF and GPS metadata are stripped before sending. (#1112)
- AI Chat: reasoning effort picker for OpenAI (Minimal to Extra High) and Claude (Low to Extra High), shown only for models that support it. (#1112)
diff --git a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift
new file mode 100644
index 000000000..1417256c7
--- /dev/null
+++ b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift
@@ -0,0 +1,349 @@
+//
+// CockroachPluginDriver.swift
+// PostgreSQLDriverPlugin
+//
+// CockroachDB PluginDatabaseDriver implementation.
+// CockroachDB speaks the PostgreSQL wire protocol, so it shares the libpq
+// connection core. Schema introspection uses information_schema and the
+// CockroachDB-native SHOW statements where pg_catalog does not fit.
+//
+
+import Foundation
+import os
+import TableProPluginKit
+
+final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable {
+ let core: LibPQDriverCore
+
+ private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "CockroachPluginDriver")
+
+ private var cachedServerVersion: String?
+
+ var capabilities: PluginCapabilities {
+ [
+ .parameterizedQueries,
+ .transactions,
+ .multiSchema,
+ .cancelQuery,
+ .batchExecute,
+ .materializedViews,
+ ]
+ }
+
+ init(config: DriverConnectionConfig) {
+ self.core = LibPQDriverCore(config: config)
+ }
+
+ // MARK: - Connection
+
+ func connect() async throws {
+ try await core.connect()
+
+ if let result = try? await core.execute(query: "SELECT version()"),
+ let version = result.rows.first?.first?.asText {
+ cachedServerVersion = version
+ }
+ }
+
+ var serverVersion: String? {
+ cachedServerVersion ?? core.serverVersion
+ }
+
+ // MARK: - EXPLAIN
+
+ func buildExplainQuery(_ sql: String) -> String? {
+ "EXPLAIN \(sql)"
+ }
+
+ // MARK: - Schema
+
+ func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
+ let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
+ let query = """
+ SELECT table_name, table_type
+ FROM information_schema.tables
+ WHERE table_schema = '\(schemaLiteral)'
+ ORDER BY table_name
+ """
+ let result = try await execute(query: query)
+ return result.rows.compactMap { row -> PluginTableInfo? in
+ guard let name = row[0].asText else { return nil }
+ let typeStr = (row[1].asText ?? "BASE TABLE").uppercased()
+ let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE"
+ return PluginTableInfo(name: name, type: type)
+ }
+ }
+
+ func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
+ let safeTable = escapeLiteral(table)
+ let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
+ let query = Self.columnsQuery(schemaLiteral: schemaLiteral, tableFilter: "AND c.table_name = '\(safeTable)'")
+ let result = try await execute(query: query)
+ return result.rows.compactMap { Self.mapColumnRow($0, includesTableName: false) }
+ }
+
+ func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
+ let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
+ let query = Self.columnsQuery(schemaLiteral: schemaLiteral, tableFilter: "", includesTableName: true)
+ let result = try await execute(query: query)
+ var allColumns: [String: [PluginColumnInfo]] = [:]
+ for row in result.rows {
+ guard let tableName = row.first?.asText,
+ let column = Self.mapColumnRow(row, includesTableName: true) else { continue }
+ allColumns[tableName, default: []].append(column)
+ }
+ return allColumns
+ }
+
+ func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] {
+ let quotedTable = quoteIdentifier(table)
+ let query = "SHOW INDEXES FROM \(quoteIdentifier(core.currentSchema)).\(quotedTable)"
+ let result = try await execute(query: query)
+
+ guard let columnIndex = result.columns.firstIndex(of: "column_name"),
+ let nameIndex = result.columns.firstIndex(of: "index_name") else {
+ return []
+ }
+ let nonUniqueIndex = result.columns.firstIndex(of: "non_unique")
+ let implicitIndex = result.columns.firstIndex(of: "implicit")
+
+ var columnsByIndex: [String: [String]] = [:]
+ var uniqueByIndex: [String: Bool] = [:]
+ var order: [String] = []
+
+ for row in result.rows {
+ guard nameIndex < row.count, columnIndex < row.count,
+ let indexName = row[nameIndex].asText,
+ let columnName = row[columnIndex].asText else { continue }
+
+ if let implicitIndex, implicitIndex < row.count,
+ row[implicitIndex].asText.map(Self.isTruthy) == true {
+ continue
+ }
+
+ if columnsByIndex[indexName] == nil {
+ order.append(indexName)
+ if let nonUniqueIndex, nonUniqueIndex < row.count {
+ uniqueByIndex[indexName] = row[nonUniqueIndex].asText.map(Self.isTruthy) == false
+ } else {
+ uniqueByIndex[indexName] = false
+ }
+ }
+ columnsByIndex[indexName, default: []].append(columnName)
+ }
+
+ return order.map { name in
+ PluginIndexInfo(
+ name: name,
+ columns: columnsByIndex[name] ?? [],
+ isUnique: uniqueByIndex[name] ?? false,
+ isPrimary: name.hasSuffix("_pkey") || name.lowercased() == "primary"
+ )
+ }
+ }
+
+ func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] {
+ let safeTable = escapeLiteral(table)
+ let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
+ let query = """
+ SELECT
+ tc.constraint_name,
+ kcu.column_name,
+ ccu.table_name AS referenced_table,
+ ccu.column_name AS referenced_column,
+ rc.delete_rule,
+ rc.update_rule
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu
+ ON tc.constraint_name = kcu.constraint_name
+ AND tc.table_schema = kcu.table_schema
+ JOIN information_schema.referential_constraints rc
+ ON tc.constraint_name = rc.constraint_name
+ AND tc.table_schema = rc.constraint_schema
+ JOIN information_schema.constraint_column_usage ccu
+ ON rc.unique_constraint_name = ccu.constraint_name
+ AND rc.unique_constraint_schema = ccu.table_schema
+ WHERE tc.table_name = '\(safeTable)'
+ AND tc.table_schema = '\(schemaLiteral)'
+ AND tc.constraint_type = 'FOREIGN KEY'
+ ORDER BY tc.constraint_name
+ """
+ let result = try await execute(query: query)
+ return result.rows.compactMap { row -> PluginForeignKeyInfo? in
+ guard row.count >= 6,
+ let name = row[0].asText,
+ let column = row[1].asText,
+ let refTable = row[2].asText,
+ let refColumn = row[3].asText
+ else { return nil }
+ return PluginForeignKeyInfo(
+ name: name,
+ column: column,
+ referencedTable: refTable,
+ referencedColumn: refColumn,
+ onDelete: row[4].asText ?? "NO ACTION",
+ onUpdate: row[5].asText ?? "NO ACTION"
+ )
+ }
+ }
+
+ func fetchTableDDL(table: String, schema: String?) async throws -> String {
+ let quotedTable = quoteIdentifier(table)
+ let result = try await execute(query: "SHOW CREATE TABLE \(quoteIdentifier(core.currentSchema)).\(quotedTable)")
+ guard let ddl = Self.createStatement(from: result) else {
+ throw LibPQPluginError(message: "Failed to fetch DDL for table '\(table)'", sqlState: nil, detail: nil)
+ }
+ return ddl
+ }
+
+ func fetchViewDefinition(view: String, schema: String?) async throws -> String {
+ let quotedView = quoteIdentifier(view)
+ let result = try await execute(query: "SHOW CREATE VIEW \(quoteIdentifier(core.currentSchema)).\(quotedView)")
+ guard let ddl = Self.createStatement(from: result) else {
+ throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil)
+ }
+ return ddl
+ }
+
+ func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata {
+ PluginTableMetadata(tableName: table, engine: "CockroachDB")
+ }
+
+ func fetchDatabases() async throws -> [String] {
+ let result = try await execute(
+ query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
+ )
+ return result.rows.compactMap { $0.first?.asText }
+ }
+
+ func fetchSchemas() async throws -> [String] {
+ let result = try await execute(query: PostgreSQLSchemaQueries.listSchemas)
+ return result.rows.compactMap { $0.first?.asText }
+ }
+
+ func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata {
+ let escapedDb = escapeLiteral(database)
+ let query = """
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_catalog = '\(escapedDb)'
+ AND table_schema NOT IN ('pg_catalog', 'information_schema', 'crdb_internal', 'pg_extension')
+ """
+ let tableCount = (try? await execute(query: query))
+ .flatMap { $0.rows.first?.first?.asText }
+ .flatMap { Int($0) }
+
+ let systemDatabases = ["postgres", "system", "defaultdb"]
+ return PluginDatabaseMetadata(
+ name: database,
+ tableCount: tableCount,
+ sizeBytes: nil,
+ isSystemDatabase: systemDatabases.contains(database)
+ )
+ }
+
+ // MARK: - Database Management
+
+ func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? {
+ PluginCreateDatabaseFormSpec(fields: [])
+ }
+
+ func createDatabase(_ request: PluginCreateDatabaseRequest) async throws {
+ let quotedName = request.name.replacingOccurrences(of: "\"", with: "\"\"")
+ _ = try await execute(query: "CREATE DATABASE \"\(quotedName)\"")
+ }
+
+ func dropDatabase(name: String) async throws {
+ let quotedName = name.replacingOccurrences(of: "\"", with: "\"\"")
+ _ = try await execute(query: "DROP DATABASE \"\(quotedName)\"")
+ }
+
+ // MARK: - Query Helpers
+
+ private static func columnsQuery(
+ schemaLiteral: String,
+ tableFilter: String,
+ includesTableName: Bool = false
+ ) -> String {
+ let selectPrefix = includesTableName ? "c.table_name,\n" : ""
+ let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position"
+ return """
+ SELECT
+ \(selectPrefix)c.column_name,
+ c.data_type,
+ c.is_nullable,
+ c.column_default,
+ c.collation_name,
+ pgd.description,
+ c.udt_name,
+ CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk
+ FROM information_schema.columns c
+ LEFT JOIN pg_catalog.pg_class cls
+ ON cls.relname = c.table_name
+ AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)
+ LEFT JOIN pg_catalog.pg_description pgd
+ ON pgd.objoid = cls.oid
+ AND pgd.objsubid = c.ordinal_position
+ LEFT JOIN (
+ SELECT DISTINCT kcu.table_name, kcu.column_name
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu
+ ON tc.constraint_name = kcu.constraint_name
+ AND tc.table_schema = kcu.table_schema
+ WHERE tc.constraint_type = 'PRIMARY KEY'
+ AND tc.table_schema = '\(schemaLiteral)'
+ ) pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name
+ WHERE c.table_schema = '\(schemaLiteral)' \(tableFilter)
+ ORDER BY \(orderBy)
+ """
+ }
+
+ private static func mapColumnRow(_ row: [PluginCellValue], includesTableName: Bool) -> PluginColumnInfo? {
+ let offset = includesTableName ? 1 : 0
+ guard row.count >= offset + 8,
+ let name = row[offset].asText,
+ let rawDataType = row[offset + 1].asText
+ else { return nil }
+
+ let udtName = row[offset + 6].asText
+ let dataType: String
+ if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName {
+ dataType = "ENUM(\(udt))"
+ } else {
+ dataType = rawDataType.uppercased()
+ }
+
+ let isNullable = row[offset + 2].asText == "YES"
+ let defaultValue = row[offset + 3].asText
+ let collation = row[offset + 4].asText
+ let comment = row[offset + 5].asText
+ let isPk = row[offset + 7].asText == "YES"
+
+ let charset: String? = collation.flatMap { coll in
+ coll.contains(".") ? coll.components(separatedBy: ".").last : nil
+ }
+
+ return PluginColumnInfo(
+ name: name,
+ dataType: dataType,
+ isNullable: isNullable,
+ isPrimaryKey: isPk,
+ defaultValue: defaultValue,
+ charset: charset,
+ collation: collation,
+ comment: comment?.isEmpty == false ? comment : nil
+ )
+ }
+
+ private static func createStatement(from result: PluginQueryResult) -> String? {
+ guard let row = result.rows.first else { return nil }
+ let createIndex = result.columns.firstIndex(of: "create_statement") ?? (row.count > 1 ? 1 : 0)
+ guard createIndex < row.count, let ddl = row[createIndex].asText, !ddl.isEmpty else { return nil }
+ return ddl
+ }
+
+ private static func isTruthy(_ value: String) -> Bool {
+ let lowered = value.lowercased()
+ return lowered == "t" || lowered == "true"
+ }
+}
diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist
index 873bc5cc8..e09f6b0c1 100644
--- a/Plugins/PostgreSQLDriverPlugin/Info.plist
+++ b/Plugins/PostgreSQLDriverPlugin/Info.plist
@@ -8,6 +8,7 @@
PostgreSQL
Redshift
+ CockroachDB
diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
new file mode 100644
index 000000000..84979e0df
--- /dev/null
+++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
@@ -0,0 +1,196 @@
+//
+// LibPQDriverCore.swift
+// PostgreSQLDriverPlugin
+//
+// Shared libpq connection lifecycle and query execution for every
+// PostgreSQL-wire driver in this plugin (PostgreSQL, Redshift, CockroachDB).
+//
+
+import Foundation
+import os
+import TableProPluginKit
+
+final class LibPQDriverCore: @unchecked Sendable {
+ private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "LibPQDriverCore")
+
+ private let config: DriverConnectionConfig
+ private var libpqConnection: LibPQPluginConnection?
+
+ var currentSchema: String = "public"
+
+ var serverVersion: String? { libpqConnection?.serverVersion() }
+ var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 }
+
+ init(config: DriverConnectionConfig) {
+ self.config = config
+ }
+
+ // MARK: - Connection
+
+ func connect() async throws {
+ let pqConn = LibPQPluginConnection(
+ host: config.host,
+ port: config.port,
+ user: config.username,
+ password: config.password.isEmpty ? nil : config.password,
+ database: config.database,
+ sslConfig: config.ssl,
+ options: config.additionalFields["connectionOptions"]
+ )
+
+ try await pqConn.connect()
+ libpqConnection = pqConn
+
+ if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"),
+ let schema = schemaResult.rows.first?.first?.asText {
+ currentSchema = schema
+ }
+ }
+
+ func disconnect() {
+ libpqConnection?.disconnect()
+ libpqConnection = nil
+ }
+
+ func ping() async throws {
+ _ = try await execute(query: "SELECT 1")
+ }
+
+ // MARK: - Query Execution
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ try await executeWithReconnect(query: query, isRetry: false)
+ }
+
+ func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult {
+ guard let pqConn = libpqConnection else {
+ throw LibPQPluginError.notConnected
+ }
+ let startTime = Date()
+ let result = try await pqConn.executeParameterizedQuery(query, parameters: parameters)
+ return PluginQueryResult(
+ columns: result.columns,
+ columnTypeNames: result.columnTypeNames,
+ rows: result.rows,
+ rowsAffected: result.affectedRows,
+ executionTime: Date().timeIntervalSince(startTime),
+ isTruncated: result.isTruncated
+ )
+ }
+
+ func streamRows(query: String) -> AsyncThrowingStream {
+ guard let pqConn = libpqConnection else {
+ return AsyncThrowingStream { $0.finish(throwing: LibPQPluginError.notConnected) }
+ }
+ return pqConn.streamQuery(query)
+ }
+
+ func cancelQuery() {
+ libpqConnection?.cancelCurrentQuery()
+ }
+
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ let ms = seconds * 1_000
+ _ = try await execute(query: "SET statement_timeout = '\(ms)'")
+ }
+
+ // MARK: - Reconnect
+
+ private func executeWithReconnect(query: String, isRetry: Bool) async throws -> PluginQueryResult {
+ guard let pqConn = libpqConnection else {
+ throw LibPQPluginError.notConnected
+ }
+
+ let startTime = Date()
+
+ do {
+ let result = try await pqConn.executeQuery(query)
+ return PluginQueryResult(
+ columns: result.columns,
+ columnTypeNames: result.columnTypeNames,
+ rows: result.rows,
+ rowsAffected: result.affectedRows,
+ executionTime: Date().timeIntervalSince(startTime),
+ isTruncated: result.isTruncated
+ )
+ } catch let error as NSError where !isRetry && Self.isConnectionLostError(error) {
+ try await reconnect()
+ return try await executeWithReconnect(query: query, isRetry: true)
+ }
+ }
+
+ private func reconnect() async throws {
+ libpqConnection?.disconnect()
+ libpqConnection = nil
+ try await connect()
+ }
+
+ private static func isConnectionLostError(_ error: NSError) -> Bool {
+ let errorMessage = error.localizedDescription.lowercased()
+ return errorMessage.contains("connection") &&
+ (errorMessage.contains("lost") ||
+ errorMessage.contains("closed") ||
+ errorMessage.contains("no connection") ||
+ errorMessage.contains("could not send"))
+ }
+}
+
+// MARK: - LibPQBackedDriver
+
+protocol LibPQBackedDriver: PluginDatabaseDriver {
+ var core: LibPQDriverCore { get }
+}
+
+extension LibPQBackedDriver {
+ func connect() async throws {
+ try await core.connect()
+ }
+
+ func disconnect() {
+ core.disconnect()
+ }
+
+ func ping() async throws {
+ try await core.ping()
+ }
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ try await core.execute(query: query)
+ }
+
+ func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult {
+ try await core.executeParameterized(query: query, parameters: parameters)
+ }
+
+ func streamRows(query: String) -> AsyncThrowingStream {
+ core.streamRows(query: query)
+ }
+
+ func cancelQuery() throws {
+ core.cancelQuery()
+ }
+
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ try await core.applyQueryTimeout(seconds)
+ }
+
+ func switchSchema(to schema: String) async throws {
+ let escapedName = schema.replacingOccurrences(of: "\"", with: "\"\"")
+ _ = try await core.execute(query: "SET search_path TO \"\(escapedName)\", public")
+ core.currentSchema = schema
+ }
+
+ var currentSchema: String? { core.currentSchema }
+ var supportsSchemas: Bool { true }
+ var supportsTransactions: Bool { true }
+ var serverVersion: String? { core.serverVersion }
+ var parameterStyle: ParameterStyle { .dollar }
+
+ func escapeLiteral(_ str: String) -> String {
+ escapeStringLiteral(str)
+ }
+
+ var escapedSchema: String {
+ escapeLiteral(core.currentSchema)
+ }
+}
diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
index 821dca47f..ad5c69b32 100644
--- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
+++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
@@ -93,6 +93,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
private let password: String?
private let database: String
private let sslConfig: SSLConfiguration
+ private let options: String?
private let stateLock = NSLock()
private var _isConnected: Bool = false
@@ -126,7 +127,8 @@ final class LibPQPluginConnection: @unchecked Sendable {
user: String,
password: String?,
database: String,
- sslConfig: SSLConfiguration = SSLConfiguration()
+ sslConfig: SSLConfiguration = SSLConfiguration(),
+ options: String? = nil
) {
self.host = host
self.port = port
@@ -134,6 +136,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
self.password = password
self.database = database
self.sslConfig = sslConfig
+ self.options = options
}
deinit {
@@ -178,6 +181,10 @@ final class LibPQPluginConnection: @unchecked Sendable {
connStr += " sslkey='\(escapeConnParam(sslConfig.clientKeyPath))'"
}
+ if let options, !options.isEmpty {
+ connStr += " options='\(escapeConnParam(options))'"
+ }
+
let connection = connStr.withCString { cStr in
PQconnectdb(cStr)
}
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
index d761d0679..89ba53455 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
@@ -14,7 +14,7 @@ import TableProPluginKit
final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let pluginName = "PostgreSQL Driver"
static let pluginVersion = "1.0.0"
- static let pluginDescription = "PostgreSQL/Redshift support via libpq"
+ static let pluginDescription = "PostgreSQL, Redshift, and CockroachDB support via libpq"
static let capabilities: [PluginCapability] = [.databaseDriver]
static let databaseTypeId = "PostgreSQL"
@@ -29,9 +29,16 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
fieldType: .toggle,
section: .authentication,
hidesPassword: true
+ ),
+ ConnectionField(
+ id: "connectionOptions",
+ label: String(localized: "Connection Options"),
+ placeholder: "--cluster=my-cluster",
+ fieldType: .text,
+ section: .advanced
)
]
- static let additionalDatabaseTypeIds: [String] = ["Redshift"]
+ static let additionalDatabaseTypeIds: [String] = ["Redshift", "CockroachDB"]
// MARK: - UI/Capability Metadata
@@ -116,15 +123,17 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
switch databaseTypeId {
case "PostgreSQL": return "PostgreSQL"
case "Redshift": return "Redshift"
+ case "CockroachDB": return "CockroachDB"
default: return nil
}
}
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
let variant = config.additionalFields["driverVariant"] ?? ""
- if variant == "Redshift" {
- return RedshiftPluginDriver(config: config)
+ switch variant {
+ case "Redshift": return RedshiftPluginDriver(config: config)
+ case "CockroachDB": return CockroachPluginDriver(config: config)
+ default: return PostgreSQLPluginDriver(config: config)
}
- return PostgreSQLPluginDriver(config: config)
}
}
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
index 70c358e5f..c67de3fef 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
@@ -10,22 +10,15 @@ import Foundation
import os
import TableProPluginKit
-final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
- private let config: DriverConnectionConfig
- private var libpqConnection: LibPQPluginConnection?
- private var _currentSchema: String = "public"
+final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
+ let core: LibPQDriverCore
private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLPluginDriver")
- var currentSchema: String? { _currentSchema }
- var supportsSchemas: Bool { true }
- var supportsTransactions: Bool { true }
- var serverVersion: String? { libpqConnection?.serverVersion() }
- var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 }
+ var serverVersionNumber: Int32 { core.serverVersionNumber }
var versionedCapabilities: PostgreSQLCapabilities {
- PostgreSQLCapabilities(serverVersion: serverVersionNumber)
+ PostgreSQLCapabilities(serverVersion: core.serverVersionNumber)
}
- var parameterStyle: ParameterStyle { .dollar }
var capabilities: PluginCapabilities {
[
@@ -43,133 +36,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
init(config: DriverConnectionConfig) {
- self.config = config
- }
-
- private var escapedSchema: String {
- escapeLiteral(_currentSchema)
- }
-
- private func escapeLiteral(_ str: String) -> String {
- var result = str
- result = result.replacingOccurrences(of: "'", with: "''")
- result = result.replacingOccurrences(of: "\0", with: "")
- return result
- }
-
- // MARK: - Connection
-
- func connect() async throws {
- let sslConfig = config.ssl
-
- let pqConn = LibPQPluginConnection(
- host: config.host,
- port: config.port,
- user: config.username,
- password: config.password.isEmpty ? nil : config.password,
- database: config.database,
- sslConfig: sslConfig
- )
-
- try await pqConn.connect()
- self.libpqConnection = pqConn
-
- if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"),
- let schema = schemaResult.rows.first?.first?.asText {
- _currentSchema = schema
- }
- }
-
- func disconnect() {
- libpqConnection?.disconnect()
- libpqConnection = nil
- }
-
- func ping() async throws {
- _ = try await execute(query: "SELECT 1")
- }
-
- // MARK: - Query Execution
-
- func execute(query: String) async throws -> PluginQueryResult {
- try await executeWithReconnect(query: query, isRetry: false)
- }
-
- private func executeWithReconnect(query: String, isRetry: Bool) async throws -> PluginQueryResult {
- guard let pqConn = libpqConnection else {
- throw LibPQPluginError.notConnected
- }
-
- let startTime = Date()
-
- do {
- let result = try await pqConn.executeQuery(query)
- return PluginQueryResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- rowsAffected: result.affectedRows,
- executionTime: Date().timeIntervalSince(startTime),
- isTruncated: result.isTruncated
- )
- } catch let error as NSError where !isRetry && isConnectionLostError(error) {
- try await reconnect()
- return try await executeWithReconnect(query: query, isRetry: true)
- }
- }
-
- func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult {
- guard let pqConn = libpqConnection else {
- throw LibPQPluginError.notConnected
- }
-
- let startTime = Date()
- let result = try await pqConn.executeParameterizedQuery(query, parameters: parameters)
- return PluginQueryResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- rowsAffected: result.affectedRows,
- executionTime: Date().timeIntervalSince(startTime),
- isTruncated: result.isTruncated
- )
- }
-
- // 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 {
- let errorMessage = error.localizedDescription.lowercased()
- return errorMessage.contains("connection") &&
- (errorMessage.contains("lost") ||
- errorMessage.contains("closed") ||
- errorMessage.contains("no connection") ||
- errorMessage.contains("could not send"))
- }
-
- private func reconnect() async throws {
- libpqConnection?.disconnect()
- libpqConnection = nil
- try await connect()
- }
-
- // MARK: - Cancellation
-
- func cancelQuery() throws {
- libpqConnection?.cancelCurrentQuery()
- }
-
- func applyQueryTimeout(_ seconds: Int) async throws {
- let ms = seconds * 1_000
- _ = try await execute(query: "SET statement_timeout = '\(ms)'")
+ self.core = LibPQDriverCore(config: config)
}
// MARK: - EXPLAIN
@@ -233,7 +100,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Schema
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
- let schemaLiteral = escapeLiteral(schema ?? _currentSchema)
+ let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let caps = versionedCapabilities
var unions: [String] = [
@@ -527,7 +394,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
var parts = columnDefs
parts.append(contentsOf: constraints)
- let quotedSchema = "\"\(_currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\""
+ let quotedSchema = "\"\(core.currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\""
let ddl = "CREATE TABLE \(quotedSchema).\(quotedTable) (\n " +
parts.joined(separator: ",\n ") +
"\n);"
@@ -596,12 +463,6 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return result.rows.compactMap { row in row.first?.asText }
}
- func switchSchema(to schema: String) async throws {
- let escapedName = schema.replacingOccurrences(of: "\"", with: "\"\"")
- _ = try await execute(query: "SET search_path TO \"\(escapedName)\", public")
- _currentSchema = schema
- }
-
func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata {
let escapedDbLiteral = escapeLiteral(database)
let query = """
@@ -692,7 +553,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval%'
"""
let result = try await execute(query: query)
- let schemaName = schema ?? _currentSchema
+ let schemaName = schema ?? core.currentSchema
return result.rows.compactMap { row -> (name: String, ddl: String)? in
guard let seqName = row[0].asText else { return nil }
let startVal = row[1].asText ?? "1"
@@ -988,7 +849,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? {
guard !definition.columns.isEmpty else { return nil }
- let schema = _currentSchema
+ let schema = core.currentSchema
let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))"
let pkColumns = definition.columns.filter { $0.isPrimaryKey }
let inlinePK = pkColumns.count == 1
@@ -1109,7 +970,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - ALTER TABLE DDL
private func qualifiedTableName(_ table: String) -> String {
- "\(quoteIdentifier(_currentSchema)).\(quoteIdentifier(table))"
+ "\(quoteIdentifier(core.currentSchema)).\(quoteIdentifier(table))"
}
func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
@@ -1163,7 +1024,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
func generateDropIndexSQL(table: String, indexName: String) -> String? {
- "DROP INDEX \(quoteIdentifier(_currentSchema)).\(quoteIdentifier(indexName))"
+ "DROP INDEX \(quoteIdentifier(core.currentSchema)).\(quoteIdentifier(indexName))"
}
func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? {
diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
index 20bc32b3f..1877a7257 100644
--- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
@@ -10,19 +10,11 @@ import Foundation
import os
import TableProPluginKit
-final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
- private let config: DriverConnectionConfig
- private var libpqConnection: LibPQPluginConnection?
- private var _currentSchema: String = "public"
+final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
+ let core: LibPQDriverCore
private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "RedshiftPluginDriver")
- var currentSchema: String? { _currentSchema }
- var supportsSchemas: Bool { true }
- var supportsTransactions: Bool { true }
- var serverVersion: String? { libpqConnection?.serverVersion() }
- var parameterStyle: ParameterStyle { .dollar }
-
var capabilities: PluginCapabilities {
[
.parameterizedQueries,
@@ -34,132 +26,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
init(config: DriverConnectionConfig) {
- self.config = config
- }
-
- private var escapedSchema: String {
- escapeLiteral(_currentSchema)
- }
-
- private func escapeLiteral(_ str: String) -> String {
- var result = str
- result = result.replacingOccurrences(of: "'", with: "''")
- result = result.replacingOccurrences(of: "\0", with: "")
- return result
- }
-
- // MARK: - Connection
-
- func connect() async throws {
- let sslConfig = config.ssl
-
- let pqConn = LibPQPluginConnection(
- host: config.host,
- port: config.port,
- user: config.username,
- password: config.password.isEmpty ? nil : config.password,
- database: config.database,
- sslConfig: sslConfig
- )
-
- try await pqConn.connect()
- self.libpqConnection = pqConn
-
- if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"),
- let schema = schemaResult.rows.first?.first?.asText {
- _currentSchema = schema
- }
- }
-
- func disconnect() {
- libpqConnection?.disconnect()
- libpqConnection = nil
- }
-
- func ping() async throws {
- _ = try await execute(query: "SELECT 1")
- }
-
- // MARK: - Query Execution
-
- func execute(query: String) async throws -> PluginQueryResult {
- try await executeWithReconnect(query: query, isRetry: false)
- }
-
- private func executeWithReconnect(query: String, isRetry: Bool) async throws -> PluginQueryResult {
- guard let pqConn = libpqConnection else {
- throw LibPQPluginError.notConnected
- }
-
- let startTime = Date()
-
- do {
- let result = try await pqConn.executeQuery(query)
- return PluginQueryResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- rowsAffected: result.affectedRows,
- executionTime: Date().timeIntervalSince(startTime),
- isTruncated: result.isTruncated
- )
- } catch let error as NSError where !isRetry && isConnectionLostError(error) {
- try await reconnect()
- return try await executeWithReconnect(query: query, isRetry: true)
- }
- }
-
- func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult {
- guard let pqConn = libpqConnection else {
- throw LibPQPluginError.notConnected
- }
- let startTime = Date()
- let result = try await pqConn.executeParameterizedQuery(query, parameters: parameters)
- return PluginQueryResult(
- columns: result.columns,
- columnTypeNames: result.columnTypeNames,
- rows: result.rows,
- rowsAffected: result.affectedRows,
- executionTime: Date().timeIntervalSince(startTime),
- isTruncated: result.isTruncated
- )
- }
-
- // 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 {
- let errorMessage = error.localizedDescription.lowercased()
- return errorMessage.contains("connection") &&
- (errorMessage.contains("lost") ||
- errorMessage.contains("closed") ||
- errorMessage.contains("no connection") ||
- errorMessage.contains("could not send"))
- }
-
- private func reconnect() async throws {
- libpqConnection?.disconnect()
- libpqConnection = nil
- try await connect()
- }
-
- // MARK: - Cancellation
-
- func cancelQuery() throws {
- libpqConnection?.cancelCurrentQuery()
- }
-
- func applyQueryTimeout(_ seconds: Int) async throws {
- let ms = seconds * 1_000
- _ = try await execute(query: "SET statement_timeout = '\(ms)'")
+ self.core = LibPQDriverCore(config: config)
}
// MARK: - EXPLAIN
@@ -429,7 +296,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func fetchTableDDL(table: String, schema: String?) async throws -> String {
let safeTable = escapeLiteral(table)
let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\""
- let quotedSchema = "\"\(_currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\""
+ let quotedSchema = "\"\(core.currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\""
do {
let showResult = try await execute(query: "SHOW TABLE \(quotedSchema).\(quotedTable)")
@@ -547,12 +414,6 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return result.rows.compactMap { row in row.first?.asText }
}
- func switchSchema(to schema: String) async throws {
- let escapedName = schema.replacingOccurrences(of: "\"", with: "\"\"")
- _ = try await execute(query: "SET search_path TO \"\(escapedName)\", public")
- _currentSchema = schema
- }
-
func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata {
let escapedDbLiteral = escapeLiteral(database)
let countQuery = """
diff --git a/TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json
new file mode 100644
index 000000000..8f89e74a9
--- /dev/null
+++ b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "cockroachdb.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg
new file mode 100644
index 000000000..9d5feb870
--- /dev/null
+++ b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg
@@ -0,0 +1 @@
+
diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift
index 96cb7e7ea..6e1b5d12a 100644
--- a/TablePro/Core/Database/DatabaseManager+Schema.swift
+++ b/TablePro/Core/Database/DatabaseManager+Schema.swift
@@ -116,7 +116,8 @@ extension DatabaseManager {
driver: DatabaseDriver
) async -> String? {
// Only needed for PostgreSQL PK modifications
- guard databaseType == .postgresql || databaseType == .redshift || databaseType == .duckdb else { return nil }
+ guard databaseType == .postgresql || databaseType == .redshift
+ || databaseType == .cockroachdb || databaseType == .duckdb else { return nil }
guard
changes.contains(where: {
if case .modifyPrimaryKey = $0 { return true }
diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift
index d4b050261..adb9bbdb5 100644
--- a/TablePro/Core/Plugins/PluginManager+Registration.swift
+++ b/TablePro/Core/Plugins/PluginManager+Registration.swift
@@ -41,7 +41,12 @@ extension PluginManager {
)
PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId, preserveIcon: true)
for additionalId in driverType.additionalDatabaseTypeIds {
- PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: additionalId, preserveIcon: true)
+ var additionalSnapshot = snapshot
+ if let existingDefault = PluginMetadataRegistry.shared.snapshot(forTypeId: additionalId),
+ !existingDefault.explainVariants.isEmpty {
+ additionalSnapshot = snapshot.withExplainVariants(existingDefault.explainVariants)
+ }
+ PluginMetadataRegistry.shared.register(snapshot: additionalSnapshot, forTypeId: additionalId, preserveIcon: true)
PluginMetadataRegistry.shared.registerTypeAlias(additionalId, primaryTypeId: typeId)
}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
index c9c61a2c2..dd4a7b6cb 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
@@ -163,6 +163,23 @@ struct PluginMetadataSnapshot: Sendable {
)
}
+ func withExplainVariants(_ newExplainVariants: [ExplainVariant]) -> PluginMetadataSnapshot {
+ PluginMetadataSnapshot(
+ displayName: displayName, iconName: iconName, defaultPort: defaultPort,
+ requiresAuthentication: requiresAuthentication, supportsForeignKeys: supportsForeignKeys,
+ supportsSchemaEditing: supportsSchemaEditing, isDownloadable: isDownloadable,
+ primaryUrlScheme: primaryUrlScheme, parameterStyle: parameterStyle,
+ navigationModel: navigationModel, explainVariants: newExplainVariants,
+ pathFieldRole: pathFieldRole, supportsHealthMonitor: supportsHealthMonitor,
+ urlSchemes: urlSchemes, postConnectActions: postConnectActions,
+ brandColorHex: brandColorHex, queryLanguageName: queryLanguageName,
+ editorLanguage: editorLanguage, connectionMode: connectionMode,
+ supportsDatabaseSwitching: supportsDatabaseSwitching,
+ supportsColumnReorder: supportsColumnReorder,
+ capabilities: capabilities, schema: schema, editor: editor, connection: connection
+ )
+ }
+
func withBranding(from source: PluginMetadataSnapshot) -> PluginMetadataSnapshot {
PluginMetadataSnapshot(
displayName: source.displayName, iconName: source.iconName, defaultPort: defaultPort,
@@ -394,6 +411,14 @@ final class PluginMetadataRegistry: @unchecked Sendable {
hidesPassword: true
)
+ let connectionOptionsField = ConnectionField(
+ id: "connectionOptions",
+ label: String(localized: "Connection Options"),
+ placeholder: "--cluster=my-cluster",
+ fieldType: .text,
+ section: .advanced
+ )
+
let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [
("MySQL", PluginMetadataSnapshot(
displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306,
@@ -530,7 +555,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
columnTypesByCategory: postgresqlColumnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
- additionalConnectionFields: [pgpassField],
+ additionalConnectionFields: [pgpassField, connectionOptionsField],
category: .relational,
tagline: String(localized: "Advanced object-relational SQL")
)
@@ -577,11 +602,70 @@ final class PluginMetadataRegistry: @unchecked Sendable {
columnTypesByCategory: postgresqlColumnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
- additionalConnectionFields: [pgpassField],
+ additionalConnectionFields: [pgpassField, connectionOptionsField],
category: .analytical,
tagline: String(localized: "Amazon's columnar warehouse on Postgres")
)
)),
+ ("CockroachDB", PluginMetadataSnapshot(
+ displayName: "CockroachDB", iconName: "cockroachdb-icon", defaultPort: 26_257,
+ requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false,
+ isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar,
+ navigationModel: .standard,
+ explainVariants: [
+ ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
+ ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN ANALYZE"),
+ ],
+ pathFieldRole: .database,
+ supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"],
+ postConnectActions: [.selectSchemaFromLastSession],
+ brandColorHex: "#6933FF",
+ queryLanguageName: "SQL", editorLanguage: .sql,
+ connectionMode: .network, supportsDatabaseSwitching: true,
+ supportsColumnReorder: false,
+ capabilities: PluginMetadataSnapshot.CapabilityFlags(
+ supportsSchemaSwitching: true,
+ supportsImport: true,
+ supportsExport: true,
+ supportsSSH: true,
+ supportsSSL: true,
+ supportsCascadeDrop: true,
+ supportsForeignKeyDisable: false,
+ supportsReadOnlyMode: true,
+ supportsQueryProgress: false,
+ requiresReconnectForDatabaseSwitch: true,
+ supportsDropDatabase: true,
+ supportsAddColumn: false,
+ supportsModifyColumn: false,
+ supportsDropColumn: false,
+ supportsRenameColumn: false,
+ supportsAddIndex: false,
+ supportsDropIndex: false,
+ supportsModifyPrimaryKey: false
+ ),
+ schema: PluginMetadataSnapshot.SchemaInfo(
+ defaultSchemaName: "public",
+ defaultGroupName: "main",
+ tableEntityName: "Tables",
+ defaultPrimaryKeyColumn: nil,
+ immutableColumns: [],
+ systemDatabaseNames: ["postgres", "system", "defaultdb"],
+ systemSchemaNames: [],
+ fileExtensions: [],
+ databaseGroupingStrategy: .bySchema,
+ structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
+ ),
+ editor: PluginMetadataSnapshot.EditorConfig(
+ sqlDialect: postgresqlDialect,
+ statementCompletions: [],
+ columnTypesByCategory: postgresqlColumnTypes
+ ),
+ connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: [pgpassField, connectionOptionsField],
+ category: .relational,
+ tagline: String(localized: "Distributed SQL, PostgreSQL-compatible")
+ )
+ )),
("SQLite", PluginMetadataSnapshot(
displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0,
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
@@ -644,6 +728,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
// Built-in type aliases: multi-type plugins where an alias maps to a primary plugin type ID
reverseTypeIndex["MariaDB"] = "MySQL"
reverseTypeIndex["Redshift"] = "PostgreSQL"
+ reverseTypeIndex["CockroachDB"] = "PostgreSQL"
reverseTypeIndex["ScyllaDB"] = "Cassandra"
reverseTypeIndex["Turso"] = "libSQL"
}
diff --git a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift
index 1f24abe9b..a5932196e 100644
--- a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift
+++ b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift
@@ -8,7 +8,7 @@ import Foundation
enum ServerDashboardQueryProviderFactory {
static func provider(for databaseType: DatabaseType) -> ServerDashboardQueryProvider? {
switch databaseType {
- case .postgresql, .redshift:
+ case .postgresql, .redshift, .cockroachdb:
return PostgreSQLDashboardProvider()
case .mysql, .mariadb:
return MySQLDashboardProvider()
diff --git a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift
index 620e38d52..1e4c58092 100644
--- a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift
+++ b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift
@@ -263,7 +263,7 @@ struct TablePlusImporter: ForeignAppImporter {
case "MSSQL": return "SQL Server"
case "Redshift": return "Redshift"
case "MariaDB": return "MariaDB"
- case "CockroachDB": return "PostgreSQL"
+ case "CockroachDB": return "CockroachDB"
default: return driver
}
}
@@ -272,6 +272,7 @@ struct TablePlusImporter: ForeignAppImporter {
switch dbType {
case "MySQL", "MariaDB": return 3_306
case "PostgreSQL", "Redshift": return 5_432
+ case "CockroachDB": return 26_257
case "MongoDB": return 27_017
case "Redis": return 6_379
case "SQL Server": return 1_433
diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift
index 178c28b9d..c607c07d6 100644
--- a/TablePro/Core/Services/Query/QueryPlanParser.swift
+++ b/TablePro/Core/Services/Query/QueryPlanParser.swift
@@ -346,6 +346,143 @@ struct IndentedTextPlanParser: QueryPlanParser {
}
}
+// MARK: - CockroachDB Parser
+
+/// Parses CockroachDB `EXPLAIN` and `EXPLAIN ANALYZE` text output. CockroachDB
+/// renders plans as a tree using `•` node markers with `│ ├── └──` tree-drawing
+/// characters, and `key: value` properties indented beneath each node.
+struct CockroachDBPlanParser: QueryPlanParser {
+ private struct RawNode {
+ let depth: Int
+ let operation: String
+ var properties: [String: String]
+ }
+
+ private static let knownKeys: Set = ["estimated row count", "actual row count", "table"]
+ private static let treeDecoration = CharacterSet(charactersIn: " \t│├└─")
+
+ func parse(rawText: String) -> QueryPlan? {
+ let lines = rawText.components(separatedBy: "\n")
+
+ var planningTime: Double?
+ var executionTime: Double?
+ var nodes: [RawNode] = []
+
+ for line in lines {
+ guard let bulletRange = line.range(of: "•") else {
+ let trimmed = line.trimmingCharacters(in: Self.treeDecoration)
+ guard !trimmed.isEmpty, let (key, value) = Self.keyValue(trimmed) else { continue }
+ switch key {
+ case "planning time":
+ planningTime = Self.milliseconds(from: value)
+ case "execution time":
+ executionTime = Self.milliseconds(from: value)
+ default:
+ if !nodes.isEmpty {
+ nodes[nodes.count - 1].properties[key] = value
+ }
+ }
+ continue
+ }
+
+ let depth = line.distance(from: line.startIndex, to: bulletRange.lowerBound)
+ let operation = line[bulletRange.upperBound...].trimmingCharacters(in: .whitespaces)
+ nodes.append(RawNode(depth: depth, operation: operation, properties: [:]))
+ }
+
+ guard !nodes.isEmpty else { return nil }
+
+ var index = 0
+ func build(parentDepth: Int) -> [QueryPlanNode] {
+ var result: [QueryPlanNode] = []
+ while index < nodes.count {
+ let raw = nodes[index]
+ if raw.depth <= parentDepth { break }
+ index += 1
+ let children = build(parentDepth: raw.depth)
+ result.append(Self.makeNode(raw, children: children))
+ }
+ return result
+ }
+
+ let roots = build(parentDepth: -1)
+ let rootNode: QueryPlanNode
+ if roots.count == 1 {
+ rootNode = roots[0]
+ } else {
+ rootNode = QueryPlanNode(
+ operation: "Query Plan",
+ relation: nil, schema: nil, alias: nil,
+ estimatedStartupCost: nil, estimatedTotalCost: nil,
+ estimatedRows: nil, estimatedWidth: nil,
+ actualStartupTime: nil, actualTotalTime: nil,
+ actualRows: nil, actualLoops: nil,
+ properties: [:],
+ children: roots
+ )
+ }
+
+ return QueryPlan(
+ rootNode: rootNode,
+ planningTime: planningTime,
+ executionTime: executionTime,
+ rawText: rawText
+ )
+ }
+
+ private static func makeNode(_ raw: RawNode, children: [QueryPlanNode]) -> QueryPlanNode {
+ let relation = raw.properties["table"].map { value in
+ String(value.split(separator: "@").first ?? Substring(value))
+ }
+ let properties = raw.properties.filter { !knownKeys.contains($0.key) }
+ return QueryPlanNode(
+ operation: raw.operation.isEmpty ? "Unknown" : raw.operation,
+ relation: relation,
+ schema: nil,
+ alias: nil,
+ estimatedStartupCost: nil,
+ estimatedTotalCost: nil,
+ estimatedRows: rowCount(from: raw.properties["estimated row count"]),
+ estimatedWidth: nil,
+ actualStartupTime: nil,
+ actualTotalTime: nil,
+ actualRows: rowCount(from: raw.properties["actual row count"]),
+ actualLoops: nil,
+ properties: properties,
+ children: children
+ )
+ }
+
+ private static func keyValue(_ line: String) -> (key: String, value: String)? {
+ guard let separator = line.range(of: ": ") else { return nil }
+ let key = String(line[.. Int? {
+ guard let value else { return nil }
+ let digits = value.prefix { $0.isNumber || $0 == "," }
+ .filter { $0 != "," }
+ return Int(digits)
+ }
+
+ private static func milliseconds(from value: String) -> Double? {
+ let trimmed = value.trimmingCharacters(in: .whitespaces)
+ let numberPart = trimmed.prefix { $0.isNumber || $0 == "." }
+ guard let number = Double(numberPart) else { return nil }
+ let unit = trimmed.dropFirst(numberPart.count).trimmingCharacters(in: .whitespaces).lowercased()
+ switch unit {
+ case "s": return number * 1_000
+ case "µs", "us": return number / 1_000
+ default: return number
+ }
+ }
+}
+
// MARK: - Factory
enum QueryPlanParserFactory {
@@ -353,6 +490,8 @@ enum QueryPlanParserFactory {
switch databaseType {
case .postgresql, .redshift:
return PostgreSQLPlanParser()
+ case .cockroachdb:
+ return CockroachDBPlanParser()
case .mysql, .mariadb:
return MySQLPlanParser()
case .sqlite:
diff --git a/TablePro/Core/Services/Query/SQLFunctionProvider.swift b/TablePro/Core/Services/Query/SQLFunctionProvider.swift
index 22e2185ee..56fab5c3a 100644
--- a/TablePro/Core/Services/Query/SQLFunctionProvider.swift
+++ b/TablePro/Core/Services/Query/SQLFunctionProvider.swift
@@ -18,7 +18,7 @@ internal enum SQLFunctionProvider {
SQLFunction(label: "UTC_TIMESTAMP()", expression: "UTC_TIMESTAMP()"),
SQLFunction(label: "UUID()", expression: "UUID()")
]
- } else if databaseType == .postgresql || databaseType == .redshift {
+ } else if databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb {
return [
SQLFunction(label: "now()", expression: "now()"),
SQLFunction(label: "CURRENT_TIMESTAMP", expression: "CURRENT_TIMESTAMP"),
diff --git a/TablePro/Core/Terminal/CLICommandResolver.swift b/TablePro/Core/Terminal/CLICommandResolver.swift
index a1d0ae797..0a2492805 100644
--- a/TablePro/Core/Terminal/CLICommandResolver.swift
+++ b/TablePro/Core/Terminal/CLICommandResolver.swift
@@ -73,7 +73,7 @@ enum CLICommandResolver {
return resolveMysql(connection: connection, password: password, database: dbName, customCliPath: customCliPath)
case .mariadb:
return resolveMariadbOrMysql(connection: connection, password: password, database: dbName, customCliPath: customCliPath)
- case .postgresql, .redshift:
+ case .postgresql, .redshift, .cockroachdb:
return resolvePsql(connection: connection, password: password, database: dbName, customCliPath: customCliPath)
case .redis:
return resolveRedisCli(connection: connection, password: password, customCliPath: customCliPath)
@@ -197,7 +197,7 @@ enum CLICommandResolver {
if !connection.username.isEmpty { cmd += " -u \(shellEscape(connection.username))" }
if !database.isEmpty { cmd += " \(shellEscape(database))" }
- case .postgresql, .redshift:
+ case .postgresql, .redshift, .cockroachdb:
if let password, !password.isEmpty {
envPrefix = "PGPASSWORD=\(shellEscape(password)) "
}
@@ -316,7 +316,7 @@ enum CLICommandResolver {
switch databaseType {
case .mysql: return "mysql"
case .mariadb: return "mariadb"
- case .postgresql, .redshift: return "psql"
+ case .postgresql, .redshift, .cockroachdb: return "psql"
case .redis: return "redis-cli"
case .mongodb: return "mongosh"
case .sqlite: return "sqlite3"
@@ -335,7 +335,7 @@ enum CLICommandResolver {
return "brew install mysql-client"
case .mariadb:
return "brew install mariadb"
- case .postgresql, .redshift:
+ case .postgresql, .redshift, .cockroachdb:
return "brew install libpq"
case .redis:
return "brew install redis"
diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
index 9bc95ba2c..7c4d1a574 100644
--- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
+++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
@@ -35,7 +35,7 @@ struct ConnectionURLFormatter {
// MARK: - Private
private static func urlScheme(for type: DatabaseType) -> String {
- PluginMetadataRegistry.shared.snapshot(forTypeId: type.pluginTypeId)?.primaryUrlScheme
+ PluginMetadataRegistry.shared.snapshot(forTypeId: type.rawValue)?.primaryUrlScheme
?? type.rawValue.lowercased()
}
diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
index 5ffdeb502..cd849a88f 100644
--- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
+++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
@@ -254,6 +254,8 @@ struct ConnectionURLParser {
return .postgresql
case "redshift":
return .redshift
+ case "cockroachdb", "cockroach":
+ return .cockroachdb
case "mysql":
return .mysql
case "mariadb":
diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
index c1bea5f21..1c8b8473c 100644
--- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
+++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
@@ -132,7 +132,7 @@ internal struct SQLRowToStatementConverter {
hex += String(format: "%02X", byte)
}
switch databaseType {
- case .postgresql, .redshift:
+ case .postgresql, .redshift, .cockroachdb:
return "'\\x\(hex)'::bytea"
case .mssql:
return "0x\(hex)"
diff --git a/TablePro/Info.plist b/TablePro/Info.plist
index d5e98b81d..2c577c05b 100644
--- a/TablePro/Info.plist
+++ b/TablePro/Info.plist
@@ -236,6 +236,8 @@
redis
rediss
redshift
+ cockroachdb
+ cockroach
mongodb+srv
mssql
sqlserver
diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift
index ad4253f18..9a96a837c 100644
--- a/TablePro/Models/Connection/DatabaseConnection.swift
+++ b/TablePro/Models/Connection/DatabaseConnection.swift
@@ -28,6 +28,7 @@ extension DatabaseType {
static let postgresql = DatabaseType(rawValue: "PostgreSQL")
static let sqlite = DatabaseType(rawValue: "SQLite")
static let redshift = DatabaseType(rawValue: "Redshift")
+ static let cockroachdb = DatabaseType(rawValue: "CockroachDB")
// Registry-distributed types (known plugins, downloadable separately)
static let mongodb = DatabaseType(rawValue: "MongoDB")
@@ -101,7 +102,11 @@ extension DatabaseType {
}
var defaultPort: Int {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.defaultPort ?? 0
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.defaultPort ?? 0
+ }
+
+ var explainVariants: [ExplainVariant] {
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.explainVariants ?? []
}
var category: DatabaseCategory {
@@ -119,6 +124,7 @@ extension DatabaseType {
case "MariaDB": Color(hex: "C0765A")
case "PostgreSQL": Color(hex: "336791")
case "Redshift": Color(hex: "527FFF")
+ case "CockroachDB": Color(hex: "6933FF")
case "SQLite": Color(hex: "0F80CC")
case "SQL Server": Color(hex: "CC2927")
case "Oracle": Color(hex: "C74634")
@@ -146,35 +152,35 @@ extension DatabaseType {
}
var supportsSchemaEditing: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsSchemaEditing ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true
}
var supportsAddColumn: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsAddColumn ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsAddColumn ?? true
}
var supportsModifyColumn: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsModifyColumn ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsModifyColumn ?? true
}
var supportsDropColumn: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsDropColumn ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsDropColumn ?? true
}
var supportsRenameColumn: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsRenameColumn ?? false
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsRenameColumn ?? false
}
var supportsAddIndex: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsAddIndex ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsAddIndex ?? true
}
var supportsDropIndex: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsDropIndex ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsDropIndex ?? true
}
var supportsModifyPrimaryKey: Bool {
- PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsModifyPrimaryKey ?? true
+ PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsModifyPrimaryKey ?? true
}
}
diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings
index 821e7bd16..ccb1f0197 100644
--- a/TablePro/Resources/Localizable.xcstrings
+++ b/TablePro/Resources/Localizable.xcstrings
@@ -8970,6 +8970,7 @@
},
"Choose a fetched model" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -11395,6 +11396,9 @@
}
}
}
+ },
+ "Connection Options" : {
+
},
"Connection policy" : {
"localizations" : {
@@ -12717,6 +12721,12 @@
}
}
}
+ },
+ "Could not decode image" : {
+
+ },
+ "Could not encode image" : {
+
},
"Could not export activity log" : {
"localizations" : {
@@ -16279,6 +16289,9 @@
},
"Distributed key-value store for service discovery" : {
+ },
+ "Distributed SQL, PostgreSQL-compatible" : {
+
},
"Distributed SQLite by Turso" : {
@@ -20029,6 +20042,9 @@
}
}
}
+ },
+ "Extra High" : {
+
},
"Extra Large" : {
"extractionState" : "stale",
@@ -21229,6 +21245,9 @@
}
}
}
+ },
+ "Fetching models…" : {
+
},
"Fields" : {
"localizations" : {
@@ -23149,6 +23168,9 @@
}
}
}
+ },
+ "High" : {
+
},
"Higher values create fewer INSERT statements, resulting in smaller files and faster imports" : {
"extractionState" : "stale",
@@ -27638,6 +27660,9 @@
}
}
}
+ },
+ "Low" : {
+
},
"LSP process exited with code %d" : {
"localizations" : {
@@ -28467,7 +28492,6 @@
}
},
"Medium" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -28601,6 +28625,9 @@
},
"Microsoft's enterprise SQL database" : {
+ },
+ "Minimal" : {
+
},
"Missing argument: %@" : {
"extractionState" : "stale",
@@ -28712,8 +28739,12 @@
}
}
}
+ },
+ "Model ID" : {
+
},
"Model name" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -33268,6 +33299,9 @@
}
}
}
+ },
+ "Other…" : {
+
},
"Outcome" : {
@@ -37106,6 +37140,12 @@
}
}
}
+ },
+ "Reasoning" : {
+
+ },
+ "Reasoning…" : {
+
},
"Reassign" : {
"localizations" : {
@@ -37937,6 +37977,9 @@
},
"Remove from Sidebar" : {
+ },
+ "Remove image" : {
+
},
"Remove jump host" : {
"localizations" : {
@@ -38583,6 +38626,9 @@
}
}
}
+ },
+ "Response failed" : {
+
},
"Restart Claude Desktop" : {
"localizations" : {
@@ -50059,6 +50105,9 @@
}
}
}
+ },
+ "Unsupported image format" : {
+
},
"Unsupported intent: %@" : {
"localizations" : {
diff --git a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift
index a5c9d8d51..232e92715 100644
--- a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift
+++ b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift
@@ -97,7 +97,7 @@ struct ImportFromURLSheet: View {
}
private func previewView(_ parsed: ParsedConnectionURL) -> some View {
- let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.pluginTypeId)
+ let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.rawValue)
let mode = snapshot?.connectionMode ?? .network
return VStack(alignment: .leading, spacing: 4) {
diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift
index 3f254670a..8107f7fa6 100644
--- a/TablePro/Views/Editor/QueryEditorView.swift
+++ b/TablePro/Views/Editor/QueryEditorView.swift
@@ -131,9 +131,7 @@ struct QueryEditorView: View {
@ViewBuilder
private func explainButton(hasQueryText: Bool) -> some View {
- let variants = databaseType.flatMap {
- PluginMetadataRegistry.shared.snapshot(forTypeId: $0.pluginTypeId)?.explainVariants
- } ?? []
+ let variants = databaseType?.explainVariants ?? []
if variants.count <= 1 {
Button {
diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift
index 6826b5332..7ac30be84 100644
--- a/TablePro/Views/Main/MainContentCoordinator.swift
+++ b/TablePro/Views/Main/MainContentCoordinator.swift
@@ -933,9 +933,7 @@ final class MainContentCoordinator {
let needsConfirmation = level.appliesToAllQueries && level.requiresConfirmation
// Multi-variant EXPLAIN: use plugin-declared variants if available
- let explainVariants = PluginMetadataRegistry.shared.snapshot(
- forTypeId: connection.type.pluginTypeId
- )?.explainVariants ?? []
+ let explainVariants = connection.type.explainVariants
if !explainVariants.isEmpty {
if needsConfirmation {
diff --git a/TablePro/Views/Settings/TerminalSettingsView.swift b/TablePro/Views/Settings/TerminalSettingsView.swift
index 4b4c256af..42d234eb8 100644
--- a/TablePro/Views/Settings/TerminalSettingsView.swift
+++ b/TablePro/Views/Settings/TerminalSettingsView.swift
@@ -23,7 +23,7 @@ struct TerminalSettingsView: View {
]
private static let terminalDatabaseTypes: [DatabaseType] = [
- .mysql, .mariadb, .postgresql, .redshift, .redis, .mongodb,
+ .mysql, .mariadb, .postgresql, .redshift, .cockroachdb, .redis, .mongodb,
.sqlite, .mssql, .clickhouse, .duckdb, .oracle
]
diff --git a/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift b/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift
new file mode 100644
index 000000000..e99174430
--- /dev/null
+++ b/TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift
@@ -0,0 +1,103 @@
+//
+// CockroachDBPlanParserTests.swift
+// TableProTests
+//
+// Tests for parsing CockroachDB EXPLAIN text output into a QueryPlan tree.
+//
+
+import Foundation
+@testable import TablePro
+import Testing
+
+@Suite("CockroachDB Plan Parser")
+struct CockroachDBPlanParserTests {
+ private let parser = CockroachDBPlanParser()
+
+ private let explainOutput = [
+ "distribution: local",
+ "vectorized: true",
+ "",
+ "• sort",
+ "│ estimated row count: 333",
+ "│ order: +name",
+ "│",
+ "└── • filter",
+ " │ estimated row count: 333",
+ " │ filter: age > 18",
+ " │",
+ " └── • scan",
+ " estimated row count: 1,000 (100% of the table)",
+ " table: users@users_pkey",
+ " spans: FULL SCAN",
+ ].joined(separator: "\n")
+
+ @Test("Parses node tree with correct depth nesting")
+ func parsesNodeTree() throws {
+ let plan = try #require(parser.parse(rawText: explainOutput))
+ #expect(plan.rootNode.operation == "sort")
+ #expect(plan.rootNode.children.count == 1)
+
+ let filter = plan.rootNode.children[0]
+ #expect(filter.operation == "filter")
+ #expect(filter.children.count == 1)
+
+ let scan = filter.children[0]
+ #expect(scan.operation == "scan")
+ #expect(scan.children.isEmpty)
+ }
+
+ @Test("Extracts estimated row count")
+ func extractsEstimatedRowCount() throws {
+ let plan = try #require(parser.parse(rawText: explainOutput))
+ #expect(plan.rootNode.estimatedRows == 333)
+
+ let scan = plan.rootNode.children[0].children[0]
+ #expect(scan.estimatedRows == 1_000)
+ }
+
+ @Test("Extracts table name into relation, stripping index suffix")
+ func extractsRelation() throws {
+ let plan = try #require(parser.parse(rawText: explainOutput))
+ let scan = plan.rootNode.children[0].children[0]
+ #expect(scan.relation == "users")
+ }
+
+ @Test("Keeps non-row-count properties on the node")
+ func keepsOtherProperties() throws {
+ let plan = try #require(parser.parse(rawText: explainOutput))
+ #expect(plan.rootNode.properties["order"] == "+name")
+ let scan = plan.rootNode.children[0].children[0]
+ #expect(scan.properties["spans"] == "FULL SCAN")
+ }
+
+ @Test("Parses planning and execution time from EXPLAIN ANALYZE")
+ func parsesTimingHeader() throws {
+ let analyzeOutput = [
+ "planning time: 1ms",
+ "execution time: 5ms",
+ "distribution: local",
+ "",
+ "• scan",
+ " estimated row count: 10",
+ " actual row count: 10",
+ " table: users@users_pkey",
+ ].joined(separator: "\n")
+
+ let plan = try #require(parser.parse(rawText: analyzeOutput))
+ #expect(plan.planningTime == 1.0)
+ #expect(plan.executionTime == 5.0)
+ #expect(plan.rootNode.operation == "scan")
+ #expect(plan.rootNode.actualRows == 10)
+ }
+
+ @Test("Returns nil for output without nodes")
+ func returnsNilForEmptyOutput() {
+ #expect(parser.parse(rawText: "") == nil)
+ #expect(parser.parse(rawText: "distribution: local\nvectorized: true") == nil)
+ }
+
+ @Test("Factory returns CockroachDB parser for .cockroachdb")
+ func factoryReturnsCockroachParser() {
+ #expect(QueryPlanParserFactory.parser(for: .cockroachdb) is CockroachDBPlanParser)
+ }
+}
diff --git a/TableProTests/Core/Utilities/ConnectionURLParserCockroachDBTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserCockroachDBTests.swift
new file mode 100644
index 000000000..b288e5357
--- /dev/null
+++ b/TableProTests/Core/Utilities/ConnectionURLParserCockroachDBTests.swift
@@ -0,0 +1,75 @@
+//
+// ConnectionURLParserCockroachDBTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+@Suite("Connection URL Parser - CockroachDB")
+struct ConnectionURLParserCockroachDBTests {
+ @Test("Full cockroachdb URL with default port")
+ func testFullURLDefaultPort() {
+ let result = ConnectionURLParser.parse("cockroachdb://user:pass@host:26257/defaultdb")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .cockroachdb)
+ #expect(parsed.host == "host")
+ #expect(parsed.port == nil)
+ #expect(parsed.database == "defaultdb")
+ #expect(parsed.username == "user")
+ #expect(parsed.password == "pass")
+ }
+
+ @Test("cockroach scheme alias parses as CockroachDB")
+ func testCockroachSchemeAlias() {
+ let result = ConnectionURLParser.parse("cockroach://user:pass@host/defaultdb")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .cockroachdb)
+ #expect(parsed.host == "host")
+ #expect(parsed.database == "defaultdb")
+ #expect(parsed.username == "user")
+ #expect(parsed.password == "pass")
+ }
+
+ @Test("Case-insensitive CockroachDB scheme")
+ func testCaseInsensitiveScheme() {
+ let result = ConnectionURLParser.parse("CockroachDB://user@host/db")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .cockroachdb)
+ #expect(parsed.host == "host")
+ #expect(parsed.username == "user")
+ }
+
+ @Test("CockroachDB URL without credentials")
+ func testWithoutCredentials() {
+ let result = ConnectionURLParser.parse("cockroachdb://host/db")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .cockroachdb)
+ #expect(parsed.host == "host")
+ #expect(parsed.database == "db")
+ #expect(parsed.username == "")
+ #expect(parsed.password == "")
+ }
+
+ @Test("CockroachDB non-default port preserved")
+ func testNonDefaultPortPreserved() {
+ let result = ConnectionURLParser.parse("cockroachdb://user:pass@host:26258/db")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .cockroachdb)
+ #expect(parsed.port == 26_258)
+ #expect(parsed.host == "host")
+ #expect(parsed.database == "db")
+ }
+}
diff --git a/TableProTests/Models/DatabaseTypeCockroachDBTests.swift b/TableProTests/Models/DatabaseTypeCockroachDBTests.swift
new file mode 100644
index 000000000..84835da9c
--- /dev/null
+++ b/TableProTests/Models/DatabaseTypeCockroachDBTests.swift
@@ -0,0 +1,73 @@
+//
+// DatabaseTypeCockroachDBTests.swift
+// TableProTests
+//
+// Tests for .cockroachdb properties and plugin resolution.
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+@Suite("DatabaseType CockroachDB")
+struct DatabaseTypeCockroachDBTests {
+ @Test("rawValue is CockroachDB")
+ func rawValue() {
+ #expect(DatabaseType.cockroachdb.rawValue == "CockroachDB")
+ }
+
+ @Test("defaultPort is 26257")
+ func defaultPort() {
+ #expect(DatabaseType.cockroachdb.defaultPort == 26_257)
+ }
+
+ @Test("requiresAuthentication is true")
+ func requiresAuthentication() {
+ #expect(DatabaseType.cockroachdb.requiresAuthentication == true)
+ }
+
+ @Test("supportsForeignKeys is true")
+ func supportsForeignKeys() {
+ #expect(DatabaseType.cockroachdb.supportsForeignKeys == true)
+ }
+
+ @Test("supportsSchemaEditing is true")
+ func supportsSchemaEditing() {
+ #expect(DatabaseType.cockroachdb.supportsSchemaEditing == true)
+ }
+
+ @Test("iconName is cockroachdb-icon")
+ func iconName() {
+ #expect(DatabaseType.cockroachdb.iconName == "cockroachdb-icon")
+ }
+
+ @Test("pluginTypeId resolves to PostgreSQL")
+ func pluginTypeIdResolvesToPostgres() {
+ #expect(DatabaseType.cockroachdb.pluginTypeId == "PostgreSQL")
+ }
+
+ @Test("EXPLAIN variants use plain text, not FORMAT JSON")
+ func explainVariantsAreText() {
+ let variants = DatabaseType.cockroachdb.explainVariants
+ #expect(!variants.isEmpty)
+ #expect(variants.allSatisfy { !$0.sqlPrefix.uppercased().contains("JSON") })
+ }
+
+ @Test("Codable round-trips through rawValue")
+ func codableRoundTrip() throws {
+ let encoded = try JSONEncoder().encode(DatabaseType.cockroachdb)
+ let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded)
+ #expect(decoded == DatabaseType.cockroachdb)
+ }
+
+ @Test("allKnownTypes contains cockroachdb")
+ func allKnownTypesContainsCockroachDB() {
+ #expect(DatabaseType.allKnownTypes.contains(.cockroachdb))
+ }
+
+ @Test("allCases shim contains cockroachdb")
+ func allCasesContainsCockroachDB() {
+ #expect(DatabaseType.allCases.contains(.cockroachdb))
+ }
+}
diff --git a/docs/databases/cockroachdb.mdx b/docs/databases/cockroachdb.mdx
new file mode 100644
index 000000000..bdf743ab7
--- /dev/null
+++ b/docs/databases/cockroachdb.mdx
@@ -0,0 +1,77 @@
+---
+title: CockroachDB
+description: Connect to CockroachDB clusters over the PostgreSQL wire protocol, including CockroachDB Cloud with cluster routing
+---
+
+# CockroachDB Connections
+
+TablePro connects to CockroachDB, a distributed SQL database that is wire-compatible with PostgreSQL. Connections use the same libpq driver as PostgreSQL. Schema introspection reads `information_schema` and `pg_catalog`, and uses CockroachDB's native `SHOW CREATE` statements for table and view DDL.
+
+## Quick Setup
+
+
+
+ Click **New Connection**, select **CockroachDB**, enter host, port, credentials, and database, then click **Create**
+
+
+
+## Connection Settings
+
+| Field | Default | Notes |
+|-------|---------|-------|
+| **Host** | - | Cluster host (or `localhost` for a local node) |
+| **Port** | `26257` | CockroachDB port |
+| **Database** | `defaultdb` | Default database in every cluster |
+| **User** | `root` | `root` works for local insecure clusters |
+
+For a local node started with `cockroach start-single-node --insecure`, set SSL Mode to **Disable** and leave the password empty.
+
+## CockroachDB Cloud
+
+CockroachDB Cloud connection strings include a cluster routing parameter. Copy the host, port, user, password, and database from the Cloud console, then put the routing value in the **Connection Options** field under the Advanced tab:
+
+```text
+--cluster=my-cluster-1234
+```
+
+Set SSL Mode to **Verify Full** for Cloud clusters. The Connection Options field is passed straight to libpq, so it also accepts other libpq options for self-hosted clusters.
+
+## Connection URL
+
+```text
+cockroachdb://user:password@host:26257/defaultdb
+```
+
+`cockroach://` also works. See [Connection URL Reference](/databases/connection-urls) for all parameters.
+
+## Features
+
+**Schemas**: Like PostgreSQL. Switch with **Cmd+K**. Default schema is `public`.
+
+**Query Execution**: Full SQL support, parameterized queries, transactions, and query cancel. Export to CSV/JSON/SQL/XLSX. Import from CSV/JSON/SQL/XLSX.
+
+**DDL**: Table and view definitions come from `SHOW CREATE`. Indexes come from `SHOW INDEXES`. Foreign keys are read from `information_schema`.
+
+**EXPLAIN**: `EXPLAIN` and `EXPLAIN ANALYZE` render as a visual plan tree. CockroachDB returns text plans, not JSON.
+
+**SSL/TLS**: Use **Verify Full** for CockroachDB Cloud. Local insecure clusters use **Disable**.
+
+**SSH Tunnels**: Supported for private clusters.
+
+## Limitations
+
+Distributed SQL database, not a drop-in for every PostgreSQL feature:
+
+- No `pg_dump` / `pg_restore`. The **Backup Dump** and **Restore Dump** menu items are not available. Use CockroachDB's `BACKUP` and `RESTORE` SQL statements instead.
+- No `VACUUM`. CockroachDB garbage-collects automatically, so the maintenance panel does not offer it.
+- Foreign key checks are not deferrable.
+
+## Troubleshooting
+
+**Connection refused**: Check the cluster is running and the port (default `26257`) is reachable. For local clusters, confirm `cockroach start` is still running.
+
+**Auth failed**: For local insecure clusters use user `root` with no password and SSL Mode **Disable**. For Cloud, copy credentials from the console and use SSL Mode **Verify Full**.
+
+**Cloud connection hangs or rejects**: Make sure the cluster routing value is set in **Connection Options** (`--cluster=your-cluster-name`).
+
+**EXPLAIN shows raw text**: CockroachDB plan output that does not match the expected tree shape falls back to raw text display.
diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx
index 8ac4403e4..41a4ce42f 100644
--- a/docs/databases/overview.mdx
+++ b/docs/databases/overview.mdx
@@ -22,6 +22,9 @@ Natively supported:
Redshift data warehouses via PostgreSQL wire protocol. Default port: 5439
+
+ Distributed SQL via PostgreSQL wire protocol. Default port: 26257
+
File-based databases, no server required
@@ -155,7 +158,7 @@ open "mysql://root:secret@localhost:3306/myapp"
open "redis://:password@localhost:6379"
```
-TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `cassandra`, and `scylladb` as URL schemes on macOS, so the OS routes these URLs directly to the app.
+TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `cockroachdb`, `cockroach`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `cassandra`, and `scylladb` as URL schemes on macOS, so the OS routes these URLs directly to the app.
**What happens:**
diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx
index 559cc4b7f..fdb39d05c 100644
--- a/docs/development/architecture.mdx
+++ b/docs/development/architecture.mdx
@@ -77,7 +77,7 @@ All database drivers are `.tableplugin` bundles loaded at runtime. This keeps th
| Plugin | Database Types | C Bridge | Distribution |
|--------|---------------|----------|--------------|
| MySQLDriverPlugin | MySQL, MariaDB | CMariaDB (libmariadb) | Built-in |
-| PostgreSQLDriverPlugin | PostgreSQL, Redshift | CLibPQ (libpq) | Built-in |
+| PostgreSQLDriverPlugin | PostgreSQL, Redshift, CockroachDB | CLibPQ (libpq) | Built-in |
| SQLiteDriverPlugin | SQLite | Foundation sqlite3 | Built-in |
| ClickHouseDriverPlugin | ClickHouse | URLSession HTTP | Built-in |
| MSSQLDriverPlugin | SQL Server | CFreeTDS | Built-in |
diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx
index c8d554aa9..79eb711cf 100644
--- a/docs/development/plugin-registry.mdx
+++ b/docs/development/plugin-registry.mdx
@@ -76,7 +76,7 @@ The `databaseTypeIds` field tells the app which registry plugin to install when
| DatabaseType | pluginTypeId |
|-------------|-------------|
| MySQL, MariaDB | `"MySQL"` |
-| PostgreSQL, Redshift | `"PostgreSQL"` |
+| PostgreSQL, Redshift, CockroachDB | `"PostgreSQL"` |
| SQLite | `"SQLite"` |
| MongoDB | `"MongoDB"` |
| Redis | `"Redis"` |
diff --git a/docs/docs.json b/docs/docs.json
index da6c839da..0fabeee5d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -39,6 +39,7 @@
"databases/mariadb",
"databases/postgresql",
"databases/redshift",
+ "databases/cockroachdb",
"databases/mssql",
"databases/oracle"
]
diff --git a/docs/features/plugins.mdx b/docs/features/plugins.mdx
index 7ff835bdd..4e632dc21 100644
--- a/docs/features/plugins.mdx
+++ b/docs/features/plugins.mdx
@@ -14,7 +14,7 @@ These ship with the app and are always available:
| Plugin | Databases |
|--------|-----------|
| MySQL | MySQL, MariaDB |
-| PostgreSQL | PostgreSQL, Redshift |
+| PostgreSQL | PostgreSQL, Redshift, CockroachDB |
| SQLite | SQLite |
| ClickHouse | ClickHouse |
| SQL Server | SQL Server |
diff --git a/docs/features/terminal.mdx b/docs/features/terminal.mdx
index 0fafd13be..a3dcb9427 100644
--- a/docs/features/terminal.mdx
+++ b/docs/features/terminal.mdx
@@ -33,7 +33,7 @@ The terminal automatically detects which CLI tool to use based on the connection
|----------|----------|-----------------|
| MySQL | `mysql` | `brew install mysql-client` |
| MariaDB | `mariadb` (falls back to `mysql`) | `brew install mariadb` |
-| PostgreSQL / Redshift | `psql` | `brew install libpq` |
+| PostgreSQL / Redshift / CockroachDB | `psql` | `brew install libpq` |
| Redis | `redis-cli` | `brew install redis` |
| MongoDB | `mongosh` | `brew install mongosh` |
| SQLite | `sqlite3` | Included with macOS |
diff --git a/docs/index.mdx b/docs/index.mdx
index 37829e4c9..8830b3676 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -64,6 +64,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under
| PostgreSQL | 5432 | Built-in |
| SQLite | N/A (file-based) | Built-in |
| Amazon Redshift | 5439 | Built-in |
+| CockroachDB | 26257 | Built-in |
| Microsoft SQL Server | 1433 | Plugin |
| ClickHouse | 8123 | Built-in |
| Redis | 6379 | Built-in |