From e8859dabf238a0d98de03d73a504d66a054d5f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 12:44:13 +0700 Subject: [PATCH] feat: add CockroachDB support --- CHANGELOG.md | 1 + .../CockroachPluginDriver.swift | 349 ++++++++++++++++++ Plugins/PostgreSQLDriverPlugin/Info.plist | 1 + .../LibPQDriverCore.swift | 196 ++++++++++ .../LibPQPluginConnection.swift | 9 +- .../PostgreSQLPlugin.swift | 19 +- .../PostgreSQLPluginDriver.swift | 161 +------- .../RedshiftPluginDriver.swift | 147 +------- .../cockroachdb-icon.imageset/Contents.json | 16 + .../cockroachdb-icon.imageset/cockroachdb.svg | 1 + .../Database/DatabaseManager+Schema.swift | 3 +- .../Plugins/PluginManager+Registration.swift | 7 +- .../Core/Plugins/PluginMetadataRegistry.swift | 89 ++++- .../ServerDashboardQueryProviderFactory.swift | 2 +- .../Export/ForeignApp/TablePlusImporter.swift | 3 +- .../Core/Services/Query/QueryPlanParser.swift | 139 +++++++ .../Services/Query/SQLFunctionProvider.swift | 2 +- .../Core/Terminal/CLICommandResolver.swift | 8 +- .../Connection/ConnectionURLFormatter.swift | 2 +- .../Connection/ConnectionURLParser.swift | 2 + .../SQL/SQLRowToStatementConverter.swift | 2 +- TablePro/Info.plist | 2 + .../Connection/DatabaseConnection.swift | 24 +- TablePro/Resources/Localizable.xcstrings | 51 ++- .../Components/ImportFromURLSheet.swift | 2 +- TablePro/Views/Editor/QueryEditorView.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 4 +- .../Views/Settings/TerminalSettingsView.swift | 2 +- .../Query/CockroachDBPlanParserTests.swift | 103 ++++++ .../ConnectionURLParserCockroachDBTests.swift | 75 ++++ .../Models/DatabaseTypeCockroachDBTests.swift | 73 ++++ docs/databases/cockroachdb.mdx | 77 ++++ docs/databases/overview.mdx | 5 +- docs/development/architecture.mdx | 2 +- docs/development/plugin-registry.mdx | 2 +- docs/docs.json | 1 + docs/features/plugins.mdx | 2 +- docs/features/terminal.mdx | 2 +- docs/index.mdx | 1 + 39 files changed, 1256 insertions(+), 335 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift create mode 100644 Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift create mode 100644 TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg create mode 100644 TableProTests/Core/Services/Query/CockroachDBPlanParserTests.swift create mode 100644 TableProTests/Core/Utilities/ConnectionURLParserCockroachDBTests.swift create mode 100644 TableProTests/Models/DatabaseTypeCockroachDBTests.swift create mode 100644 docs/databases/cockroachdb.mdx 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 @@ +CL 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 |