From 9a940f66d7dbad39487b60380367ba5e3c518f1f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 21:39:25 +0700 Subject: [PATCH 1/5] feat(plugin-trino): add Trino driver over the REST client protocol --- .github/workflows/build-plugin.yml | 5 + CHANGELOG.md | 4 + Packages/TableProCore/Package.swift | 13 +- .../TableProCoreTypes/DatabaseType.swift | 4 +- .../TableProTrinoCore/TrinoClientConfig.swift | 76 ++++++ .../TableProTrinoCore/TrinoError.swift | 57 +++++ .../TrinoIntrospectionSQL.swift | 70 +++++ .../TableProTrinoCore/TrinoJSONValue.swift | 124 +++++++++ .../TrinoProtocolHeaders.swift | 35 +++ .../TableProTrinoCore/TrinoQueryResults.swift | 65 +++++ .../TableProTrinoCore/TrinoResultSet.swift | 39 +++ .../TableProTrinoCore/TrinoSessionState.swift | 100 ++++++++ .../TrinoStatementClient.swift | 242 ++++++++++++++++++ .../TableProTrinoCore/TrinoTransport.swift | 152 +++++++++++ .../TableProTrinoCore/TrinoTypeMapper.swift | 38 +++ .../TableProTrinoCore/TrinoValue.swift | 26 ++ .../TrinoIntrospectionSQLTests.swift | 59 +++++ .../TrinoJSONValueTests.swift | 56 ++++ .../TrinoQueryResultsDecodingTests.swift | 53 ++++ .../TrinoSessionStateTests.swift | 61 +++++ .../TrinoStatementClientTests.swift | 140 ++++++++++ .../TrinoTestSupport.swift | 50 ++++ .../TrinoTypeMapperTests.swift | 48 ++++ .../TrinoValueDecoderTests.swift | 40 +++ Plugins/TrinoDriverPlugin/Info.plist | 8 + Plugins/TrinoDriverPlugin/TrinoPlugin.swift | 141 ++++++++++ .../TrinoPluginDriver+Schema.swift | 105 ++++++++ .../TrinoDriverPlugin/TrinoPluginDriver.swift | 142 ++++++++++ TablePro.xcodeproj/project.pbxproj | 131 ++++++++++ ...ginMetadataRegistry+RegistryDefaults.swift | 111 ++++++++ .../Core/Services/ColumnTypeClassifier.swift | 6 + .../Services/ColumnTypeClassifierTests.swift | 55 ++++ docs/databases/trino.mdx | 61 +++++ docs/docs.json | 2 +- docs/index.mdx | 7 +- scripts/release-all-plugins.sh | 1 + 36 files changed, 2321 insertions(+), 6 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoJSONValue.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoProtocolHeaders.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoQueryResults.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoResultSet.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoSessionState.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift create mode 100644 Plugins/TrinoDriverPlugin/Info.plist create mode 100644 Plugins/TrinoDriverPlugin/TrinoPlugin.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift create mode 100644 docs/databases/trino.mdx diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 9706d0821..802a3c84a 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -183,6 +183,11 @@ jobs: DISPLAY_NAME="Teradata Driver"; SUMMARY="Teradata Vantage driver via a native Swift TD2 client" DB_TYPE_IDS='["Teradata"]'; ICON="teradata-icon"; BUNDLE_NAME="TeradataDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/teradata" ;; + trino) + TARGET="TrinoDriverPlugin"; BUNDLE_ID="com.TablePro.TrinoDriverPlugin" + DISPLAY_NAME="Trino Driver"; SUMMARY="Trino distributed SQL engine driver via the REST client protocol" + DB_TYPE_IDS='["Trino"]'; ICON="trino-icon"; BUNDLE_NAME="TrinoDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/trino" ;; mongodb) TARGET="MongoDBDriver"; BUNDLE_ID="com.TablePro.MongoDBDriver" DISPLAY_NAME="MongoDB Driver"; SUMMARY="MongoDB document database driver via libmongoc" diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc88a2db..5c6335939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Trino support through a downloadable native Swift driver. Connect to a Trino cluster over its HTTP client protocol, browse catalogs, schemas, tables, and columns, and run SQL. Authenticate with a username and password or a JWT access token. (#1906) + ## [0.58.0] - 2026-07-18 ### Added diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 74387b1fc..4017c1b79 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -18,7 +18,8 @@ let package = Package( .library(name: "TableProSync", targets: ["TableProSync"]), .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]), .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]), - .library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]) + .library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]), + .library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]) ], targets: [ .target( @@ -72,6 +73,11 @@ let package = Package( dependencies: [], path: "Sources/TableProTeradataCore" ), + .target( + name: "TableProTrinoCore", + dependencies: [], + path: "Sources/TableProTrinoCore" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], @@ -107,6 +113,11 @@ let package = Package( dependencies: ["TableProTeradataCore"], path: "Tests/TableProTeradataCoreTests" ), + .testTarget( + name: "TableProTrinoCoreTests", + dependencies: ["TableProTrinoCore"], + path: "Tests/TableProTrinoCoreTests" + ), .testTarget( name: "TableProSyncTests", dependencies: ["TableProSync", "TableProModels"], diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index ad591a9c6..2ddde73cb 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -33,12 +33,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let turso = DatabaseType(rawValue: "Turso") public static let surrealdb = DatabaseType(rawValue: "SurrealDB") public static let teradata = DatabaseType(rawValue: "Teradata") + public static let trino = DatabaseType(rawValue: "Trino") public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount, - .surrealdb, .teradata + .surrealdb, .teradata, .trino ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -65,6 +66,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .beancount: return "beancount-icon" case .surrealdb: return "surrealdb-icon" case .teradata: return "teradata-icon" + case .trino: return "trino-icon" default: return "externaldrive" } } diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift new file mode 100644 index 000000000..1cc8c49c0 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum TrinoAuth: Sendable, Equatable { + case none + case basic(password: String) + case jwt(token: String) +} + +public struct TrinoClientConfig: Sendable { + public var host: String + public var port: Int + public var useTLS: Bool + public var verifyTLS: Bool + public var user: String + public var source: String + public var catalog: String? + public var schema: String? + public var timeZone: String? + public var auth: TrinoAuth + public var clientTags: [String] + public var protocolHeaders: TrinoProtocolHeaders + public var requestTimeoutSeconds: Int + + public init( + host: String, + port: Int = 8_080, + useTLS: Bool = false, + verifyTLS: Bool = true, + user: String, + source: String = "TablePro", + catalog: String? = nil, + schema: String? = nil, + timeZone: String? = nil, + auth: TrinoAuth = .none, + clientTags: [String] = [], + protocolHeaders: TrinoProtocolHeaders = .trino, + requestTimeoutSeconds: Int = 60 + ) { + self.host = host + self.port = port + self.useTLS = useTLS + self.verifyTLS = verifyTLS + self.user = user + self.source = source + self.catalog = catalog + self.schema = schema + self.timeZone = timeZone + self.auth = auth + self.clientTags = clientTags + self.protocolHeaders = protocolHeaders + self.requestTimeoutSeconds = requestTimeoutSeconds + } + + public var scheme: String { useTLS ? "https" : "http" } + + public var statementURL: URL? { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = port + components.path = "/v1/statement" + return components.url + } + + public var authorizationHeader: String? { + switch auth { + case .none: + return nil + case .basic(let password): + let credentials = "\(user):\(password)" + return "Basic \(Data(credentials.utf8).base64EncodedString())" + case .jwt(let token): + return "Bearer \(token)" + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift new file mode 100644 index 000000000..be9a2076f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct TrinoQueryError: Decodable, Sendable, Equatable { + public let message: String + public let errorCode: Int? + public let errorName: String? + public let errorType: String? + + public init(message: String, errorCode: Int? = nil, errorName: String? = nil, errorType: String? = nil) { + self.message = message + self.errorCode = errorCode + self.errorName = errorName + self.errorType = errorType + } + + private enum CodingKeys: String, CodingKey { + case message, errorCode, errorName, errorType + } +} + +public enum TrinoError: Error, LocalizedError, Equatable { + case invalidConfiguration(String) + case notConnected + case transport(String) + case httpStatus(code: Int, body: String) + case authenticationFailed(String) + case query(TrinoQueryError) + case invalidResponse(String) + case cancelled + case timedOut + + public var errorDescription: String? { + switch self { + case .invalidConfiguration(let detail): + return detail + case .notConnected: + return "Not connected to Trino" + case .transport(let detail): + return detail + case .httpStatus(let code, let body): + return body.isEmpty ? "HTTP \(code)" : "HTTP \(code): \(body)" + case .authenticationFailed(let detail): + return detail + case .query(let error): + if let name = error.errorName, !name.isEmpty { + return "\(name): \(error.message)" + } + return error.message + case .invalidResponse(let detail): + return detail + case .cancelled: + return "Query was cancelled" + case .timedOut: + return "Timed out waiting for Trino" + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift new file mode 100644 index 000000000..0d8f63012 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift @@ -0,0 +1,70 @@ +import Foundation + +public enum TrinoIntrospectionSQL { + public static func quoteIdentifier(_ name: String) -> String { + "\"" + name.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + public static func quoteLiteral(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "''") + "'" + } + + public static func qualifiedName(catalog: String?, schema: String?, table: String) -> String { + var parts: [String] = [] + if let catalog, !catalog.isEmpty { + parts.append(quoteIdentifier(catalog)) + } + if let schema, !schema.isEmpty { + parts.append(quoteIdentifier(schema)) + } + parts.append(quoteIdentifier(table)) + return parts.joined(separator: ".") + } + + public static func showCatalogs() -> String { + "SHOW CATALOGS" + } + + public static func showSchemas(catalog: String) -> String { + "SHOW SCHEMAS FROM \(quoteIdentifier(catalog))" + } + + public static func listTables(catalog: String, schema: String) -> String { + """ + SELECT table_name, table_type FROM \(quoteIdentifier(catalog)).information_schema.tables \ + WHERE table_schema = \(quoteLiteral(schema)) ORDER BY table_name + """ + } + + public static func listColumns(catalog: String, schema: String, table: String) -> String { + """ + SELECT column_name, data_type, is_nullable, column_default, ordinal_position \ + FROM \(quoteIdentifier(catalog)).information_schema.columns \ + WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table)) \ + ORDER BY ordinal_position + """ + } + + public static func listAllColumns(catalog: String, schema: String) -> String { + """ + SELECT table_name, column_name, data_type, is_nullable, column_default, ordinal_position \ + FROM \(quoteIdentifier(catalog)).information_schema.columns \ + WHERE table_schema = \(quoteLiteral(schema)) ORDER BY table_name, ordinal_position + """ + } + + public static func approximateRowCount(catalog: String, schema: String, table: String) -> String { + """ + SELECT row_count FROM \(quoteIdentifier(catalog)).information_schema.tables \ + WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table)) + """ + } + + public static func showCreateTable(catalog: String?, schema: String?, table: String) -> String { + "SHOW CREATE TABLE \(qualifiedName(catalog: catalog, schema: schema, table: table))" + } + + public static func showCreateView(catalog: String?, schema: String?, view: String) -> String { + "SHOW CREATE VIEW \(qualifiedName(catalog: catalog, schema: schema, table: view))" + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoJSONValue.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoJSONValue.swift new file mode 100644 index 000000000..9f7492c61 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoJSONValue.swift @@ -0,0 +1,124 @@ +import Foundation + +public enum TrinoJSONValue: Decodable, Sendable, Equatable { + case null + case bool(Bool) + case int(Int64) + case double(Double) + case string(String) + case array([TrinoJSONValue]) + case object([String: TrinoJSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Int64.self) { + self = .int(value) + return + } + if let value = try? container.decode(Double.self) { + self = .double(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([TrinoJSONValue].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: TrinoJSONValue].self) { + self = .object(value) + return + } + self = .null + } + + public var isNull: Bool { + if case .null = self { return true } + return false + } + + public var foundationObject: Any { + switch self { + case .null: + return NSNull() + case .bool(let value): + return value + case .int(let value): + return NSNumber(value: value) + case .double(let value): + return NSNumber(value: value) + case .string(let value): + return value + case .array(let values): + return values.map(\.foundationObject) + case .object(let values): + return values.mapValues(\.foundationObject) + } + } + + public func jsonText() -> String { + switch self { + case .null: + return "null" + case .bool(let value): + return value ? "true" : "false" + case .int(let value): + return String(value) + case .double(let value): + return TrinoJSONValue.format(double: value) + case .string(let value): + return TrinoJSONValue.encode(string: value) + case .array, .object: + guard let data = try? JSONSerialization.data( + withJSONObject: foundationObject, + options: [.sortedKeys, .fragmentsAllowed] + ), let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } + } + + public var scalarText: String? { + switch self { + case .null: + return nil + case .bool(let value): + return value ? "true" : "false" + case .int(let value): + return String(value) + case .double(let value): + return TrinoJSONValue.format(double: value) + case .string(let value): + return value + case .array, .object: + return jsonText() + } + } + + static func format(double value: Double) -> String { + if value == value.rounded(), abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + } + + static func encode(string value: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) else { + return "\"\"" + } + return text + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoProtocolHeaders.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoProtocolHeaders.swift new file mode 100644 index 000000000..c6518692f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoProtocolHeaders.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct TrinoProtocolHeaders: Sendable, Equatable { + public let prefix: String + + public init(prefix: String) { + self.prefix = prefix + } + + public static let trino = TrinoProtocolHeaders(prefix: "X-Trino-") + public static let presto = TrinoProtocolHeaders(prefix: "X-Presto-") + + public var user: String { prefix + "User" } + public var source: String { prefix + "Source" } + public var catalog: String { prefix + "Catalog" } + public var schema: String { prefix + "Schema" } + public var timeZone: String { prefix + "Time-Zone" } + public var session: String { prefix + "Session" } + public var role: String { prefix + "Role" } + public var preparedStatement: String { prefix + "Prepared-Statement" } + public var transactionId: String { prefix + "Transaction-Id" } + public var clientInfo: String { prefix + "Client-Info" } + public var clientTags: String { prefix + "Client-Tags" } + public var clientCapabilities: String { prefix + "Client-Capabilities" } + + public var setCatalog: String { prefix + "Set-Catalog" } + public var setSchema: String { prefix + "Set-Schema" } + public var setSession: String { prefix + "Set-Session" } + public var clearSession: String { prefix + "Clear-Session" } + public var setRole: String { prefix + "Set-Role" } + public var addedPrepare: String { prefix + "Added-Prepare" } + public var deallocatedPrepare: String { prefix + "Deallocated-Prepare" } + public var startedTransactionId: String { prefix + "Started-Transaction-Id" } + public var clearTransactionId: String { prefix + "Clear-Transaction-Id" } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoQueryResults.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoQueryResults.swift new file mode 100644 index 000000000..477532a5d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoQueryResults.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct TrinoTypeSignature: Decodable, Sendable, Equatable { + public let rawType: String + + public init(rawType: String) { + self.rawType = rawType + } + + private enum CodingKeys: String, CodingKey { + case rawType + } +} + +public struct TrinoColumn: Decodable, Sendable, Equatable { + public let name: String + public let type: String + public let typeSignature: TrinoTypeSignature? + + public init(name: String, type: String, typeSignature: TrinoTypeSignature? = nil) { + self.name = name + self.type = type + self.typeSignature = typeSignature + } + + public var rawTypeName: String { + if let raw = typeSignature?.rawType, !raw.isEmpty { + return raw + } + return TrinoTypeMapper.baseType(fromDisplayType: type) + } + + public var category: TrinoTypeCategory { + TrinoTypeMapper.category(forRawType: rawTypeName) + } + + private enum CodingKeys: String, CodingKey { + case name, type, typeSignature + } +} + +public struct TrinoStats: Decodable, Sendable, Equatable { + public let state: String? + + private enum CodingKeys: String, CodingKey { + case state + } +} + +public struct TrinoQueryResults: Decodable, Sendable { + public let id: String + public let infoUri: String? + public let partialCancelUri: String? + public let nextUri: String? + public let columns: [TrinoColumn]? + public let data: [[TrinoJSONValue]]? + public let error: TrinoQueryError? + public let updateType: String? + public let updateCount: Int? + public let stats: TrinoStats? + + private enum CodingKeys: String, CodingKey { + case id, infoUri, partialCancelUri, nextUri, columns, data, error, updateType, updateCount, stats + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoResultSet.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoResultSet.swift new file mode 100644 index 000000000..fe3df3cf6 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoResultSet.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct TrinoColumnDescriptor: Sendable, Equatable { + public let name: String + public let typeName: String + public let category: TrinoTypeCategory + + public init(name: String, typeName: String, category: TrinoTypeCategory) { + self.name = name + self.typeName = typeName + self.category = category + } +} + +public struct TrinoResultSet: Sendable { + public let columns: [TrinoColumnDescriptor] + public let rows: [[TrinoValue]] + public let updateType: String? + public let updateCount: Int? + public let queryId: String? + + public init( + columns: [TrinoColumnDescriptor], + rows: [[TrinoValue]], + updateType: String? = nil, + updateCount: Int? = nil, + queryId: String? = nil + ) { + self.columns = columns + self.rows = rows + self.updateType = updateType + self.updateCount = updateCount + self.queryId = queryId + } + + public var isEmpty: Bool { + columns.isEmpty && rows.isEmpty + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoSessionState.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoSessionState.swift new file mode 100644 index 000000000..2d2dd89ea --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoSessionState.swift @@ -0,0 +1,100 @@ +import Foundation + +public final class TrinoSessionState: @unchecked Sendable { + private let lock = NSLock() + private var _catalog: String? + private var _schema: String? + private var _sessionProperties: [String: String] + private var _preparedStatements: [String: String] + private var _transactionId: String? + + public init( + catalog: String? = nil, + schema: String? = nil, + sessionProperties: [String: String] = [:], + preparedStatements: [String: String] = [:], + transactionId: String? = nil + ) { + self._catalog = catalog + self._schema = schema + self._sessionProperties = sessionProperties + self._preparedStatements = preparedStatements + self._transactionId = transactionId + } + + public var catalog: String? { lock.withLock { _catalog } } + public var schema: String? { lock.withLock { _schema } } + public var transactionId: String? { lock.withLock { _transactionId } } + public var sessionProperties: [String: String] { lock.withLock { _sessionProperties } } + + public func setCatalog(_ value: String?) { + lock.withLock { _catalog = value } + } + + public func setSchema(_ value: String?) { + lock.withLock { _schema = value } + } + + public func sessionPropertyHeaderValue() -> String { + let properties = lock.withLock { _sessionProperties } + return properties.keys.sorted().compactMap { key in + guard let value = properties[key] else { return nil } + return "\(key)=\(Self.encode(value))" + }.joined(separator: ",") + } + + public func preparedStatementHeaderValue() -> String { + let statements = lock.withLock { _preparedStatements } + return statements.keys.sorted().compactMap { key in + guard let value = statements[key] else { return nil } + return "\(key)=\(Self.encode(value))" + }.joined(separator: ",") + } + + public func apply(responseHeaders: TrinoHeaderFields, protocolHeaders: TrinoProtocolHeaders) { + lock.withLock { + if let catalog = responseHeaders.first(protocolHeaders.setCatalog) { + _catalog = catalog + } + if let schema = responseHeaders.first(protocolHeaders.setSchema) { + _schema = schema + } + for entry in responseHeaders.all(protocolHeaders.setSession) { + guard let (key, value) = Self.parsePair(entry) else { continue } + _sessionProperties[key] = Self.decode(value) + } + for key in responseHeaders.all(protocolHeaders.clearSession) { + _sessionProperties.removeValue(forKey: key) + } + for entry in responseHeaders.all(protocolHeaders.addedPrepare) { + guard let (key, value) = Self.parsePair(entry) else { continue } + _preparedStatements[key] = Self.decode(value) + } + for key in responseHeaders.all(protocolHeaders.deallocatedPrepare) { + _preparedStatements.removeValue(forKey: key) + } + if let transactionId = responseHeaders.first(protocolHeaders.startedTransactionId) { + _transactionId = transactionId + } + if responseHeaders.first(protocolHeaders.clearTransactionId) != nil { + _transactionId = nil + } + } + } + + static func parsePair(_ entry: String) -> (String, String)? { + guard let separator = entry.firstIndex(of: "=") else { return nil } + let key = String(entry[.. String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value + } + + static func decode(_ value: String) -> String { + value.removingPercentEncoding ?? value + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift new file mode 100644 index 000000000..85c6f7ad9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift @@ -0,0 +1,242 @@ +import Foundation +import os + +public final class TrinoStatementClient: @unchecked Sendable { + private let transport: TrinoTransport + private let config: TrinoClientConfig + private let session: TrinoSessionState + private let lock = NSLock() + private var _cancelled = false + private var _currentNextUri: String? + + private static let maxTransientRetries = 5 + private static let logger = Logger(subsystem: "com.TablePro", category: "TrinoStatementClient") + + public init(transport: TrinoTransport, config: TrinoClientConfig, session: TrinoSessionState) { + self.transport = transport + self.config = config + self.session = session + } + + public func execute(_ sql: String) async throws -> TrinoResultSet { + lock.withLock { + _cancelled = false + _currentNextUri = nil + } + guard let statementURL = config.statementURL else { + throw TrinoError.invalidConfiguration("Invalid Trino server URL") + } + + let initialRequest = makeRequest(method: .post, url: statementURL, headers: initialHeaders(), body: Data(sql.utf8)) + var httpResponse = try await sendWithRetry(initialRequest) + var results = try decode(httpResponse) + session.apply(responseHeaders: httpResponse.headers, protocolHeaders: config.protocolHeaders) + if let error = results.error { + throw TrinoError.query(error) + } + + var columns = results.columns + var rows: [[TrinoValue]] = [] + appendRows(from: results, columns: columns, into: &rows) + var updateType = results.updateType + var updateCount = results.updateCount + let queryId = results.id + var nextUri = results.nextUri + + while let uri = nextUri { + try abortIfCancelled(currentUri: uri) + lock.withLock { _currentNextUri = uri } + guard let nextURL = URL(string: uri) else { + throw TrinoError.invalidResponse("Trino returned an invalid nextUri") + } + httpResponse = try await sendWithRetry(makeRequest(method: .get, url: nextURL, headers: followHeaders())) + results = try decode(httpResponse) + session.apply(responseHeaders: httpResponse.headers, protocolHeaders: config.protocolHeaders) + if let error = results.error { + throw TrinoError.query(error) + } + if columns == nil { + columns = results.columns + } + appendRows(from: results, columns: columns, into: &rows) + if let type = results.updateType { + updateType = type + } + if let count = results.updateCount { + updateCount = count + } + nextUri = results.nextUri + } + + lock.withLock { _currentNextUri = nil } + return TrinoResultSet( + columns: descriptors(from: columns), + rows: rows, + updateType: updateType, + updateCount: updateCount, + queryId: queryId + ) + } + + public func cancel() { + let uri = lock.withLock { () -> String? in + _cancelled = true + return _currentNextUri + } + if let uri { + fireDelete(uri) + } + } + + private func appendRows(from results: TrinoQueryResults, columns: [TrinoColumn]?, into rows: inout [[TrinoValue]]) { + guard let data = results.data, let columns else { return } + for row in data { + var decoded: [TrinoValue] = [] + decoded.reserveCapacity(columns.count) + for (index, value) in row.enumerated() { + let category = index < columns.count ? columns[index].category : .scalar + decoded.append(TrinoValueDecoder.decode(value, category: category)) + } + rows.append(decoded) + } + } + + private func descriptors(from columns: [TrinoColumn]?) -> [TrinoColumnDescriptor] { + guard let columns else { return [] } + return columns.map { + TrinoColumnDescriptor(name: $0.name, typeName: $0.type, category: $0.category) + } + } + + private func abortIfCancelled(currentUri: String) throws { + let cancelled = lock.withLock { _cancelled } || Task.isCancelled + guard cancelled else { return } + fireDelete(currentUri) + throw TrinoError.cancelled + } + + private func sendWithRetry(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse { + var attempt = 0 + while true { + let response = try await transport.send(request) + switch response.statusCode { + case 200...299: + return response + case 502, 503, 504: + attempt += 1 + guard attempt <= Self.maxTransientRetries else { + throw TrinoError.httpStatus(code: response.statusCode, body: bodyText(response)) + } + Self.logger.debug("Trino transient \(response.statusCode, privacy: .public), retry \(attempt)") + try await sleepBackoff(attempt: attempt, retryAfter: nil) + case 429: + attempt += 1 + guard attempt <= Self.maxTransientRetries else { + throw TrinoError.httpStatus(code: 429, body: bodyText(response)) + } + try await sleepBackoff(attempt: attempt, retryAfter: response.retryAfterSeconds()) + case 401, 403: + throw TrinoError.authenticationFailed(authMessage(response)) + default: + throw TrinoError.httpStatus(code: response.statusCode, body: bodyText(response)) + } + } + } + + private func sleepBackoff(attempt: Int, retryAfter: Double?) async throws { + let seconds: Double + if let retryAfter, retryAfter > 0 { + seconds = min(retryAfter, 10) + } else { + seconds = min(0.05 * Double(attempt) + 0.05, 1.0) + } + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + private func decode(_ response: TrinoHTTPResponse) throws -> TrinoQueryResults { + do { + return try JSONDecoder().decode(TrinoQueryResults.self, from: response.body) + } catch { + throw TrinoError.invalidResponse("Could not decode the Trino response") + } + } + + private func fireDelete(_ uri: String) { + guard let url = URL(string: uri) else { return } + let request = makeRequest(method: .delete, url: url, headers: followHeaders()) + let transport = self.transport + Task.detached { _ = try? await transport.send(request) } + } + + private func makeRequest( + method: TrinoHTTPRequest.Method, + url: URL, + headers: [String: String], + body: Data? = nil + ) -> TrinoHTTPRequest { + TrinoHTTPRequest( + method: method, + url: url, + headers: headers, + body: body, + timeoutSeconds: config.requestTimeoutSeconds + ) + } + + private func initialHeaders() -> [String: String] { + let protocolHeaders = config.protocolHeaders + var headers: [String: String] = [:] + if !config.user.isEmpty { + headers[protocolHeaders.user] = config.user + } + if !config.source.isEmpty { + headers[protocolHeaders.source] = config.source + } + if let catalog = session.catalog, !catalog.isEmpty { + headers[protocolHeaders.catalog] = catalog + } + if let schema = session.schema, !schema.isEmpty { + headers[protocolHeaders.schema] = schema + } + if let timeZone = config.timeZone, !timeZone.isEmpty { + headers[protocolHeaders.timeZone] = timeZone + } + let sessionProperties = session.sessionPropertyHeaderValue() + if !sessionProperties.isEmpty { + headers[protocolHeaders.session] = sessionProperties + } + let prepared = session.preparedStatementHeaderValue() + if !prepared.isEmpty { + headers[protocolHeaders.preparedStatement] = prepared + } + if let transactionId = session.transactionId, !transactionId.isEmpty { + headers[protocolHeaders.transactionId] = transactionId + } + if !config.clientTags.isEmpty { + headers[protocolHeaders.clientTags] = config.clientTags.joined(separator: ",") + } + headers[protocolHeaders.clientCapabilities] = "PARAMETRIC_DATETIME" + headers["Content-Type"] = "text/plain; charset=utf-8" + if let authorization = config.authorizationHeader { + headers["Authorization"] = authorization + } + return headers + } + + private func followHeaders() -> [String: String] { + var headers: [String: String] = [:] + if let authorization = config.authorizationHeader { + headers["Authorization"] = authorization + } + return headers + } + + private func authMessage(_ response: TrinoHTTPResponse) -> String { + let body = bodyText(response) + return body.isEmpty ? "Authentication failed" : body + } + + private func bodyText(_ response: TrinoHTTPResponse) -> String { + String(data: response.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift new file mode 100644 index 000000000..3f46129a2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct TrinoHeaderFields: Sendable, Equatable { + private let storage: [String: String] + + public init(_ fields: [String: String]) { + var map: [String: String] = [:] + for (key, value) in fields { + map[key.lowercased()] = value + } + storage = map + } + + public init(httpResponse: HTTPURLResponse) { + var map: [String: String] = [:] + for (key, value) in httpResponse.allHeaderFields { + guard let name = key as? String, let text = value as? String else { continue } + map[name.lowercased()] = text + } + storage = map + } + + public func first(_ name: String) -> String? { + storage[name.lowercased()] + } + + public func all(_ name: String) -> [String] { + guard let value = storage[name.lowercased()] else { return [] } + return value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} + +public struct TrinoHTTPRequest: Sendable { + public enum Method: String, Sendable { + case get = "GET" + case post = "POST" + case delete = "DELETE" + } + + public let method: Method + public let url: URL + public let headers: [String: String] + public let body: Data? + public let timeoutSeconds: Int + + public init(method: Method, url: URL, headers: [String: String], body: Data? = nil, timeoutSeconds: Int = 60) { + self.method = method + self.url = url + self.headers = headers + self.body = body + self.timeoutSeconds = timeoutSeconds + } +} + +public struct TrinoHTTPResponse: Sendable { + public let statusCode: Int + public let headers: TrinoHeaderFields + public let body: Data + + public init(statusCode: Int, headers: TrinoHeaderFields, body: Data) { + self.statusCode = statusCode + self.headers = headers + self.body = body + } + + public func retryAfterSeconds() -> Double? { + guard let value = headers.first("Retry-After"), let seconds = Double(value) else { return nil } + return seconds + } +} + +public protocol TrinoTransport: Sendable { + func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse +} + +public final class URLSessionTrinoTransport: NSObject, TrinoTransport, URLSessionDelegate, @unchecked Sendable { + private let verifyTLS: Bool + private let session: URLSession + + public init(verifyTLS: Bool) { + self.verifyTLS = verifyTLS + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let delegateProxy = TLSDelegate(verifyTLS: verifyTLS) + self.session = URLSession(configuration: configuration, delegate: delegateProxy, delegateQueue: nil) + super.init() + } + + public func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = TimeInterval(request.timeoutSeconds) + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let (data, response) = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.dataTask(with: urlRequest) { data, response, error in + if let error { + if (error as? URLError)?.code == .cancelled { + continuation.resume(throwing: TrinoError.cancelled) + } else { + continuation.resume(throwing: TrinoError.transport(error.localizedDescription)) + } + return + } + guard let data, let response else { + continuation.resume(throwing: TrinoError.invalidResponse("Empty response from Trino")) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw TrinoError.invalidResponse("Response was not HTTP") + } + return TrinoHTTPResponse( + statusCode: httpResponse.statusCode, + headers: TrinoHeaderFields(httpResponse: httpResponse), + body: data + ) + } +} + +private final class TLSDelegate: NSObject, URLSessionDelegate { + private let verifyTLS: Bool + + init(verifyTLS: Bool) { + self.verifyTLS = verifyTLS + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard !verifyTLS, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift new file mode 100644 index 000000000..215e9997f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum TrinoTypeCategory: Sendable, Equatable { + case scalar + case binary + case structured +} + +public enum TrinoTypeMapper { + static let binaryTypes: Set = [ + "varbinary", "hyperloglog", "p4hyperloglog", "qdigest", "tdigest", "setdigest" + ] + + static let structuredTypes: Set = ["array", "map", "row"] + + public static func baseType(fromDisplayType type: String) -> String { + let trimmed = type.trimmingCharacters(in: .whitespaces).lowercased() + guard let parenIndex = trimmed.firstIndex(of: "(") else { + return trimmed + } + return String(trimmed[.. TrinoTypeCategory { + let base = baseType(fromDisplayType: rawType) + if binaryTypes.contains(base) { + return .binary + } + if structuredTypes.contains(base) { + return .structured + } + return .scalar + } + + public static func displayType(_ column: TrinoColumn) -> String { + column.type + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift new file mode 100644 index 000000000..1a534210b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum TrinoValue: Sendable, Equatable { + case null + case text(String) + case bytes([UInt8]) +} + +public enum TrinoValueDecoder { + public static func decode(_ json: TrinoJSONValue, category: TrinoTypeCategory) -> TrinoValue { + if json.isNull { + return .null + } + switch category { + case .scalar: + return .text(json.scalarText ?? "") + case .binary: + if case .string(let encoded) = json, let data = Data(base64Encoded: encoded) { + return .bytes([UInt8](data)) + } + return .text(json.scalarText ?? "") + case .structured: + return .text(json.jsonText()) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift new file mode 100644 index 000000000..1f4ef26d7 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoIntrospectionSQLTests: XCTestCase { + func testQuoteIdentifierEscapesQuotes() { + XCTAssertEqual(TrinoIntrospectionSQL.quoteIdentifier("name"), "\"name\"") + XCTAssertEqual(TrinoIntrospectionSQL.quoteIdentifier("we\"ird"), "\"we\"\"ird\"") + } + + func testQuoteLiteralEscapesApostrophes() { + XCTAssertEqual(TrinoIntrospectionSQL.quoteLiteral("O'Brien"), "'O''Brien'") + } + + func testQualifiedNameJoinsAvailableParts() { + XCTAssertEqual( + TrinoIntrospectionSQL.qualifiedName(catalog: "hive", schema: "sales", table: "orders"), + "\"hive\".\"sales\".\"orders\"" + ) + XCTAssertEqual( + TrinoIntrospectionSQL.qualifiedName(catalog: nil, schema: "sales", table: "orders"), + "\"sales\".\"orders\"" + ) + XCTAssertEqual( + TrinoIntrospectionSQL.qualifiedName(catalog: nil, schema: nil, table: "orders"), + "\"orders\"" + ) + } + + func testShowCatalogsAndSchemas() { + XCTAssertEqual(TrinoIntrospectionSQL.showCatalogs(), "SHOW CATALOGS") + XCTAssertEqual(TrinoIntrospectionSQL.showSchemas(catalog: "hive"), "SHOW SCHEMAS FROM \"hive\"") + } + + func testListTablesQueriesInformationSchema() { + let sql = TrinoIntrospectionSQL.listTables(catalog: "hive", schema: "sales") + XCTAssertTrue(sql.contains("\"hive\".information_schema.tables")) + XCTAssertTrue(sql.contains("table_schema = 'sales'")) + XCTAssertTrue(sql.contains("ORDER BY table_name")) + } + + func testListColumnsQueriesInformationSchema() { + let sql = TrinoIntrospectionSQL.listColumns(catalog: "hive", schema: "sales", table: "orders") + XCTAssertTrue(sql.contains("\"hive\".information_schema.columns")) + XCTAssertTrue(sql.contains("table_schema = 'sales'")) + XCTAssertTrue(sql.contains("table_name = 'orders'")) + XCTAssertTrue(sql.contains("ORDER BY ordinal_position")) + } + + func testShowCreateTableAndView() { + XCTAssertEqual( + TrinoIntrospectionSQL.showCreateTable(catalog: "hive", schema: "sales", table: "orders"), + "SHOW CREATE TABLE \"hive\".\"sales\".\"orders\"" + ) + XCTAssertEqual( + TrinoIntrospectionSQL.showCreateView(catalog: "hive", schema: "sales", view: "v"), + "SHOW CREATE VIEW \"hive\".\"sales\".\"v\"" + ) + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift new file mode 100644 index 000000000..fd110db6d --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoJSONValueTests: XCTestCase { + private func decode(_ json: String) throws -> TrinoJSONValue { + try JSONDecoder().decode(TrinoJSONValue.self, from: Data(json.utf8)) + } + + func testDecodesNull() throws { + XCTAssertEqual(try decode("null"), .null) + } + + func testDecodesBool() throws { + XCTAssertEqual(try decode("true"), .bool(true)) + XCTAssertEqual(try decode("false"), .bool(false)) + } + + func testBigintKeepsFullPrecision() throws { + let value = try decode("9007199254740993") + XCTAssertEqual(value, .int(9_007_199_254_740_993)) + XCTAssertEqual(value.scalarText, "9007199254740993") + } + + func testInt64MaxRoundTrips() throws { + let value = try decode("9223372036854775807") + XCTAssertEqual(value, .int(9_223_372_036_854_775_807)) + XCTAssertEqual(value.scalarText, "9223372036854775807") + } + + func testDecimalStaysString() throws { + let value = try decode("\"12345678901234567890.99\"") + XCTAssertEqual(value, .string("12345678901234567890.99")) + XCTAssertEqual(value.scalarText, "12345678901234567890.99") + } + + func testDoubleSpecialValuesArriveAsStrings() throws { + XCTAssertEqual(try decode("\"NaN\""), .string("NaN")) + XCTAssertEqual(try decode("\"Infinity\""), .string("Infinity")) + XCTAssertEqual(try decode("\"-Infinity\""), .string("-Infinity")) + } + + func testArrayJsonText() throws { + let value = try decode("[1,2,3]") + XCTAssertEqual(value.jsonText(), "[1,2,3]") + } + + func testObjectJsonTextSortsKeys() throws { + let value = try decode("{\"b\":2,\"a\":1}") + XCTAssertEqual(value.jsonText(), "{\"a\":1,\"b\":2}") + } + + func testNestedArrayOfObjectsSerializes() throws { + let value = try decode("[{\"k\":\"v\"},{\"k\":\"w\"}]") + XCTAssertEqual(value.jsonText(), "[{\"k\":\"v\"},{\"k\":\"w\"}]") + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift new file mode 100644 index 000000000..b601daf32 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoQueryResultsDecodingTests: XCTestCase { + private func decode(_ json: String) throws -> TrinoQueryResults { + try JSONDecoder().decode(TrinoQueryResults.self, from: Data(json.utf8)) + } + + func testDecodesInitialResponseWithoutColumns() throws { + let json = #"{"id":"q1","infoUri":"http://h/ui/q1","nextUri":"http://h/v1/statement/q1/1","stats":{"state":"QUEUED"}}"# + let results = try decode(json) + XCTAssertEqual(results.id, "q1") + XCTAssertEqual(results.nextUri, "http://h/v1/statement/q1/1") + XCTAssertNil(results.columns) + XCTAssertNil(results.data) + XCTAssertNil(results.error) + XCTAssertEqual(results.stats?.state, "QUEUED") + } + + func testDecodesColumnsAndData() throws { + let json = #""" + {"id":"q1","columns":[{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}, + {"name":"s","type":"varchar(3)","typeSignature":{"rawType":"varchar"}}], + "data":[[1,"abc"],[2,"xyz"]],"stats":{"state":"FINISHED"}} + """# + let results = try decode(json) + XCTAssertEqual(results.columns?.count, 2) + XCTAssertEqual(results.columns?.first?.name, "n") + XCTAssertEqual(results.columns?.first?.rawTypeName, "bigint") + XCTAssertEqual(results.columns?.last?.type, "varchar(3)") + XCTAssertEqual(results.data?.count, 2) + } + + func testDecodesErrorObject() throws { + let json = #""" + {"id":"q1","error":{"message":"line 1:1: Table not found","errorCode":46, + "errorName":"TABLE_NOT_FOUND","errorType":"USER_ERROR"}} + """# + let results = try decode(json) + XCTAssertEqual(results.error?.errorName, "TABLE_NOT_FOUND") + XCTAssertEqual(results.error?.errorType, "USER_ERROR") + XCTAssertEqual(results.error?.errorCode, 46) + XCTAssertEqual(results.error?.message, "line 1:1: Table not found") + } + + func testDecodesUpdateTypeAndCount() throws { + let json = #"{"id":"q1","updateType":"INSERT","updateCount":7,"stats":{"state":"FINISHED"}}"# + let results = try decode(json) + XCTAssertEqual(results.updateType, "INSERT") + XCTAssertEqual(results.updateCount, 7) + XCTAssertNil(results.nextUri) + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift new file mode 100644 index 000000000..581aa7806 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoSessionStateTests: XCTestCase { + func testAppliesSetCatalogAndSchema() { + let session = TrinoSessionState(catalog: "old", schema: "public") + let headers = TrinoHeaderFields([ + "X-Trino-Set-Catalog": "hive", + "X-Trino-Set-Schema": "analytics" + ]) + session.apply(responseHeaders: headers, protocolHeaders: .trino) + XCTAssertEqual(session.catalog, "hive") + XCTAssertEqual(session.schema, "analytics") + } + + func testAppliesSetAndClearSession() { + let session = TrinoSessionState(sessionProperties: ["stale": "1"]) + let setHeaders = TrinoHeaderFields([ + "X-Trino-Set-Session": "query_max_memory=1GB", + "X-Trino-Clear-Session": "stale" + ]) + session.apply(responseHeaders: setHeaders, protocolHeaders: .trino) + XCTAssertEqual(session.sessionProperties["query_max_memory"], "1GB") + XCTAssertNil(session.sessionProperties["stale"]) + } + + func testDecodesPercentEncodedSessionValue() { + let session = TrinoSessionState() + let headers = TrinoHeaderFields(["X-Trino-Set-Session": "note=hello%20world"]) + session.apply(responseHeaders: headers, protocolHeaders: .trino) + XCTAssertEqual(session.sessionProperties["note"], "hello world") + } + + func testAppliesTransactionLifecycle() { + let session = TrinoSessionState() + session.apply( + responseHeaders: TrinoHeaderFields(["X-Trino-Started-Transaction-Id": "tx-123"]), + protocolHeaders: .trino + ) + XCTAssertEqual(session.transactionId, "tx-123") + session.apply( + responseHeaders: TrinoHeaderFields(["X-Trino-Clear-Transaction-Id": "true"]), + protocolHeaders: .trino + ) + XCTAssertNil(session.transactionId) + } + + func testSessionPropertyHeaderValueEncodesAndSorts() { + let session = TrinoSessionState(sessionProperties: ["b": "2", "a": "hello world"]) + XCTAssertEqual(session.sessionPropertyHeaderValue(), "a=hello%20world,b=2") + } + + func testPrestoProtocolHeadersApply() { + let session = TrinoSessionState() + session.apply( + responseHeaders: TrinoHeaderFields(["X-Presto-Set-Catalog": "tpch"]), + protocolHeaders: .presto + ) + XCTAssertEqual(session.catalog, "tpch") + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift new file mode 100644 index 000000000..b4d6dd626 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoStatementClientTests: XCTestCase { + private func makeClient(_ transport: StubTransport, session: TrinoSessionState = TrinoSessionState(catalog: "c", schema: "s")) -> TrinoStatementClient { + let config = TrinoClientConfig(host: "h", port: 8_080, user: "u") + return TrinoStatementClient(transport: transport, config: config, session: session) + } + + private let bigintColumn = #"{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}"# + + func testPollsNextUriUntilAbsentAndAccumulatesRows() async throws { + let transport = StubTransport([ + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2","columns":[\#(bigintColumn)]}"#), + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/3","columns":[\#(bigintColumn)],"data":[[9007199254740993]]}"#), + canned(#"{"id":"q1","columns":[\#(bigintColumn)],"data":[[42]]}"#) + ]) + let client = makeClient(transport) + + let result = try await client.execute("SELECT n FROM t") + + XCTAssertEqual(result.columns.map(\.name), ["n"]) + XCTAssertEqual(result.columns.first?.category, .scalar) + XCTAssertEqual(result.rows, [[.text("9007199254740993")], [.text("42")]]) + XCTAssertEqual(transport.requests.count, 4) + XCTAssertEqual(transport.requests.first?.method, .post) + XCTAssertEqual(transport.requests.dropFirst().map(\.method), [.get, .get, .get]) + } + + func testInitialPostCarriesSessionHeaders() async throws { + let transport = StubTransport([canned(#"{"id":"q1"}"#)]) + let client = makeClient(transport) + + _ = try await client.execute("SELECT 1") + + let post = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(post.headers["X-Trino-User"], "u") + XCTAssertEqual(post.headers["X-Trino-Catalog"], "c") + XCTAssertEqual(post.headers["X-Trino-Schema"], "s") + XCTAssertEqual(post.headers["X-Trino-Source"], "TablePro") + XCTAssertEqual(post.headers["Content-Type"], "text/plain; charset=utf-8") + XCTAssertEqual(post.body, Data("SELECT 1".utf8)) + } + + func testThrowsOnQueryError() async throws { + let transport = StubTransport([ + canned(#"{"id":"q1","error":{"message":"Table not found","errorName":"TABLE_NOT_FOUND","errorType":"USER_ERROR","errorCode":46}}"#) + ]) + let client = makeClient(transport) + + do { + _ = try await client.execute("SELECT * FROM missing") + XCTFail("Expected a query error") + } catch let error as TrinoError { + guard case .query(let queryError) = error else { + return XCTFail("Expected TrinoError.query, got \(error)") + } + XCTAssertEqual(queryError.errorName, "TABLE_NOT_FOUND") + XCTAssertEqual(error.errorDescription, "TABLE_NOT_FOUND: Table not found") + } + } + + func testRetriesOnServiceUnavailable() async throws { + let transport = StubTransport([ + canned("upstream unavailable", status: 503), + canned(#"{"id":"q1","data":[]}"#) + ]) + let client = makeClient(transport) + + _ = try await client.execute("SELECT 1") + + XCTAssertEqual(transport.requests.count, 2) + XCTAssertEqual(transport.requests.map(\.method), [.post, .post]) + } + + func testThrowsAuthenticationFailedOn401() async throws { + let transport = StubTransport([canned("Unauthorized", status: 401)]) + let client = makeClient(transport) + + do { + _ = try await client.execute("SELECT 1") + XCTFail("Expected an authentication error") + } catch let error as TrinoError { + guard case .authenticationFailed = error else { + return XCTFail("Expected authenticationFailed, got \(error)") + } + } + } + + func testReportsUpdateTypeAndCountForDML() async throws { + let transport = StubTransport([canned(#"{"id":"q1","updateType":"INSERT","updateCount":5}"#)]) + let client = makeClient(transport) + + let result = try await client.execute("INSERT INTO t VALUES (1)") + + XCTAssertEqual(result.updateType, "INSERT") + XCTAssertEqual(result.updateCount, 5) + XCTAssertTrue(result.columns.isEmpty) + XCTAssertTrue(result.rows.isEmpty) + } + + func testAppliesResponseSessionHeadersToState() async throws { + let session = TrinoSessionState(catalog: "c", schema: "s") + let transport = StubTransport([ + canned( + #"{"id":"q1","updateType":"SET SESSION"}"#, + headers: ["X-Trino-Set-Catalog": "hive", "X-Trino-Set-Schema": "analytics"] + ) + ]) + let client = makeClient(transport, session: session) + + _ = try await client.execute("USE hive.analytics") + + XCTAssertEqual(session.catalog, "hive") + XCTAssertEqual(session.schema, "analytics") + } + + func testCancelStopsPolling() async throws { + let box = ClientBox() + let transport = StubTransport([ + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2"}"#) + ]) + transport.onSend = { _, index in + if index == 1 { + box.client?.cancel() + } + } + let client = makeClient(transport) + box.client = client + + do { + _ = try await client.execute("SELECT 1") + XCTFail("Expected cancellation") + } catch let error as TrinoError { + XCTAssertEqual(error, .cancelled) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift new file mode 100644 index 000000000..a9a2965a3 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import TableProTrinoCore + +final class StubTransport: TrinoTransport, @unchecked Sendable { + struct Canned { + let statusCode: Int + let headers: [String: String] + let body: Data + } + + private let lock = NSLock() + private var queue: [Canned] + private var recorded: [TrinoHTTPRequest] = [] + var onSend: ((TrinoHTTPRequest, Int) -> Void)? + + init(_ responses: [Canned]) { + self.queue = responses + } + + var requests: [TrinoHTTPRequest] { + lock.withLock { recorded } + } + + func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse { + let index = lock.withLock { () -> Int in + recorded.append(request) + return recorded.count - 1 + } + onSend?(request, index) + let canned = lock.withLock { () -> Canned in + guard !queue.isEmpty else { + return Canned(statusCode: 200, headers: [:], body: Data(#"{"id":"empty"}"#.utf8)) + } + return queue.removeFirst() + } + return TrinoHTTPResponse( + statusCode: canned.statusCode, + headers: TrinoHeaderFields(canned.headers), + body: canned.body + ) + } +} + +func canned(_ json: String, status: Int = 200, headers: [String: String] = [:]) -> StubTransport.Canned { + StubTransport.Canned(statusCode: status, headers: headers, body: Data(json.utf8)) +} + +final class ClientBox: @unchecked Sendable { + var client: TrinoStatementClient? +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift new file mode 100644 index 000000000..0ce295991 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoTypeMapperTests: XCTestCase { + func testBaseTypeStripsParameters() { + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "varchar(255)"), "varchar") + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "decimal(10,2)"), "decimal") + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "array(varchar)"), "array") + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "row(x integer, y varchar)"), "row") + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "bigint"), "bigint") + XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "timestamp(3) with time zone"), "timestamp") + } + + func testScalarCategories() { + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "bigint"), .scalar) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "varchar"), .scalar) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "decimal"), .scalar) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "uuid"), .scalar) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "timestamp with time zone"), .scalar) + } + + func testBinaryCategories() { + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "varbinary"), .binary) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "hyperloglog"), .binary) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "qdigest"), .binary) + } + + func testStructuredCategories() { + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "array"), .structured) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "map"), .structured) + XCTAssertEqual(TrinoTypeMapper.category(forRawType: "row"), .structured) + } + + func testColumnCategoryUsesTypeSignatureRawType() { + let column = TrinoColumn( + name: "c", + type: "array(varchar)", + typeSignature: TrinoTypeSignature(rawType: "array") + ) + XCTAssertEqual(column.category, .structured) + XCTAssertEqual(column.rawTypeName, "array") + } + + func testColumnCategoryFallsBackToDisplayType() { + let column = TrinoColumn(name: "c", type: "varbinary", typeSignature: nil) + XCTAssertEqual(column.category, .binary) + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift new file mode 100644 index 000000000..48f0ac040 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoValueDecoderTests: XCTestCase { + func testScalarIntBecomesText() { + let value = TrinoValueDecoder.decode(.int(42), category: .scalar) + XCTAssertEqual(value, .text("42")) + } + + func testScalarBoolBecomesText() { + XCTAssertEqual(TrinoValueDecoder.decode(.bool(true), category: .scalar), .text("true")) + XCTAssertEqual(TrinoValueDecoder.decode(.bool(false), category: .scalar), .text("false")) + } + + func testNullStaysNull() { + XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .scalar), .null) + XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .binary), .null) + XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .structured), .null) + } + + func testVarbinaryBase64DecodesToBytes() { + let value = TrinoValueDecoder.decode(.string("aGVsbG8="), category: .binary) + XCTAssertEqual(value, .bytes([104, 101, 108, 108, 111])) + } + + func testInvalidBase64FallsBackToText() { + let value = TrinoValueDecoder.decode(.string("not base64!!!"), category: .binary) + XCTAssertEqual(value, .text("not base64!!!")) + } + + func testStructuredArrayBecomesJsonText() { + let value = TrinoValueDecoder.decode(.array([.int(1), .string("x")]), category: .structured) + XCTAssertEqual(value, .text("[1,\"x\"]")) + } + + func testStructuredObjectBecomesJsonText() { + let value = TrinoValueDecoder.decode(.object(["a": .int(1)]), category: .structured) + XCTAssertEqual(value, .text("{\"a\":1}")) + } +} diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist new file mode 100644 index 000000000..8171adf32 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 18 + + diff --git a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift new file mode 100644 index 000000000..b0cab9780 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -0,0 +1,141 @@ +import Foundation +import TableProPluginKit + +final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Trino Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Trino distributed SQL engine support via the REST client protocol" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Trino" + static let databaseDisplayName = "Trino" + static let iconName = "trino-icon" + static let defaultPort = 8_080 + static let isDownloadable = true + + static let connectionMode: ConnectionMode = .network + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = false + static let brandColorHex = "#DD5F3B" + static let queryLanguageName = "SQL" + static let editorLanguage: EditorLanguage = .sql + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = true + static let supportsSchemaSwitching = true + static let requiresReconnectForDatabaseSwitch = false + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let defaultGroupName = "default" + static let containerEntityName = "Catalog" + static let schemaEntityName = "Schema" + static let tableEntityName = "Tables" + static let defaultSchemaName = "" + static let systemSchemaNames: [String] = ["information_schema"] + static let supportsSSH = true + static let supportsSSL = true + static let supportsHealthMonitor = true + static let supportsImport = false + static let supportsExport = true + static let supportsReadOnlyMode = true + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + + static let columnTypesByCategory: [String: [String]] = [ + "Boolean": ["boolean"], + "Integer": ["tinyint", "smallint", "integer", "bigint"], + "Floating": ["real", "double", "decimal"], + "String": ["varchar", "char"], + "Binary": ["varbinary"], + "Date/Time": ["date", "time", "timestamp", "time with time zone", "timestamp with time zone"], + "Complex": ["array", "map", "row", "json"], + "Other": ["uuid", "ipaddress", "interval year to month", "interval day to second"], + ] + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "trinoAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "password", + fieldType: .dropdown(options: [ + .init(value: "password", label: "Username & Password"), + .init(value: "jwt", label: "JWT Access Token"), + ]), + section: .authentication + ), + ConnectionField( + id: "trinoJwtToken", + label: String(localized: "Access Token"), + placeholder: "JWT bearer token", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "trinoAuthMethod", values: ["jwt"]) + ), + ConnectionField( + id: "trinoSchema", + label: String(localized: "Schema"), + placeholder: "Default schema (optional)", + section: .connection + ), + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", "FETCH", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", "ON", "USING", "NATURAL", + "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "DISTINCT", "ALL", + "UNION", "INTERSECT", "EXCEPT", "WITH", "RECURSIVE", "VALUES", "UNNEST", "LATERAL", + "INSERT", "INTO", "UPDATE", "SET", "DELETE", "MERGE", "CREATE", "ALTER", "DROP", + "TABLE", "VIEW", "SCHEMA", "CATALOG", "IF", "EXISTS", "REPLACE", "COMMENT", + "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "TRY_CAST", "IS", "NULL", "TRUE", "FALSE", + "ASC", "DESC", "NULLS", "FIRST", "LAST", "OVER", "PARTITION", "WINDOW", + "ROWS", "RANGE", "GROUPS", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT", "ROW", + "GROUPING", "SETS", "CUBE", "ROLLUP", "QUALIFY", + "SHOW", "CATALOGS", "SCHEMAS", "TABLES", "COLUMNS", "DESCRIBE", "EXPLAIN", "ANALYZE", + "USE", "PREPARE", "EXECUTE", "DEALLOCATE", "RESET", "SESSION", + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "ARRAY_AGG", "APPROX_DISTINCT", "ARBITRARY", + "CARDINALITY", "ELEMENT_AT", "CONTAINS", "FILTER", "TRANSFORM", "REDUCE", "ZIP", + "MAP", "MAP_KEYS", "MAP_VALUES", "MAP_FROM_ENTRIES", "SLICE", "SEQUENCE", "FLATTEN", + "JSON_EXTRACT", "JSON_EXTRACT_SCALAR", "JSON_PARSE", "JSON_FORMAT", "JSON_ARRAY_LENGTH", + "REGEXP_LIKE", "REGEXP_REPLACE", "REGEXP_EXTRACT", "REGEXP_EXTRACT_ALL", "REGEXP_SPLIT", + "SUBSTR", "SUBSTRING", "LENGTH", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", "SPLIT", + "SPLIT_PART", "CONCAT", "REPLACE", "REVERSE", "POSITION", "STRPOS", "FORMAT", + "COALESCE", "NULLIF", "GREATEST", "LEAST", "IF", "TRY", "NOW", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "AT_TIMEZONE", "WITH_TIMEZONE", + "DATE_TRUNC", "DATE_ADD", "DATE_DIFF", "DATE_FORMAT", "DATE_PARSE", "PARSE_DATETIME", + "FROM_UNIXTIME", "TO_UNIXTIME", "FROM_ISO8601_TIMESTAMP", "EXTRACT", + "ROW_NUMBER", "RANK", "DENSE_RANK", "PERCENT_RANK", "CUME_DIST", "NTILE", + "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE", + "ABS", "CEIL", "CEILING", "FLOOR", "ROUND", "POWER", "SQRT", "CBRT", "LN", "LOG10", + "LOG2", "EXP", "MOD", "SIGN", "TRUNCATE", "WIDTH_BUCKET", + ], + dataTypes: [ + "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "INT", "BIGINT", "REAL", "DOUBLE", + "DECIMAL", "VARCHAR", "CHAR", "VARBINARY", "JSON", "DATE", "TIME", "TIMESTAMP", + "INTERVAL", "ARRAY", "MAP", "ROW", "IPADDRESS", "UUID", "HYPERLOGLOG", + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "logical", label: "Explain (Logical)", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "distributed", label: "Explain (Distributed)", sqlPrefix: "EXPLAIN (TYPE DISTRIBUTED)"), + ] + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + TrinoPluginDriver(config: config) + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift new file mode 100644 index 000000000..b01dc8dd5 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift @@ -0,0 +1,105 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +extension TrinoPluginDriver { + private var currentCatalog: String? { + session.catalog + } + + private func resolveSchema(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + return session.schema + } + + private func firstText(_ row: [PluginCellValue]) -> String? { + text(row.first) + } + + private func text(_ cell: PluginCellValue?) -> String? { + if case .text(let value)? = cell { return value } + return nil + } + + func fetchDatabases() async throws -> [String] { + let result = try await execute(query: TrinoIntrospectionSQL.showCatalogs()) + return result.rows.compactMap { firstText($0) } + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchSchemas() async throws -> [String] { + guard let catalog = currentCatalog else { return [] } + let result = try await execute(query: TrinoIntrospectionSQL.showSchemas(catalog: catalog)) + return result.rows.compactMap { firstText($0) } + } + + func switchDatabase(to database: String) async throws { + session.setCatalog(database) + } + + func switchSchema(to schema: String) async throws { + session.setSchema(schema) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return [] } + let result = try await execute(query: TrinoIntrospectionSQL.listTables(catalog: catalog, schema: targetSchema)) + return result.rows.compactMap { row in + guard let name = firstText(row) else { return nil } + let kind = row.count > 1 ? text(row[1]) : nil + return PluginTableInfo(name: name, type: Self.tableType(kind), schema: targetSchema, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return [] } + let result = try await execute( + query: TrinoIntrospectionSQL.listColumns(catalog: catalog, schema: targetSchema, table: table) + ) + return result.rows.map { row in + let name = text(row.first) ?? "" + let dataType = row.count > 1 ? text(row[1]) ?? "" : "" + let nullable = (row.count > 2 ? text(row[2]) : nil)?.uppercased() != "NO" + let defaultValue = Self.normalizedDefault(row.count > 3 ? text(row[3]) : nil) + return PluginColumnInfo(name: name, dataType: dataType, isNullable: nullable, defaultValue: defaultValue) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let result = try await execute( + query: TrinoIntrospectionSQL.showCreateTable(catalog: currentCatalog, schema: resolveSchema(schema), table: table) + ) + return result.rows.compactMap { firstText($0) }.joined(separator: "\n") + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let result = try await execute( + query: TrinoIntrospectionSQL.showCreateView(catalog: currentCatalog, schema: resolveSchema(schema), view: view) + ) + return result.rows.compactMap { firstText($0) }.joined(separator: "\n") + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + private static func tableType(_ kind: String?) -> String { + (kind ?? "").uppercased().contains("VIEW") ? "VIEW" : "TABLE" + } + + private static func normalizedDefault(_ value: String?) -> String? { + guard let value else { return nil } + return value.trimmingCharacters(in: .whitespaces).isEmpty ? nil : value + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift new file mode 100644 index 000000000..ede4e8fc7 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift @@ -0,0 +1,142 @@ +import Foundation +import os +import TableProPluginKit +import TableProTrinoCore + +final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + let config: DriverConnectionConfig + let session: TrinoSessionState + private let clientConfig: TrinoClientConfig + private let lock = NSLock() + private var _client: TrinoStatementClient? + private var _serverVersion: String? + + static let logger = Logger(subsystem: "com.TablePro", category: "TrinoPluginDriver") + + init(config: DriverConnectionConfig) { + self.config = config + self.clientConfig = Self.makeClientConfig(config) + self.session = TrinoSessionState( + catalog: config.database.isEmpty ? nil : config.database, + schema: Self.trimmedField(config.additionalFields["trinoSchema"]) + ) + } + + var capabilities: PluginCapabilities { + [.multiSchema, .cancelQuery] + } + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { false } + var currentSchema: String? { session.schema } + var serverVersion: String? { lock.withLock { _serverVersion } } + + var client: TrinoStatementClient? { + lock.withLock { _client } + } + + func connect() async throws { + let transport = URLSessionTrinoTransport(verifyTLS: clientConfig.verifyTLS) + let client = TrinoStatementClient(transport: transport, config: clientConfig, session: session) + lock.withLock { _client = client } + + let result = try await client.execute("SELECT version()") + if case .text(let version)? = result.rows.first?.first { + lock.withLock { _serverVersion = "Trino \(version)" } + } else { + lock.withLock { _serverVersion = "Trino" } + } + } + + func disconnect() { + lock.withLock { _client = nil } + } + + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + + func cancelQuery() throws { + client?.cancel() + } + + func execute(query: String) async throws -> PluginQueryResult { + guard let client else { throw TrinoError.notConnected } + let start = Date() + let result = try await client.execute(query) + return pluginResult(result, executionTime: Date().timeIntervalSince(start)) + } + + private func pluginResult(_ result: TrinoResultSet, executionTime: TimeInterval) -> PluginQueryResult { + guard !result.columns.isEmpty else { + let message = Self.statusMessage(for: result) + return PluginQueryResult( + columns: ["status"], + columnTypeNames: ["varchar"], + rows: [[.text(message)]], + rowsAffected: result.updateCount ?? 0, + executionTime: executionTime, + statusMessage: message + ) + } + return PluginQueryResult( + columns: result.columns.map(\.name), + columnTypeNames: result.columns.map(\.typeName), + rows: result.rows.map { row in row.map(Self.cellValue) }, + rowsAffected: result.updateCount ?? 0, + executionTime: executionTime + ) + } + + static func cellValue(_ value: TrinoValue) -> PluginCellValue { + switch value { + case .null: + return .null + case .text(let text): + return .text(text) + case .bytes(let bytes): + return .bytes(Data(bytes)) + } + } + + private static func statusMessage(for result: TrinoResultSet) -> String { + guard let updateType = result.updateType, !updateType.isEmpty else { + return String(localized: "Statement executed") + } + guard let count = result.updateCount else { + return updateType + } + return String(format: String(localized: "%1$@: %2$lld rows"), updateType, Int64(count)) + } + + private static func makeClientConfig(_ config: DriverConnectionConfig) -> TrinoClientConfig { + let useTLS = config.ssl.isEnabled + let port = config.port > 0 ? config.port : (useTLS ? 8_443 : 8_080) + return TrinoClientConfig( + host: config.host.isEmpty ? "localhost" : config.host, + port: port, + useTLS: useTLS, + verifyTLS: !useTLS || config.ssl.verifiesCertificate, + user: config.username, + catalog: config.database.isEmpty ? nil : config.database, + schema: trimmedField(config.additionalFields["trinoSchema"]), + auth: resolveAuth(config) + ) + } + + private static func resolveAuth(_ config: DriverConnectionConfig) -> TrinoAuth { + switch config.additionalFields["trinoAuthMethod"] { + case "jwt": + let token = config.additionalFields["trinoJwtToken"] ?? "" + return token.isEmpty ? .none : .jwt(token: token) + default: + return config.password.isEmpty ? .none : .basic(password: config.password) + } + } + + private static func trimmedField(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 4fa614a85..47d26871b 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -97,6 +97,9 @@ 5E1A500100000000000000A9 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 66671E37CDACCE1B9B0D9400 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97DB8F422F8F77B13A41B15F /* Cocoa.framework */; }; 675C428FECC26FF3E0AF42DA /* TableProTeradataCore in Frameworks */ = {isa = PBXBuildFile; productRef = F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */; }; + AC1D0000000000000000F003 /* TableProTrinoCore in Frameworks */ = {isa = PBXBuildFile; productRef = AC1D0000000000000000F002 /* TableProTrinoCore */; }; + AC1D0000000000000000F004 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97DB8F422F8F77B13A41B15F /* Cocoa.framework */; }; + AC1D0000000000000000F005 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; 703C8C18A7F43E262695F557 /* SnowflakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */; }; @@ -427,6 +430,7 @@ 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHeartbeat.swift; sourceTree = ""; }; 97DB8F422F8F77B13A41B15F /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */ = {isa = PBXFileReference; explicitFileType = "wrapper.plug-in"; includeInIndex = 0; name = TeradataDriver.bundle; path = TeradataDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = "wrapper.plug-in"; includeInIndex = 0; name = TrinoDriverPlugin.tableplugin; path = TrinoDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnectionRegistry.swift; sourceTree = ""; }; F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeAuth.swift; sourceTree = ""; }; @@ -672,6 +676,13 @@ ); target = 5A1091C62EF17EDC0055EA7C /* TablePro */; }; + AC1D0000000000000000F00A /* Exceptions for "Plugins/TrinoDriverPlugin" folder in "TrinoDriverPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = AC1D0000000000000000F00B /* TrinoDriverPlugin */; + }; A14460BE78075C507C6E5BDF /* Exceptions for "Plugins/TeradataDriverPlugin" folder in "TeradataDriver" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -902,6 +913,14 @@ path = TableProUITests; sourceTree = ""; }; + AC1D0000000000000000F009 /* Plugins/TrinoDriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AC1D0000000000000000F00A /* Exceptions for "Plugins/TrinoDriverPlugin" folder in "TrinoDriverPlugin" target */, + ); + path = Plugins/TrinoDriverPlugin; + sourceTree = ""; + }; 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -913,6 +932,16 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + AC1D0000000000000000F007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AC1D0000000000000000F004 /* Cocoa.framework in Frameworks */, + AC1D0000000000000000F005 /* TableProPluginKit.framework in Frameworks */, + AC1D0000000000000000F003 /* TableProTrinoCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3600DDF1A8FAAF26C193B519 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1250,6 +1279,7 @@ 5AEA8B482F6808E90040461A /* Frameworks */, 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */, + AC1D0000000000000000F009 /* Plugins/TrinoDriverPlugin */, ); sourceTree = ""; }; @@ -1291,6 +1321,7 @@ 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */, 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */, A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */, + AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -2070,6 +2101,29 @@ productReference = 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; }; + AC1D0000000000000000F00B /* TrinoDriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = AC1D0000000000000000F00E /* Build configuration list for PBXNativeTarget "TrinoDriverPlugin" */; + buildPhases = ( + AC1D0000000000000000F006 /* Sources */, + AC1D0000000000000000F007 /* Frameworks */, + AC1D0000000000000000F008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AC1D0000000000000000F009 /* Plugins/TrinoDriverPlugin */, + ); + name = TrinoDriverPlugin; + packageProductDependencies = ( + AC1D0000000000000000F002 /* TableProTrinoCore */, + ); + productName = TrinoDriverPlugin; + productReference = AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; A95F5AB41510A4EFAAA0F38C /* TeradataDriver */ = { isa = PBXNativeTarget; buildConfigurationList = 21831B14D329AEDEF6E0FC05 /* Build configuration list for PBXNativeTarget "TeradataDriver" */; @@ -2255,6 +2309,7 @@ 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */, FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */, A95F5AB41510A4EFAAA0F38C /* TeradataDriver */, + AC1D0000000000000000F00B /* TrinoDriverPlugin */, ); }; /* End PBXProject section */ @@ -2484,6 +2539,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC1D0000000000000000F008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CEA116D19B343A1E94648AFE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2771,6 +2833,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC1D0000000000000000F006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A71ED1E7D88AFCF2B8B4757E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4926,6 +4995,54 @@ }; name = Release; }; + AC1D0000000000000000F00C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/TrinoDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).TrinoPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TrinoDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + AC1D0000000000000000F00D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/TrinoDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).TrinoPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TrinoDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; E7AC38103A156E07E8B6F0B9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4977,6 +5094,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + AC1D0000000000000000F00E /* Build configuration list for PBXNativeTarget "TrinoDriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AC1D0000000000000000F00C /* Release */, + AC1D0000000000000000F00D /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 21831B14D329AEDEF6E0FC05 /* Build configuration list for PBXNativeTarget "TeradataDriver" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -5378,6 +5504,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; + AC1D0000000000000000F002 /* TableProTrinoCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProTrinoCore; + }; F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */ = { isa = XCSwiftPackageProductDependency; package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 0a6eff2f0..26f1fe0b8 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -316,6 +316,117 @@ extension PluginMetadataRegistry { tagline: String(localized: "Teradata Vantage data warehouse") ) )), + ("Trino", PluginMetadataSnapshot( + displayName: "Trino", iconName: "trino-icon", defaultPort: 8_080, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "trino", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["trino"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#DD5F3B", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameColumn: false, + defaultSSLMode: .disabled + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Tables", + containerEntityName: "Catalog", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: ["information_schema"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "ON", "USING", "AND", "OR", "NOT", + "IN", "LIKE", "BETWEEN", "AS", "DISTINCT", "UNION", "INTERSECT", "EXCEPT", "WITH", + "INSERT", "INTO", "UPDATE", "SET", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", + "VIEW", "SCHEMA", "CATALOG", "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "TRY_CAST", + "SHOW", "CATALOGS", "SCHEMAS", "TABLES", "COLUMNS", "DESCRIBE", "EXPLAIN", "ANALYZE", + "USE", "UNNEST", "OVER", "PARTITION", "QUALIFY", + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "ARRAY_AGG", "APPROX_DISTINCT", "CARDINALITY", + "ELEMENT_AT", "JSON_EXTRACT", "JSON_EXTRACT_SCALAR", "REGEXP_LIKE", "REGEXP_REPLACE", + "SUBSTR", "LENGTH", "LOWER", "UPPER", "TRIM", "SPLIT", "CONCAT", "COALESCE", + "DATE_TRUNC", "DATE_DIFF", "DATE_FORMAT", "FROM_UNIXTIME", "TO_UNIXTIME", + "ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", + ], + dataTypes: [ + "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "REAL", "DOUBLE", "DECIMAL", + "VARCHAR", "CHAR", "VARBINARY", "JSON", "DATE", "TIME", "TIMESTAMP", "INTERVAL", + "ARRAY", "MAP", "ROW", "IPADDRESS", "UUID", + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ), + statementCompletions: [], + columnTypesByCategory: [ + "Boolean": ["boolean"], + "Integer": ["tinyint", "smallint", "integer", "bigint"], + "Floating": ["real", "double", "decimal"], + "String": ["varchar", "char"], + "Binary": ["varbinary"], + "Date/Time": ["date", "time", "timestamp"], + "Complex": ["array", "map", "row", "json"], + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [ + ConnectionField( + id: "trinoAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "password", + fieldType: .dropdown(options: [ + .init(value: "password", label: "Username & Password"), + .init(value: "jwt", label: "JWT Access Token"), + ]), + section: .authentication + ), + ConnectionField( + id: "trinoJwtToken", + label: String(localized: "Access Token"), + placeholder: "JWT bearer token", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "trinoAuthMethod", values: ["jwt"]) + ), + ConnectionField( + id: "trinoSchema", + label: String(localized: "Schema"), + placeholder: "Default schema (optional)", + section: .connection + ), + ], + category: .analytical, + tagline: String(localized: "Distributed SQL query engine for data lakes") + ) + )), ("Oracle", PluginMetadataSnapshot( displayName: "Oracle", iconName: "oracle-icon", defaultPort: 1_521, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index a13a8db5e..fc8d586a0 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -63,6 +63,9 @@ struct ColumnTypeClassifier { // MARK: - Pattern Fallback private func classifyByPattern(upper: String, rawTypeName: String) -> ColumnType { + if upper == "ARRAY" || upper == "MAP" || upper == "ROW" { + return .json(rawType: rawTypeName) + } if upper.contains("BOOL") { return .boolean(rawType: rawTypeName) } @@ -75,6 +78,9 @@ struct ColumnTypeClassifier { if upper.hasPrefix("TIMESTAMP") { return .timestamp(rawType: rawTypeName) } + if upper.hasPrefix("TIME") { + return .timestamp(rawType: rawTypeName) + } if upper.hasSuffix("TEXT") || upper.hasSuffix("CHAR") { return .text(rawType: rawTypeName) } diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index b1a441fd8..a277470bd 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -877,4 +877,59 @@ struct ColumnTypeClassifierTests { #expect(isText(classifier.classify(rawTypeName: "unknown_type_xyz"))) } } + + @Suite("Trino Types") + struct TrinoTests { + private let classifier = ColumnTypeClassifier() + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + @Test("array(varchar) classifies as json") + func arrayIsJson() { + #expect(classifier.classify(rawTypeName: "array(varchar)").isJsonType) + } + + @Test("map(varchar, bigint) classifies as json") + func mapIsJson() { + #expect(classifier.classify(rawTypeName: "map(varchar, bigint)").isJsonType) + } + + @Test("row(x integer, y varchar) classifies as json") + func rowIsJson() { + #expect(classifier.classify(rawTypeName: "row(x integer, y varchar)").isJsonType) + } + + @Test("timestamp(3) with time zone classifies as timestamp") + func timestampTzIsTimestamp() { + #expect(isTimestamp(classifier.classify(rawTypeName: "timestamp(3) with time zone"))) + } + + @Test("time with time zone classifies as timestamp") + func timeWithTimeZoneIsTimestamp() { + #expect(isTimestamp(classifier.classify(rawTypeName: "time with time zone"))) + } + + @Test("varbinary classifies as blob") + func varbinaryIsBlob() { + #expect(classifier.classify(rawTypeName: "varbinary").isBlobType) + } + + @Test("uuid classifies as text") + func uuidIsText() { + #expect(isText(classifier.classify(rawTypeName: "uuid"))) + } + + @Test("varchar(255) classifies as text") + func varcharIsText() { + #expect(isText(classifier.classify(rawTypeName: "varchar(255)"))) + } + } } diff --git a/docs/databases/trino.mdx b/docs/databases/trino.mdx new file mode 100644 index 000000000..770411863 --- /dev/null +++ b/docs/databases/trino.mdx @@ -0,0 +1,61 @@ +--- +title: Trino +description: Connect to a Trino cluster with TablePro's native Swift driver. +--- + +TablePro connects to Trino with a driver written in native Swift. It speaks the Trino client REST protocol directly over HTTP, so there is no JDBC jar or CLI to install. Queries run over `POST /v1/statement` and TablePro pages through the results until the cluster is done. + +Presto uses the same protocol under a different header prefix. The current driver targets Trino. + +## Install the plugin + +Trino is a downloadable driver. Open **Settings > Plugins**, find Trino, and install it. The driver downloads and loads without restarting the app. + +## Connection settings + +| Field | Description | +| --- | --- | +| Host | The Trino coordinator hostname. | +| Port | The coordinator HTTP port. Default `8080`, or `8443` when TLS is on. | +| Username | The user the query runs as. Sent as `X-Trino-User`. | +| Catalog | The default catalog for unqualified table names. Optional; leave blank to browse all catalogs. | +| Schema | The default schema. Optional. | +| Auth Method | `Username & Password` for LDAP or password-file auth, or `JWT Access Token` for a bearer token. | +| Password | Your password when the auth method is Username & Password. Sent as HTTP Basic auth. | +| Access Token | Your JWT when the auth method is JWT Access Token. Sent as a bearer token. | +| SSL | Off by default. Turn it on to connect over HTTPS. Password and JWT auth both require it. | + +Trino requires TLS for any authentication. If you pick Password or JWT, turn SSL on so the coordinator accepts the credentials. + +## Catalogs, schemas, and tables + +A Trino connection is scoped to a catalog and schema, but every query can name objects in full as `catalog.schema.table`. TablePro shows catalogs from `SHOW CATALOGS`, schemas from `SHOW SCHEMAS`, and tables and columns from each catalog's `information_schema`. Expand a catalog to see its schemas, then a schema to see its tables. + +Identifiers are quoted with double quotes. Results page with standard `LIMIT` and `OFFSET`. + +## Types + +Trino's type system maps to TablePro's grid as follows: + +- Numbers keep full precision. `bigint` and `decimal` are read as exact text, not floating point. +- `varbinary` is shown as hex. +- `array`, `map`, and `row` are shown as JSON. +- `json` opens in the JSON viewer. +- `timestamp`, `time`, and their `with time zone` forms keep the value the server returned. + +## Writes + +The first release is read-focused. `SELECT` and schema browsing work everywhere. `INSERT`, `UPDATE`, `DELETE`, and DDL run through the same path, but whether they succeed depends on the connected catalog. Many Trino connectors are read-only or support only part of SQL, so a valid statement can still return `NOT_SUPPORTED` from the connector. + +## Authentication + +- **Username & Password** sends HTTP Basic auth. Use it for LDAP or password-file authentication. Requires TLS. +- **JWT Access Token** sends the token as a bearer credential. Requires TLS. + +Kerberos and OAuth 2.0 are not yet supported. + +## Troubleshooting + +- **Cannot reach the coordinator.** Confirm the host and that the coordinator port is open. The default is `8080` for HTTP and `8443` for HTTPS. +- **Authentication failed.** Password and JWT auth need TLS. Turn SSL on and confirm the credentials. +- **A write fails with NOT_SUPPORTED.** The connected catalog's connector does not support that operation. Check the connector's documentation for what it allows. diff --git a/docs/docs.json b/docs/docs.json index fae25865c..3c171eee2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -89,7 +89,7 @@ }, { "group": "Analytics", - "pages": ["databases/clickhouse", "databases/teradata"] + "pages": ["databases/clickhouse", "databases/teradata", "databases/trino"] } ] }, diff --git a/docs/index.mdx b/docs/index.mdx index 49fbc1ba2..6bca38d23 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,11 +1,11 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 17 more. +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 18 more. --- # TablePro -Native macOS client for 22 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. +Native macOS client for 23 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. TablePro main interface @@ -23,7 +23,7 @@ Native macOS client for 22 databases. Built with SwiftUI and AppKit, no Electron **[Safe Mode](/features/safe-mode)**: 6 per-connection protection levels, from silent alerts to Touch ID and read-only. **[Import & Export](/features/import-export)**: CSV, JSON, SQL, XLSX, MQL. Streaming export for large datasets. **[CSV Inspector](/features/csv-inspector)**: Open `.csv` and `.tsv` files directly. Edit cells, insert and delete rows and columns, save in the original dialect. -**[Plugin System](/features/plugins)**: 5 bundled drivers covering 8 databases, plus 14 more drivers installable from the plugin registry. +**[Plugin System](/features/plugins)**: 5 bundled drivers covering 8 databases, plus 15 more drivers installable from the plugin registry. **[iCloud Sync](/features/icloud-sync)**: Connections, groups, tags, settings, SSH profiles, saved queries and folders, favorite tables, and custom AI slash commands sync across Macs. **[Themes](/customization/appearance)**: Light, dark, and custom editor themes. Per-connection color labels. @@ -54,6 +54,7 @@ Native macOS client for 22 databases. Built with SwiftUI and AppKit, no Electron | libSQL / Turso | N/A (API-based) | Plugin | | Elasticsearch | 9200 | Plugin | | SurrealDB | 8000 | Plugin | +| Trino | 8080 | Plugin | ## System Requirements diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index fb5965f5d..b918ab09d 100755 --- a/scripts/release-all-plugins.sh +++ b/scripts/release-all-plugins.sh @@ -42,6 +42,7 @@ PLUGINS=( elasticsearch surrealdb teradata + trino ) BUNDLED_PLUGINS=( From 1138b0789c1e5717929cd1e952bed998148a0f06 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 21:46:49 +0700 Subject: [PATCH 2/5] feat(plugin-trino): add Trino brand icon --- .../trino-icon.imageset/Contents.json | 16 ++++++++++++++++ .../trino-icon.imageset/trino.svg | 1 + .../trino-icon.imageset/Contents.json | 16 ++++++++++++++++ .../trino-icon.imageset/trino.svg | 1 + .../trino-icon.imageset/Contents.json | 16 ++++++++++++++++ .../trino-icon.imageset/trino.svg | 1 + 6 files changed, 51 insertions(+) create mode 100644 TablePro/Assets.xcassets/trino-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/trino-icon.imageset/trino.svg create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/trino.svg create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/trino.svg diff --git a/TablePro/Assets.xcassets/trino-icon.imageset/Contents.json b/TablePro/Assets.xcassets/trino-icon.imageset/Contents.json new file mode 100644 index 000000000..6310cdad3 --- /dev/null +++ b/TablePro/Assets.xcassets/trino-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trino.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/trino-icon.imageset/trino.svg b/TablePro/Assets.xcassets/trino-icon.imageset/trino.svg new file mode 100644 index 000000000..2278773da --- /dev/null +++ b/TablePro/Assets.xcassets/trino-icon.imageset/trino.svg @@ -0,0 +1 @@ +Trino diff --git a/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/Contents.json new file mode 100644 index 000000000..6310cdad3 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trino.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/trino.svg b/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/trino.svg new file mode 100644 index 000000000..2278773da --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/trino-icon.imageset/trino.svg @@ -0,0 +1 @@ +Trino diff --git a/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/Contents.json new file mode 100644 index 000000000..6310cdad3 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trino.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/trino.svg b/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/trino.svg new file mode 100644 index 000000000..2278773da --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/trino-icon.imageset/trino.svg @@ -0,0 +1 @@ +Trino From e775e1d33b33fb46b1f0442a1bae92dcb30c5d97 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 22:48:17 +0700 Subject: [PATCH 3/5] feat(plugin-trino): add write path, streaming export, TLS modes, and richer browsing --- CHANGELOG.md | 2 +- .../TableProTrinoCore/TrinoClientConfig.swift | 25 +++- .../TableProTrinoCore/TrinoDDLSQL.swift | 78 ++++++++++++ .../TrinoIntrospectionSQL.swift | 22 +++- .../TableProTrinoCore/TrinoLiteral.swift | 94 ++++++++++++++ .../TableProTrinoCore/TrinoRowEditSQL.swift | 52 ++++++++ .../TrinoStatementClient.swift | 111 +++++++++++----- .../TrinoStatementSplitter.swift | 116 +++++++++++++++++ .../TableProTrinoCore/TrinoTransport.swift | 51 ++++++-- .../TrinoIntrospectionSQLTests.swift | 16 +++ .../TrinoLiteralTests.swift | 64 ++++++++++ .../TrinoStatementSplitterTests.swift | 60 +++++++++ .../TrinoStreamingTests.swift | 52 ++++++++ .../TrinoWriteSQLTests.swift | 119 ++++++++++++++++++ Plugins/TrinoDriverPlugin/TrinoPlugin.swift | 19 ++- .../TrinoPluginDriver+DDL.swift | 59 +++++++++ .../TrinoPluginDriver+Editing.swift | 111 ++++++++++++++++ .../TrinoPluginDriver+Schema.swift | 97 +++++++++++--- .../TrinoDriverPlugin/TrinoPluginDriver.swift | 67 +++++++++- .../TrinoDriverPlugin/TrinoSSLMapping.swift | 16 +++ ...ginMetadataRegistry+RegistryDefaults.swift | 20 ++- docs/databases/trino.mdx | 15 ++- 22 files changed, 1179 insertions(+), 87 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoDDLSQL.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoLiteral.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoRowEditSQL.swift create mode 100644 Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementSplitter.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoWriteSQLTests.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoPluginDriver+Editing.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6335939..eae5bb44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Trino support through a downloadable native Swift driver. Connect to a Trino cluster over its HTTP client protocol, browse catalogs, schemas, tables, and columns, and run SQL. Authenticate with a username and password or a JWT access token. (#1906) +- Trino support through a downloadable native Swift driver. Connect over the HTTP client protocol with a username and password or a JWT token, browse catalogs, schemas, tables, materialized views, and columns, run SQL and EXPLAIN, edit rows, and create or alter tables. TLS supports Verify CA and Verify Identity with a custom CA certificate. Large exports stream page by page. (#1906) ## [0.58.0] - 2026-07-18 diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift index 1cc8c49c0..af4206943 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift @@ -6,11 +6,30 @@ public enum TrinoAuth: Sendable, Equatable { case jwt(token: String) } +public struct TrinoTLSOptions: Sendable, Equatable { + public enum VerificationMode: Sendable, Equatable { + case full + case caOnly + case insecure + } + + public var mode: VerificationMode + public var caCertificatePath: String + + public init(mode: VerificationMode = .full, caCertificatePath: String = "") { + self.mode = mode + self.caCertificatePath = caCertificatePath + } + + public static let systemDefault = TrinoTLSOptions(mode: .full) + public static let insecure = TrinoTLSOptions(mode: .insecure) +} + public struct TrinoClientConfig: Sendable { public var host: String public var port: Int public var useTLS: Bool - public var verifyTLS: Bool + public var tls: TrinoTLSOptions public var user: String public var source: String public var catalog: String? @@ -25,7 +44,7 @@ public struct TrinoClientConfig: Sendable { host: String, port: Int = 8_080, useTLS: Bool = false, - verifyTLS: Bool = true, + tls: TrinoTLSOptions = .systemDefault, user: String, source: String = "TablePro", catalog: String? = nil, @@ -39,7 +58,7 @@ public struct TrinoClientConfig: Sendable { self.host = host self.port = port self.useTLS = useTLS - self.verifyTLS = verifyTLS + self.tls = tls self.user = user self.source = source self.catalog = catalog diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoDDLSQL.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoDDLSQL.swift new file mode 100644 index 000000000..64105a813 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoDDLSQL.swift @@ -0,0 +1,78 @@ +import Foundation + +public struct TrinoColumnSpec: Sendable, Equatable { + public let name: String + public let type: String + public let nullable: Bool + public let comment: String? + + public init(name: String, type: String, nullable: Bool, comment: String?) { + self.name = name + self.type = type + self.nullable = nullable + self.comment = comment + } +} + +public enum TrinoDDLSQL { + public static func columnDefinition(_ column: TrinoColumnSpec) -> String { + var definition = "\(TrinoIntrospectionSQL.quoteIdentifier(column.name)) \(column.type)" + if !column.nullable { + definition += " NOT NULL" + } + if let comment = column.comment, !comment.isEmpty { + definition += " COMMENT \(TrinoIntrospectionSQL.quoteLiteral(comment))" + } + return definition + } + + public static func createTable( + qualifiedTable: String, + columns: [TrinoColumnSpec], + tableComment: String?, + ifNotExists: Bool + ) -> String? { + guard !columns.isEmpty else { return nil } + let existsClause = ifNotExists ? "IF NOT EXISTS " : "" + let body = columns.map(columnDefinition).joined(separator: ",\n ") + var statement = "CREATE TABLE \(existsClause)\(qualifiedTable) (\n \(body)\n)" + if let tableComment, !tableComment.isEmpty { + statement += " COMMENT \(TrinoIntrospectionSQL.quoteLiteral(tableComment))" + } + return statement + } + + public static func addColumn(qualifiedTable: String, column: TrinoColumnSpec) -> String { + "ALTER TABLE \(qualifiedTable) ADD COLUMN \(columnDefinition(column))" + } + + public static func dropColumn(qualifiedTable: String, name: String) -> String { + "ALTER TABLE \(qualifiedTable) DROP COLUMN \(TrinoIntrospectionSQL.quoteIdentifier(name))" + } + + public static func renameColumn(qualifiedTable: String, from: String, to: String) -> String { + "ALTER TABLE \(qualifiedTable) RENAME COLUMN " + + "\(TrinoIntrospectionSQL.quoteIdentifier(from)) TO \(TrinoIntrospectionSQL.quoteIdentifier(to))" + } + + public static func setColumnType(qualifiedTable: String, name: String, type: String) -> String { + "ALTER TABLE \(qualifiedTable) ALTER COLUMN \(TrinoIntrospectionSQL.quoteIdentifier(name)) SET DATA TYPE \(type)" + } + + public static func setColumnComment(qualifiedTable: String, name: String, comment: String?) -> String { + let reference = "\(qualifiedTable).\(TrinoIntrospectionSQL.quoteIdentifier(name))" + let value = comment.flatMap { $0.isEmpty ? nil : $0 } + guard let value else { + return "COMMENT ON COLUMN \(reference) IS NULL" + } + return "COMMENT ON COLUMN \(reference) IS \(TrinoIntrospectionSQL.quoteLiteral(value))" + } + + public static func setTableComment(qualifiedTable: String, comment: String?) -> String { + let value = comment.flatMap { $0.isEmpty ? nil : $0 } + guard let value else { + return "COMMENT ON TABLE \(qualifiedTable) IS NULL" + } + return "COMMENT ON TABLE \(qualifiedTable) IS \(TrinoIntrospectionSQL.quoteLiteral(value))" + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift index 0d8f63012..9c0a00ef7 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift @@ -38,7 +38,7 @@ public enum TrinoIntrospectionSQL { public static func listColumns(catalog: String, schema: String, table: String) -> String { """ - SELECT column_name, data_type, is_nullable, column_default, ordinal_position \ + SELECT column_name, data_type, is_nullable, column_default, comment, ordinal_position \ FROM \(quoteIdentifier(catalog)).information_schema.columns \ WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table)) \ ORDER BY ordinal_position @@ -47,19 +47,31 @@ public enum TrinoIntrospectionSQL { public static func listAllColumns(catalog: String, schema: String) -> String { """ - SELECT table_name, column_name, data_type, is_nullable, column_default, ordinal_position \ + SELECT table_name, column_name, data_type, is_nullable, column_default, comment, ordinal_position \ FROM \(quoteIdentifier(catalog)).information_schema.columns \ WHERE table_schema = \(quoteLiteral(schema)) ORDER BY table_name, ordinal_position """ } - public static func approximateRowCount(catalog: String, schema: String, table: String) -> String { + public static func tableComment(catalog: String, schema: String, table: String) -> String { """ - SELECT row_count FROM \(quoteIdentifier(catalog)).information_schema.tables \ - WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table)) + SELECT comment FROM system.metadata.table_comments \ + WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) \ + AND table_name = \(quoteLiteral(table)) """ } + public static func listMaterializedViews(catalog: String, schema: String) -> String { + """ + SELECT name FROM system.metadata.materialized_views \ + WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) + """ + } + + public static func approximateRowCount(catalog: String?, schema: String?, table: String) -> String { + "SHOW STATS FOR \(qualifiedName(catalog: catalog, schema: schema, table: table))" + } + public static func showCreateTable(catalog: String?, schema: String?, table: String) -> String { "SHOW CREATE TABLE \(qualifiedName(catalog: catalog, schema: schema, table: table))" } diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoLiteral.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoLiteral.swift new file mode 100644 index 000000000..732421154 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoLiteral.swift @@ -0,0 +1,94 @@ +import Foundation + +public enum TrinoLiteral { + private static let numericBaseTypes: Set = [ + "tinyint", "smallint", "integer", "int", "bigint", "real", "double", "decimal" + ] + + public static func render(_ value: TrinoValue, typeName: String) -> String { + switch value { + case .null: + return "NULL" + case .bytes(let bytes): + return "X'" + bytes.map { String(format: "%02X", $0) }.joined() + "'" + case .text(let text): + return renderText(text, typeName: typeName) + } + } + + static func renderText(_ text: String, typeName: String) -> String { + let base = TrinoTypeMapper.baseType(fromDisplayType: typeName) + + if base == "boolean" { + let lower = text.lowercased() + if lower == "true" || lower == "false" { return lower } + return quoted(text) + } + if numericBaseTypes.contains(base) { + return isNumeric(text) ? text : quoted(text) + } + if base.hasPrefix("timestamp") { + return "TIMESTAMP " + quoted(text) + } + if base.hasPrefix("time") { + return "TIME " + quoted(text) + } + if base.hasPrefix("date") { + return "DATE " + quoted(text) + } + if base == "json" { + return "JSON " + quoted(text) + } + if base == "uuid" { + return "UUID " + quoted(text) + } + if base == "ipaddress" { + return "CAST(" + quoted(text) + " AS ipaddress)" + } + if base == "varbinary" { + return "from_hex(" + quoted(text) + ")" + } + if base == "array" || base == "map" || base == "row" { + return "CAST(json_parse(" + quoted(text) + ") AS " + typeName + ")" + } + return quoted(text) + } + + static func quoted(_ text: String) -> String { + "'" + text.replacingOccurrences(of: "'", with: "''") + "'" + } + + static func isNumeric(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + var seenDigit = false + var seenDot = false + var seenExponent = false + var iterator = text.makeIterator() + var isFirst = true + while let character = iterator.next() { + if isFirst { + isFirst = false + if character == "-" || character == "+" { continue } + } + if character.isNumber { + seenDigit = true + continue + } + if character == ".", !seenDot, !seenExponent { + seenDot = true + continue + } + if (character == "e" || character == "E"), seenDigit, !seenExponent { + seenExponent = true + seenDigit = false + if let next = iterator.next() { + if next == "+" || next == "-" { continue } + if next.isNumber { seenDigit = true; continue } + } + return false + } + return false + } + return seenDigit + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoRowEditSQL.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoRowEditSQL.swift new file mode 100644 index 000000000..c96327cb2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoRowEditSQL.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct TrinoColumnValue: Sendable, Equatable { + public let name: String + public let value: TrinoValue + public let typeName: String + + public init(name: String, value: TrinoValue, typeName: String) { + self.name = name + self.value = value + self.typeName = typeName + } +} + +public enum TrinoRowEditSQL { + public static func insert(qualifiedTable: String, columns: [TrinoColumnValue]) -> String? { + guard !columns.isEmpty else { return nil } + let names = columns.map { TrinoIntrospectionSQL.quoteIdentifier($0.name) }.joined(separator: ", ") + let values = columns.map { TrinoLiteral.render($0.value, typeName: $0.typeName) }.joined(separator: ", ") + return "INSERT INTO \(qualifiedTable) (\(names)) VALUES (\(values))" + } + + public static func update( + qualifiedTable: String, + assignments: [TrinoColumnValue], + keyColumns: [TrinoColumnValue] + ) -> String? { + guard !assignments.isEmpty, let predicate = predicate(keyColumns) else { return nil } + let sets = assignments + .map { "\(TrinoIntrospectionSQL.quoteIdentifier($0.name)) = \(TrinoLiteral.render($0.value, typeName: $0.typeName))" } + .joined(separator: ", ") + return "UPDATE \(qualifiedTable) SET \(sets) WHERE \(predicate)" + } + + public static func delete(qualifiedTable: String, keyColumns: [TrinoColumnValue]) -> String? { + guard let predicate = predicate(keyColumns) else { return nil } + return "DELETE FROM \(qualifiedTable) WHERE \(predicate)" + } + + static func predicate(_ keyColumns: [TrinoColumnValue]) -> String? { + var conditions: [String] = [] + for key in keyColumns where TrinoTypeMapper.category(forRawType: key.typeName) != .structured { + let quoted = TrinoIntrospectionSQL.quoteIdentifier(key.name) + if case .null = key.value { + conditions.append("\(quoted) IS NULL") + } else { + conditions.append("\(quoted) = \(TrinoLiteral.render(key.value, typeName: key.typeName))") + } + } + return conditions.isEmpty ? nil : conditions.joined(separator: " AND ") + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift index 85c6f7ad9..193e24ad4 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift @@ -1,6 +1,11 @@ import Foundation import os +public enum TrinoStreamElement: Sendable { + case columns([TrinoColumnDescriptor]) + case rows([[TrinoValue]]) +} + public final class TrinoStatementClient: @unchecked Sendable { private let transport: TrinoTransport private let config: TrinoClientConfig @@ -19,6 +24,61 @@ public final class TrinoStatementClient: @unchecked Sendable { } public func execute(_ sql: String) async throws -> TrinoResultSet { + var columns: [TrinoColumn] = [] + var rows: [[TrinoValue]] = [] + let outcome = try await runStatement( + sql, + onColumns: { columns = $0 }, + onPage: { rows.append(contentsOf: $0) } + ) + return TrinoResultSet( + columns: descriptors(from: columns), + rows: rows, + updateType: outcome.updateType, + updateCount: outcome.updateCount, + queryId: outcome.queryId + ) + } + + public func executeStreamed(_ sql: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let client = self + Task { + do { + _ = try await client.runStatement( + sql, + onColumns: { continuation.yield(.columns(client.descriptors(from: $0))) }, + onPage: { continuation.yield(.rows($0)) } + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func cancel() { + let uri = lock.withLock { () -> String? in + _cancelled = true + return _currentNextUri + } + if let uri { + fireDelete(uri) + } + } + + private struct StatementOutcome { + let updateType: String? + let updateCount: Int? + let queryId: String + } + + private func runStatement( + _ sql: String, + onColumns: ([TrinoColumn]) -> Void, + onPage: ([[TrinoValue]]) -> Void + ) async throws -> StatementOutcome { lock.withLock { _cancelled = false _currentNextUri = nil @@ -27,8 +87,9 @@ public final class TrinoStatementClient: @unchecked Sendable { throw TrinoError.invalidConfiguration("Invalid Trino server URL") } - let initialRequest = makeRequest(method: .post, url: statementURL, headers: initialHeaders(), body: Data(sql.utf8)) - var httpResponse = try await sendWithRetry(initialRequest) + var httpResponse = try await sendWithRetry( + makeRequest(method: .post, url: statementURL, headers: initialHeaders(), body: Data(sql.utf8)) + ) var results = try decode(httpResponse) session.apply(responseHeaders: httpResponse.headers, protocolHeaders: config.protocolHeaders) if let error = results.error { @@ -36,8 +97,12 @@ public final class TrinoStatementClient: @unchecked Sendable { } var columns = results.columns - var rows: [[TrinoValue]] = [] - appendRows(from: results, columns: columns, into: &rows) + var columnsEmitted = false + if let columns { + onColumns(columns) + columnsEmitted = true + } + emitRows(results, columns: columns, onPage: onPage) var updateType = results.updateType var updateCount = results.updateCount let queryId = results.id @@ -58,7 +123,11 @@ public final class TrinoStatementClient: @unchecked Sendable { if columns == nil { columns = results.columns } - appendRows(from: results, columns: columns, into: &rows) + if !columnsEmitted, let columns { + onColumns(columns) + columnsEmitted = true + } + emitRows(results, columns: columns, onPage: onPage) if let type = results.updateType { updateType = type } @@ -69,27 +138,13 @@ public final class TrinoStatementClient: @unchecked Sendable { } lock.withLock { _currentNextUri = nil } - return TrinoResultSet( - columns: descriptors(from: columns), - rows: rows, - updateType: updateType, - updateCount: updateCount, - queryId: queryId - ) - } - - public func cancel() { - let uri = lock.withLock { () -> String? in - _cancelled = true - return _currentNextUri - } - if let uri { - fireDelete(uri) - } + return StatementOutcome(updateType: updateType, updateCount: updateCount, queryId: queryId) } - private func appendRows(from results: TrinoQueryResults, columns: [TrinoColumn]?, into rows: inout [[TrinoValue]]) { - guard let data = results.data, let columns else { return } + private func emitRows(_ results: TrinoQueryResults, columns: [TrinoColumn]?, onPage: ([[TrinoValue]]) -> Void) { + guard let data = results.data, let columns, !data.isEmpty else { return } + var page: [[TrinoValue]] = [] + page.reserveCapacity(data.count) for row in data { var decoded: [TrinoValue] = [] decoded.reserveCapacity(columns.count) @@ -97,13 +152,13 @@ public final class TrinoStatementClient: @unchecked Sendable { let category = index < columns.count ? columns[index].category : .scalar decoded.append(TrinoValueDecoder.decode(value, category: category)) } - rows.append(decoded) + page.append(decoded) } + onPage(page) } - private func descriptors(from columns: [TrinoColumn]?) -> [TrinoColumnDescriptor] { - guard let columns else { return [] } - return columns.map { + private func descriptors(from columns: [TrinoColumn]) -> [TrinoColumnDescriptor] { + columns.map { TrinoColumnDescriptor(name: $0.name, typeName: $0.type, category: $0.category) } } diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementSplitter.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementSplitter.swift new file mode 100644 index 000000000..c91ce11bd --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementSplitter.swift @@ -0,0 +1,116 @@ +import Foundation + +public enum TrinoStatementSplitter { + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let semicolon = UInt16(UnicodeScalar(";").value) + private static let dash = UInt16(UnicodeScalar("-").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let star = UInt16(UnicodeScalar("*").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let space = UInt16(UnicodeScalar(" ").value) + private static let tab = UInt16(UnicodeScalar("\t").value) + private static let carriageReturn = UInt16(UnicodeScalar("\r").value) + + public static func split(_ sql: String) -> [String] { + let text = sql as NSString + let length = text.length + var statements: [String] = [] + var start = 0 + var index = 0 + var inSingle = false + var inDouble = false + var inLineComment = false + var inBlockComment = false + var hasContent = false + + while index < length { + let char = text.character(at: index) + + if inLineComment { + if char == newline { inLineComment = false } + index += 1 + continue + } + if inBlockComment { + if char == star, index + 1 < length, text.character(at: index + 1) == slash { + inBlockComment = false + index += 2 + continue + } + index += 1 + continue + } + if inSingle { + hasContent = true + if char == singleQuote { + if index + 1 < length, text.character(at: index + 1) == singleQuote { + index += 2 + continue + } + inSingle = false + } + index += 1 + continue + } + if inDouble { + hasContent = true + if char == doubleQuote { + if index + 1 < length, text.character(at: index + 1) == doubleQuote { + index += 2 + continue + } + inDouble = false + } + index += 1 + continue + } + + if char == singleQuote { + inSingle = true + hasContent = true + } else if char == doubleQuote { + inDouble = true + hasContent = true + } else if char == dash, index + 1 < length, text.character(at: index + 1) == dash { + inLineComment = true + index += 2 + continue + } else if char == slash, index + 1 < length, text.character(at: index + 1) == star { + inBlockComment = true + index += 2 + continue + } else if char == semicolon { + appendStatement(text, from: start, to: index, hasContent: hasContent, into: &statements) + start = index + 1 + hasContent = false + } else if !isWhitespace(char) { + hasContent = true + } + index += 1 + } + + appendStatement(text, from: start, to: length, hasContent: hasContent, into: &statements) + return statements + } + + private static func isWhitespace(_ char: UInt16) -> Bool { + char == space || char == tab || char == newline || char == carriageReturn + } + + private static func appendStatement( + _ text: NSString, + from start: Int, + to end: Int, + hasContent: Bool, + into statements: inout [String] + ) { + guard hasContent, end > start else { return } + let candidate = text + .substring(with: NSRange(location: start, length: end - start)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !candidate.isEmpty { + statements.append(candidate) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift index 3f46129a2..37eb6f9da 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift @@ -1,4 +1,5 @@ import Foundation +import Security public struct TrinoHeaderFields: Sendable, Equatable { private let storage: [String: String] @@ -76,15 +77,13 @@ public protocol TrinoTransport: Sendable { func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse } -public final class URLSessionTrinoTransport: NSObject, TrinoTransport, URLSessionDelegate, @unchecked Sendable { - private let verifyTLS: Bool +public final class URLSessionTrinoTransport: NSObject, TrinoTransport, @unchecked Sendable { private let session: URLSession - public init(verifyTLS: Bool) { - self.verifyTLS = verifyTLS + public init(tls: TrinoTLSOptions) { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - let delegateProxy = TLSDelegate(verifyTLS: verifyTLS) + let delegateProxy = TrinoTLSDelegate(tls: tls) self.session = URLSession(configuration: configuration, delegate: delegateProxy, delegateQueue: nil) super.init() } @@ -129,11 +128,11 @@ public final class URLSessionTrinoTransport: NSObject, TrinoTransport, URLSessio } } -private final class TLSDelegate: NSObject, URLSessionDelegate { - private let verifyTLS: Bool +private final class TrinoTLSDelegate: NSObject, URLSessionDelegate { + private let tls: TrinoTLSOptions - init(verifyTLS: Bool) { - self.verifyTLS = verifyTLS + init(tls: TrinoTLSOptions) { + self.tls = tls } func urlSession( @@ -141,12 +140,38 @@ private final class TLSDelegate: NSObject, URLSessionDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - guard !verifyTLS, - challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let trust = challenge.protectionSpace.serverTrust else { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil) return } - completionHandler(.useCredential, URLCredential(trust: trust)) + + if tls.mode == .insecure { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + return + } + + if !tls.caCertificatePath.isEmpty { + guard let caData = try? Data(contentsOf: URL(fileURLWithPath: tls.caCertificatePath)), + let caCert = SecCertificateCreateWithData(nil, caData as CFData) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + SecTrustSetAnchorCertificates(serverTrust, [caCert] as CFArray) + SecTrustSetAnchorCertificatesOnly(serverTrust, true) + } else if tls.mode == .full { + completionHandler(.performDefaultHandling, nil) + return + } + + if tls.mode == .caOnly { + SecTrustSetPolicies(serverTrust, SecPolicyCreateBasicX509()) + } + + if SecTrustEvaluateWithError(serverTrust, nil) { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } } } diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift index 1f4ef26d7..eea42e28b 100644 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift @@ -43,9 +43,25 @@ final class TrinoIntrospectionSQLTests: XCTestCase { XCTAssertTrue(sql.contains("\"hive\".information_schema.columns")) XCTAssertTrue(sql.contains("table_schema = 'sales'")) XCTAssertTrue(sql.contains("table_name = 'orders'")) + XCTAssertTrue(sql.contains("comment")) XCTAssertTrue(sql.contains("ORDER BY ordinal_position")) } + func testTableCommentQueriesSystemMetadata() { + let sql = TrinoIntrospectionSQL.tableComment(catalog: "hive", schema: "sales", table: "orders") + XCTAssertTrue(sql.contains("system.metadata.table_comments")) + XCTAssertTrue(sql.contains("catalog_name = 'hive'")) + XCTAssertTrue(sql.contains("schema_name = 'sales'")) + XCTAssertTrue(sql.contains("table_name = 'orders'")) + } + + func testListMaterializedViewsQueriesSystemMetadata() { + let sql = TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "sales") + XCTAssertTrue(sql.contains("system.metadata.materialized_views")) + XCTAssertTrue(sql.contains("catalog_name = 'hive'")) + XCTAssertTrue(sql.contains("schema_name = 'sales'")) + } + func testShowCreateTableAndView() { XCTAssertEqual( TrinoIntrospectionSQL.showCreateTable(catalog: "hive", schema: "sales", table: "orders"), diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift new file mode 100644 index 000000000..bb3fcf6b4 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoLiteralTests: XCTestCase { + func testNull() { + XCTAssertEqual(TrinoLiteral.render(.null, typeName: "varchar(10)"), "NULL") + } + + func testVarcharQuotesNumericLookingText() { + XCTAssertEqual(TrinoLiteral.render(.text("123"), typeName: "varchar(10)"), "'123'") + } + + func testVarcharEscapesApostrophe() { + XCTAssertEqual(TrinoLiteral.render(.text("O'Brien"), typeName: "varchar(20)"), "'O''Brien'") + } + + func testNumericColumnEmitsBareNumber() { + XCTAssertEqual(TrinoLiteral.render(.text("123"), typeName: "bigint"), "123") + XCTAssertEqual(TrinoLiteral.render(.text("-4.5"), typeName: "double"), "-4.5") + XCTAssertEqual(TrinoLiteral.render(.text("10.25"), typeName: "decimal(10,2)"), "10.25") + } + + func testNumericColumnQuotesNonNumericText() { + XCTAssertEqual(TrinoLiteral.render(.text("NaN"), typeName: "double"), "'NaN'") + } + + func testBoolean() { + XCTAssertEqual(TrinoLiteral.render(.text("true"), typeName: "boolean"), "true") + XCTAssertEqual(TrinoLiteral.render(.text("FALSE"), typeName: "boolean"), "false") + } + + func testTemporalLiterals() { + XCTAssertEqual(TrinoLiteral.render(.text("2020-01-02"), typeName: "date"), "DATE '2020-01-02'") + XCTAssertEqual(TrinoLiteral.render(.text("12:00:00"), typeName: "time(3)"), "TIME '12:00:00'") + XCTAssertEqual( + TrinoLiteral.render(.text("2020-01-02 03:04:05"), typeName: "timestamp(3)"), + "TIMESTAMP '2020-01-02 03:04:05'" + ) + XCTAssertEqual( + TrinoLiteral.render(.text("12:00:00 +00:00"), typeName: "time(3) with time zone"), + "TIME '12:00:00 +00:00'" + ) + } + + func testJsonUuidIpAddress() { + XCTAssertEqual(TrinoLiteral.render(.text("{\"a\":1}"), typeName: "json"), "JSON '{\"a\":1}'") + XCTAssertEqual( + TrinoLiteral.render(.text("12151fd2-7586-11e9-8f9e-2a86e4085a59"), typeName: "uuid"), + "UUID '12151fd2-7586-11e9-8f9e-2a86e4085a59'" + ) + XCTAssertEqual(TrinoLiteral.render(.text("10.0.0.1"), typeName: "ipaddress"), "CAST('10.0.0.1' AS ipaddress)") + } + + func testVarbinaryFromBytes() { + XCTAssertEqual(TrinoLiteral.render(.bytes([104, 105]), typeName: "varbinary"), "X'6869'") + } + + func testStructuredCastsFromJson() { + XCTAssertEqual( + TrinoLiteral.render(.text("[1,2,3]"), typeName: "array(integer)"), + "CAST(json_parse('[1,2,3]') AS array(integer))" + ) + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift new file mode 100644 index 000000000..79351cc86 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoStatementSplitterTests: XCTestCase { + func testSingleStatementNoTrailingSemicolon() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1"), ["SELECT 1"]) + } + + func testSingleStatementTrailingSemicolon() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1;"), ["SELECT 1"]) + } + + func testSplitsTwoStatements() { + XCTAssertEqual( + TrinoStatementSplitter.split("ALTER TABLE t RENAME COLUMN a TO b;\nALTER TABLE t ALTER COLUMN b SET DATA TYPE bigint"), + ["ALTER TABLE t RENAME COLUMN a TO b", "ALTER TABLE t ALTER COLUMN b SET DATA TYPE bigint"] + ) + } + + func testIgnoresSemicolonInsideSingleQuotes() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 'a;b'"), ["SELECT 'a;b'"]) + } + + func testIgnoresSemicolonInsideDoubleQuotedIdentifier() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT \"we;ird\" FROM t"), ["SELECT \"we;ird\" FROM t"]) + } + + func testHandlesEscapedSingleQuote() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 'O''Brien;x'; SELECT 2"), ["SELECT 'O''Brien;x'", "SELECT 2"]) + } + + func testIgnoresSemicolonInLineComment() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 -- a;b\n; SELECT 2"), ["SELECT 1 -- a;b", "SELECT 2"]) + } + + func testIgnoresSemicolonInBlockComment() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 /* a;b */; SELECT 2"), ["SELECT 1 /* a;b */", "SELECT 2"]) + } + + func testDropsEmptyStatements() { + XCTAssertEqual(TrinoStatementSplitter.split(";;SELECT 1;;"), ["SELECT 1"]) + } + + func testDropsTrailingCommentOnlySegment() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1; -- done"), ["SELECT 1"]) + } + + func testDropsCommentOnlyInput() { + XCTAssertEqual(TrinoStatementSplitter.split("-- just a comment"), []) + XCTAssertEqual(TrinoStatementSplitter.split("/* block */"), []) + } + + func testKeepsTrailingCommentOnRealStatement() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 -- trailing"), ["SELECT 1 -- trailing"]) + } + + func testKeepsLeadingCommentWithFollowingStatement() { + XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1;\n-- note\nSELECT 2"), ["SELECT 1", "-- note\nSELECT 2"]) + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift new file mode 100644 index 000000000..a2e9f299c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoStreamingTests: XCTestCase { + private let bigintColumn = #"{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}"# + + private func makeClient(_ transport: StubTransport) -> TrinoStatementClient { + let config = TrinoClientConfig(host: "h", port: 8_080, user: "u") + return TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState(catalog: "c", schema: "s")) + } + + func testStreamsColumnsOnceThenEachPageSeparately() async throws { + let transport = StubTransport([ + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2","columns":[\#(bigintColumn)]}"#), + canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/3","columns":[\#(bigintColumn)],"data":[[1],[2]]}"#), + canned(#"{"id":"q1","columns":[\#(bigintColumn)],"data":[[3]]}"#), + ]) + let client = makeClient(transport) + + var columnBatches: [[TrinoColumnDescriptor]] = [] + var pages: [[[TrinoValue]]] = [] + for try await element in client.executeStreamed("SELECT n FROM t") { + switch element { + case .columns(let columns): + columnBatches.append(columns) + case .rows(let page): + pages.append(page) + } + } + + XCTAssertEqual(columnBatches.count, 1) + XCTAssertEqual(columnBatches.first?.map(\.name), ["n"]) + XCTAssertEqual(pages, [[[.text("1")], [.text("2")]], [[.text("3")]]]) + } + + func testStreamPropagatesQueryError() async throws { + let transport = StubTransport([ + canned(#"{"id":"q1","error":{"message":"boom","errorName":"GENERIC_INTERNAL_ERROR","errorType":"INTERNAL_ERROR"}}"#), + ]) + let client = makeClient(transport) + + do { + for try await _ in client.executeStreamed("SELECT 1") {} + XCTFail("Expected an error") + } catch let error as TrinoError { + guard case .query = error else { + return XCTFail("Expected TrinoError.query, got \(error)") + } + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoWriteSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoWriteSQLTests.swift new file mode 100644 index 000000000..c118096ee --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoWriteSQLTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import TableProTrinoCore + +final class TrinoDDLSQLTests: XCTestCase { + private let target = "\"hive\".\"sales\".\"orders\"" + + func testColumnDefinitionWithComment() { + let spec = TrinoColumnSpec(name: "amount", type: "decimal(10,2)", nullable: false, comment: "the total") + XCTAssertEqual( + TrinoDDLSQL.columnDefinition(spec), + "\"amount\" decimal(10,2) NOT NULL COMMENT 'the total'" + ) + } + + func testCreateTable() { + let columns = [ + TrinoColumnSpec(name: "id", type: "bigint", nullable: false, comment: nil), + TrinoColumnSpec(name: "name", type: "varchar(50)", nullable: true, comment: nil), + ] + let sql = TrinoDDLSQL.createTable(qualifiedTable: target, columns: columns, tableComment: "orders", ifNotExists: true) + XCTAssertEqual( + sql, + "CREATE TABLE IF NOT EXISTS \(target) (\n \"id\" bigint NOT NULL,\n \"name\" varchar(50)\n) COMMENT 'orders'" + ) + } + + func testCreateTableEmptyColumnsReturnsNil() { + XCTAssertNil(TrinoDDLSQL.createTable(qualifiedTable: target, columns: [], tableComment: nil, ifNotExists: false)) + } + + func testAddDropRenameRetype() { + XCTAssertEqual( + TrinoDDLSQL.addColumn(qualifiedTable: target, column: TrinoColumnSpec(name: "c", type: "varchar", nullable: true, comment: nil)), + "ALTER TABLE \(target) ADD COLUMN \"c\" varchar" + ) + XCTAssertEqual(TrinoDDLSQL.dropColumn(qualifiedTable: target, name: "c"), "ALTER TABLE \(target) DROP COLUMN \"c\"") + XCTAssertEqual( + TrinoDDLSQL.renameColumn(qualifiedTable: target, from: "a", to: "b"), + "ALTER TABLE \(target) RENAME COLUMN \"a\" TO \"b\"" + ) + XCTAssertEqual( + TrinoDDLSQL.setColumnType(qualifiedTable: target, name: "a", type: "bigint"), + "ALTER TABLE \(target) ALTER COLUMN \"a\" SET DATA TYPE bigint" + ) + } + + func testComments() { + XCTAssertEqual( + TrinoDDLSQL.setColumnComment(qualifiedTable: target, name: "a", comment: "note"), + "COMMENT ON COLUMN \(target).\"a\" IS 'note'" + ) + XCTAssertEqual( + TrinoDDLSQL.setColumnComment(qualifiedTable: target, name: "a", comment: nil), + "COMMENT ON COLUMN \(target).\"a\" IS NULL" + ) + XCTAssertEqual( + TrinoDDLSQL.setTableComment(qualifiedTable: target, comment: "hi"), + "COMMENT ON TABLE \(target) IS 'hi'" + ) + } +} + +final class TrinoRowEditSQLTests: XCTestCase { + private let target = "\"hive\".\"sales\".\"orders\"" + + func testInsert() { + let columns = [ + TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint"), + TrinoColumnValue(name: "name", value: .text("Ann"), typeName: "varchar(20)"), + ] + XCTAssertEqual( + TrinoRowEditSQL.insert(qualifiedTable: target, columns: columns), + "INSERT INTO \(target) (\"id\", \"name\") VALUES (7, 'Ann')" + ) + } + + func testUpdateWithKey() { + let sql = TrinoRowEditSQL.update( + qualifiedTable: target, + assignments: [TrinoColumnValue(name: "name", value: .text("Bob"), typeName: "varchar(20)")], + keyColumns: [TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint")] + ) + XCTAssertEqual(sql, "UPDATE \(target) SET \"name\" = 'Bob' WHERE \"id\" = 7") + } + + func testDeleteWithNullKey() { + let sql = TrinoRowEditSQL.delete( + qualifiedTable: target, + keyColumns: [TrinoColumnValue(name: "id", value: .null, typeName: "bigint")] + ) + XCTAssertEqual(sql, "DELETE FROM \(target) WHERE \"id\" IS NULL") + } + + func testPredicateSkipsStructuredColumns() { + let sql = TrinoRowEditSQL.delete( + qualifiedTable: target, + keyColumns: [ + TrinoColumnValue(name: "tags", value: .text("[1]"), typeName: "array(integer)"), + TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint"), + ] + ) + XCTAssertEqual(sql, "DELETE FROM \(target) WHERE \"id\" = 7") + } + + func testDeleteWithOnlyStructuredColumnsReturnsNil() { + XCTAssertNil(TrinoRowEditSQL.delete( + qualifiedTable: target, + keyColumns: [TrinoColumnValue(name: "tags", value: .text("[1]"), typeName: "array(integer)")] + )) + } + + func testUpdateWithoutKeyReturnsNil() { + XCTAssertNil(TrinoRowEditSQL.update( + qualifiedTable: target, + assignments: [TrinoColumnValue(name: "name", value: .text("Bob"), typeName: "varchar(20)")], + keyColumns: [] + )) + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index b0cab9780..deabbce87 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -20,7 +20,7 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let queryLanguageName = "SQL" static let editorLanguage: EditorLanguage = .sql static let supportsForeignKeys = false - static let supportsSchemaEditing = false + static let supportsSchemaEditing = true static let supportsDatabaseSwitching = true static let supportsSchemaSwitching = true static let requiresReconnectForDatabaseSwitch = false @@ -38,13 +38,13 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsExport = true static let supportsReadOnlyMode = true static let supportsForeignKeyDisable = false - static let supportsAddColumn = false - static let supportsModifyColumn = false - static let supportsDropColumn = false + static let supportsAddColumn = true + static let supportsModifyColumn = true + static let supportsDropColumn = true static let supportsAddIndex = false static let supportsDropIndex = false static let supportsModifyPrimaryKey = false - static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] static let columnTypesByCategory: [String: [String]] = [ @@ -84,6 +84,12 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { placeholder: "Default schema (optional)", section: .connection ), + ConnectionField( + id: "trinoTimeZone", + label: String(localized: "Time Zone"), + placeholder: "Optional (e.g. America/New_York)", + section: .advanced + ), ] static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( @@ -133,6 +139,9 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let explainVariants: [ExplainVariant] = [ ExplainVariant(id: "logical", label: "Explain (Logical)", sqlPrefix: "EXPLAIN"), ExplainVariant(id: "distributed", label: "Explain (Distributed)", sqlPrefix: "EXPLAIN (TYPE DISTRIBUTED)"), + ExplainVariant(id: "io", label: "Explain (IO)", sqlPrefix: "EXPLAIN (TYPE IO)"), + ExplainVariant(id: "validate", label: "Explain (Validate)", sqlPrefix: "EXPLAIN (TYPE VALIDATE)"), + ExplainVariant(id: "analyze", label: "Explain Analyze", sqlPrefix: "EXPLAIN ANALYZE"), ] func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift new file mode 100644 index 000000000..61176667e --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift @@ -0,0 +1,59 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +extension TrinoPluginDriver { + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + TrinoDDLSQL.addColumn(qualifiedTable: qualifiedName(table: table, schema: nil), column: columnSpec(column)) + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + TrinoDDLSQL.dropColumn(qualifiedTable: qualifiedName(table: table, schema: nil), name: columnName) + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + let target = qualifiedName(table: table, schema: nil) + var statements: [String] = [] + if oldColumn.name != newColumn.name { + statements.append(TrinoDDLSQL.renameColumn(qualifiedTable: target, from: oldColumn.name, to: newColumn.name)) + } + if oldColumn.dataType != newColumn.dataType { + statements.append(TrinoDDLSQL.setColumnType(qualifiedTable: target, name: newColumn.name, type: newColumn.dataType)) + } + if oldColumn.comment != newColumn.comment { + statements.append(TrinoDDLSQL.setColumnComment( + qualifiedTable: target, + name: newColumn.name, + comment: normalizedComment(newColumn.comment) + )) + } + return statements.isEmpty ? nil : statements.joined(separator: ";\n") + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + TrinoDDLSQL.createTable( + qualifiedTable: qualifiedName(table: definition.tableName, schema: nil), + columns: definition.columns.map(columnSpec), + tableComment: nil, + ifNotExists: definition.ifNotExists + ) + } + + private func columnSpec(_ column: PluginColumnDefinition) -> TrinoColumnSpec { + TrinoColumnSpec( + name: column.name, + type: column.dataType, + nullable: column.isNullable, + comment: normalizedComment(column.comment) + ) + } + + func normalizedComment(_ comment: String?) -> String? { + guard let comment, !comment.isEmpty else { return nil } + return comment + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Editing.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Editing.swift new file mode 100644 index 000000000..c7eb9efb7 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Editing.swift @@ -0,0 +1,111 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +extension TrinoPluginDriver { + func generateStatements( + table: String, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + generateStatements( + table: table, schema: nil, columns: columns, primaryKeyColumns: primaryKeyColumns, + changes: changes, insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices + ) + } + + func generateStatements( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let target = qualifiedName(table: table, schema: schema) + let types = cachedColumnTypes(key: columnTypeKey(schema: schema, table: table)) + let typeName: (String) -> String = { types[$0] ?? "varchar" } + + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { continue } + let values = insertValues(change, columns: columns, insertedRowData: insertedRowData, typeName: typeName) + if let sql = TrinoRowEditSQL.insert(qualifiedTable: target, columns: values) { + statements.append((sql, [])) + } + case .update: + let assignments = change.cellChanges.map { + TrinoColumnValue(name: $0.columnName, value: Self.trinoValue($0.newValue), typeName: typeName($0.columnName)) + } + let keys = keyColumns(primaryKeyColumns, columns: columns, change: change, typeName: typeName) + if let sql = TrinoRowEditSQL.update(qualifiedTable: target, assignments: assignments, keyColumns: keys) { + statements.append((sql, [])) + } + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + let keys = keyColumns(primaryKeyColumns, columns: columns, change: change, typeName: typeName) + if let sql = TrinoRowEditSQL.delete(qualifiedTable: target, keyColumns: keys) { + statements.append((sql, [])) + } + } + } + return statements.isEmpty ? nil : statements + } + + private func insertValues( + _ change: PluginRowChange, + columns: [String], + insertedRowData: [Int: [PluginCellValue]], + typeName: (String) -> String + ) -> [TrinoColumnValue] { + if let rowData = insertedRowData[change.rowIndex] { + return columns.enumerated().compactMap { index, column in + guard index < rowData.count else { return nil } + return TrinoColumnValue(name: column, value: Self.trinoValue(rowData[index]), typeName: typeName(column)) + } + } + return change.cellChanges.map { + TrinoColumnValue(name: $0.columnName, value: Self.trinoValue($0.newValue), typeName: typeName($0.columnName)) + } + } + + private func keyColumns( + _ primaryKeyColumns: [String], + columns: [String], + change: PluginRowChange, + typeName: (String) -> String + ) -> [TrinoColumnValue] { + let keyNames = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns + return keyNames.compactMap { column in + guard let value = originalValue(column, columns: columns, change: change) else { return nil } + return TrinoColumnValue(name: column, value: Self.trinoValue(value), typeName: typeName(column)) + } + } + + private func originalValue(_ column: String, columns: [String], change: PluginRowChange) -> PluginCellValue? { + if let originalRow = change.originalRow, let index = columns.firstIndex(of: column), index < originalRow.count { + return originalRow[index] + } + return change.cellChanges.first { $0.columnName == column }?.oldValue + } + + private static func trinoValue(_ cell: PluginCellValue) -> TrinoValue { + switch cell { + case .null: + return .null + case .text(let text): + return .text(text) + case .bytes(let data): + return .bytes([UInt8](data)) + } + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift index b01dc8dd5..e16b7bff4 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift @@ -3,24 +3,32 @@ import TableProPluginKit import TableProTrinoCore extension TrinoPluginDriver { - private var currentCatalog: String? { + var currentCatalog: String? { session.catalog } - private func resolveSchema(_ schema: String?) -> String? { + func resolveSchema(_ schema: String?) -> String? { if let schema, !schema.isEmpty { return schema } return session.schema } - private func firstText(_ row: [PluginCellValue]) -> String? { - text(row.first) + func qualifiedName(table: String, schema: String?) -> String { + TrinoIntrospectionSQL.qualifiedName(catalog: currentCatalog, schema: resolveSchema(schema), table: table) + } + + func columnTypeKey(schema: String?, table: String) -> String { + "\(resolveSchema(schema) ?? "").\(table)" } - private func text(_ cell: PluginCellValue?) -> String? { + func text(_ cell: PluginCellValue?) -> String? { if case .text(let value)? = cell { return value } return nil } + private func firstText(_ row: [PluginCellValue]) -> String? { + text(row.first) + } + func fetchDatabases() async throws -> [String] { let result = try await execute(query: TrinoIntrospectionSQL.showCatalogs()) return result.rows.compactMap { firstText($0) } @@ -47,11 +55,21 @@ extension TrinoPluginDriver { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return [] } let result = try await execute(query: TrinoIntrospectionSQL.listTables(catalog: catalog, schema: targetSchema)) - return result.rows.compactMap { row in - guard let name = firstText(row) else { return nil } + let materialized = await materializedViewNames(catalog: catalog, schema: targetSchema) + + var tables: [PluginTableInfo] = [] + var seen: Set = [] + for row in result.rows { + guard let name = firstText(row) else { continue } + seen.insert(name) let kind = row.count > 1 ? text(row[1]) : nil - return PluginTableInfo(name: name, type: Self.tableType(kind), schema: targetSchema, comment: nil) + let type = materialized.contains(name) ? "MATERIALIZED VIEW" : Self.tableType(kind) + tables.append(PluginTableInfo(name: name, type: type, schema: targetSchema, comment: nil)) } + for name in materialized.sorted() where !seen.contains(name) { + tables.append(PluginTableInfo(name: name, type: "MATERIALIZED VIEW", schema: targetSchema, comment: nil)) + } + return tables } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { @@ -59,13 +77,24 @@ extension TrinoPluginDriver { let result = try await execute( query: TrinoIntrospectionSQL.listColumns(catalog: catalog, schema: targetSchema, table: table) ) - return result.rows.map { row in + var types: [String: String] = [:] + let columns = result.rows.map { row -> PluginColumnInfo in let name = text(row.first) ?? "" let dataType = row.count > 1 ? text(row[1]) ?? "" : "" let nullable = (row.count > 2 ? text(row[2]) : nil)?.uppercased() != "NO" - let defaultValue = Self.normalizedDefault(row.count > 3 ? text(row[3]) : nil) - return PluginColumnInfo(name: name, dataType: dataType, isNullable: nullable, defaultValue: defaultValue) + let defaultValue = Self.normalizedText(row.count > 3 ? text(row[3]) : nil) + let comment = Self.normalizedText(row.count > 4 ? text(row[4]) : nil) + types[name] = dataType + return PluginColumnInfo( + name: name, + dataType: dataType, + isNullable: nullable, + defaultValue: defaultValue, + comment: comment + ) } + cacheColumnTypes(types, key: columnTypeKey(schema: targetSchema, table: table)) + return columns } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { @@ -77,28 +106,58 @@ extension TrinoPluginDriver { } func fetchTableDDL(table: String, schema: String?) async throws -> String { - let result = try await execute( - query: TrinoIntrospectionSQL.showCreateTable(catalog: currentCatalog, schema: resolveSchema(schema), table: table) - ) + let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table: table, schema: schema))") return result.rows.compactMap { firstText($0) }.joined(separator: "\n") } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let result = try await execute( - query: TrinoIntrospectionSQL.showCreateView(catalog: currentCatalog, schema: resolveSchema(schema), view: view) - ) + let result = try await execute(query: "SHOW CREATE VIEW \(qualifiedName(table: view, schema: schema))") return result.rows.compactMap { firstText($0) }.joined(separator: "\n") } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) + let rowCount = try? await fetchApproximateRowCount(table: table, schema: schema) + let comment = await tableComment(table: table, schema: schema) + return PluginTableMetadata( + tableName: table, + rowCount: rowCount.flatMap { $0 }.map { Int64($0) }, + comment: comment + ) + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + guard currentCatalog != nil else { return nil } + let sql = TrinoIntrospectionSQL.approximateRowCount( + catalog: currentCatalog, schema: resolveSchema(schema), table: table + ) + guard let result = try? await execute(query: sql) else { return nil } + for row in result.rows { + guard case .null? = row.first, row.count > 4, let value = text(row[4]), let count = Double(value) else { + continue + } + return Int(count) + } + return nil + } + + private func tableComment(table: String, schema: String?) async -> String? { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return nil } + let sql = TrinoIntrospectionSQL.tableComment(catalog: catalog, schema: targetSchema, table: table) + let result = try? await execute(query: sql) + return Self.normalizedText(text(result?.rows.first?.first)) + } + + private func materializedViewNames(catalog: String, schema: String) async -> Set { + let sql = TrinoIntrospectionSQL.listMaterializedViews(catalog: catalog, schema: schema) + guard let result = try? await execute(query: sql) else { return [] } + return Set(result.rows.compactMap { firstText($0) }) } private static func tableType(_ kind: String?) -> String { (kind ?? "").uppercased().contains("VIEW") ? "VIEW" : "TABLE" } - private static func normalizedDefault(_ value: String?) -> String? { + private static func normalizedText(_ value: String?) -> String? { guard let value else { return nil } return value.trimmingCharacters(in: .whitespaces).isEmpty ? nil : value } diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift index ede4e8fc7..7a5fc59d3 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift @@ -10,6 +10,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let lock = NSLock() private var _client: TrinoStatementClient? private var _serverVersion: String? + private var _columnTypes: [String: [String: String]] = [:] static let logger = Logger(subsystem: "com.TablePro", category: "TrinoPluginDriver") @@ -23,7 +24,15 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } var capabilities: PluginCapabilities { - [.multiSchema, .cancelQuery] + [.multiSchema, .cancelQuery, .materializedViews] + } + + func cacheColumnTypes(_ types: [String: String], key: String) { + lock.withLock { _columnTypes[key] = types } + } + + func cachedColumnTypes(key: String) -> [String: String] { + lock.withLock { _columnTypes[key] } ?? [:] } var supportsSchemas: Bool { true } @@ -36,7 +45,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func connect() async throws { - let transport = URLSessionTrinoTransport(verifyTLS: clientConfig.verifyTLS) + let transport = URLSessionTrinoTransport(tls: clientConfig.tls) let client = TrinoStatementClient(transport: transport, config: clientConfig, session: session) lock.withLock { _client = client } @@ -63,8 +72,55 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func execute(query: String) async throws -> PluginQueryResult { guard let client else { throw TrinoError.notConnected } let start = Date() - let result = try await client.execute(query) - return pluginResult(result, executionTime: Date().timeIntervalSince(start)) + + if !query.contains(";") { + let result = try await client.execute(query) + return pluginResult(result, executionTime: Date().timeIntervalSince(start)) + } + + let statements = TrinoStatementSplitter.split(query) + guard !statements.isEmpty else { + return pluginResult(TrinoResultSet(columns: [], rows: []), executionTime: 0) + } + var last = TrinoResultSet(columns: [], rows: []) + for statement in statements { + last = try await client.execute(statement) + } + return pluginResult(last, executionTime: Date().timeIntervalSince(start)) + } + + func streamRows(query: String) -> AsyncThrowingStream { + let statement = query.contains(";") ? (TrinoStatementSplitter.split(query).last ?? query) : query + return AsyncThrowingStream { continuation in + let driver = self + Task { + guard let client = driver.client else { + continuation.finish(throwing: TrinoError.notConnected) + return + } + do { + var headerSent = false + for try await element in client.executeStreamed(statement) { + switch element { + case .columns(let columns): + continuation.yield(.header(PluginStreamHeader( + columns: columns.map(\.name), + columnTypeNames: columns.map(\.typeName) + ))) + headerSent = true + case .rows(let page): + continuation.yield(.rows(page.map { row in row.map(Self.cellValue) })) + } + } + if !headerSent { + continuation.yield(.header(PluginStreamHeader(columns: [], columnTypeNames: []))) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } } private func pluginResult(_ result: TrinoResultSet, executionTime: TimeInterval) -> PluginQueryResult { @@ -116,10 +172,11 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { host: config.host.isEmpty ? "localhost" : config.host, port: port, useTLS: useTLS, - verifyTLS: !useTLS || config.ssl.verifiesCertificate, + tls: TrinoSSLMapping.tlsOptions(for: config.ssl), user: config.username, catalog: config.database.isEmpty ? nil : config.database, schema: trimmedField(config.additionalFields["trinoSchema"]), + timeZone: trimmedField(config.additionalFields["trinoTimeZone"]), auth: resolveAuth(config) ) } diff --git a/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift new file mode 100644 index 000000000..a483ebe77 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift @@ -0,0 +1,16 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +enum TrinoSSLMapping { + static func tlsOptions(for ssl: SSLConfiguration) -> TrinoTLSOptions { + switch ssl.mode { + case .disabled, .preferred, .required: + return TrinoTLSOptions(mode: .insecure) + case .verifyCa: + return TrinoTLSOptions(mode: .caOnly, caCertificatePath: ssl.caCertificatePath) + case .verifyIdentity: + return TrinoTLSOptions(mode: .full, caCertificatePath: ssl.caCertificatePath) + } + } +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 26f1fe0b8..5cda9f147 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -318,9 +318,17 @@ extension PluginMetadataRegistry { )), ("Trino", PluginMetadataSnapshot( displayName: "Trino", iconName: "trino-icon", defaultPort: 8_080, - requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "trino", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + navigationModel: .standard, + explainVariants: [ + ExplainVariant(id: "logical", label: "Explain (Logical)", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "distributed", label: "Explain (Distributed)", sqlPrefix: "EXPLAIN (TYPE DISTRIBUTED)"), + ExplainVariant(id: "io", label: "Explain (IO)", sqlPrefix: "EXPLAIN (TYPE IO)"), + ExplainVariant(id: "validate", label: "Explain (Validate)", sqlPrefix: "EXPLAIN (TYPE VALIDATE)"), + ExplainVariant(id: "analyze", label: "Explain Analyze", sqlPrefix: "EXPLAIN ANALYZE"), + ], + pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["trino"], postConnectActions: [.selectSchemaFromLastSession], brandColorHex: "#DD5F3B", @@ -353,7 +361,7 @@ extension PluginMetadataRegistry { systemSchemaNames: ["information_schema"], fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue] + structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment] ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: SQLDialectDescriptor( @@ -422,6 +430,12 @@ extension PluginMetadataRegistry { placeholder: "Default schema (optional)", section: .connection ), + ConnectionField( + id: "trinoTimeZone", + label: String(localized: "Time Zone"), + placeholder: "Optional (e.g. America/New_York)", + section: .advanced + ), ], category: .analytical, tagline: String(localized: "Distributed SQL query engine for data lakes") diff --git a/docs/databases/trino.mdx b/docs/databases/trino.mdx index 770411863..a9639658d 100644 --- a/docs/databases/trino.mdx +++ b/docs/databases/trino.mdx @@ -23,13 +23,14 @@ Trino is a downloadable driver. Open **Settings > Plugins**, find Trino, and ins | Auth Method | `Username & Password` for LDAP or password-file auth, or `JWT Access Token` for a bearer token. | | Password | Your password when the auth method is Username & Password. Sent as HTTP Basic auth. | | Access Token | Your JWT when the auth method is JWT Access Token. Sent as a bearer token. | -| SSL | Off by default. Turn it on to connect over HTTPS. Password and JWT auth both require it. | +| SSL | Off by default. The SSL pane sets the mode: Required encrypts without checking the certificate, Verify CA checks the certificate chain, and Verify Identity also checks the hostname. Point CA Certificate at your CA file to trust a private authority. | +| Time Zone | Optional IANA time zone (for example `America/New_York`) for the session. Leave blank to use the server default. | -Trino requires TLS for any authentication. If you pick Password or JWT, turn SSL on so the coordinator accepts the credentials. +Trino requires TLS for any authentication. If you pick Password or JWT, set an SSL mode so the coordinator accepts the credentials. ## Catalogs, schemas, and tables -A Trino connection is scoped to a catalog and schema, but every query can name objects in full as `catalog.schema.table`. TablePro shows catalogs from `SHOW CATALOGS`, schemas from `SHOW SCHEMAS`, and tables and columns from each catalog's `information_schema`. Expand a catalog to see its schemas, then a schema to see its tables. +A Trino connection is scoped to a catalog and schema, but every query can name objects in full as `catalog.schema.table`. TablePro shows catalogs from `SHOW CATALOGS`, schemas from `SHOW SCHEMAS`, and tables and columns from each catalog's `information_schema`. Materialized views appear alongside tables and views. Expand a catalog to see its schemas, then a schema to see its tables. Table row counts come from `SHOW STATS`, and table and column comments come from `information_schema` and the `system.metadata` tables. Identifiers are quoted with double quotes. Results page with standard `LIMIT` and `OFFSET`. @@ -43,9 +44,13 @@ Trino's type system maps to TablePro's grid as follows: - `json` opens in the JSON viewer. - `timestamp`, `time`, and their `with time zone` forms keep the value the server returned. -## Writes +## Editing rows and schema -The first release is read-focused. `SELECT` and schema browsing work everywhere. `INSERT`, `UPDATE`, `DELETE`, and DDL run through the same path, but whether they succeed depends on the connected catalog. Many Trino connectors are read-only or support only part of SQL, so a valid statement can still return `NOT_SUPPORTED` from the connector. +Edit cells in the data grid and TablePro writes `INSERT`, `UPDATE`, and `DELETE` back, keyed on the primary key when the connector reports one, or on the row's other column values otherwise. Values are written with their Trino type, so a `varchar` holding digits stays quoted and a number stays unquoted. Columns whose type cannot be compared with `=`, such as `array`, `map`, and `row`, are left out of the WHERE clause. + +The Structure tab creates tables and adds, drops, renames, and retypes columns. Statements run in autocommit, matching how most Trino connectors handle DDL. + +Whether a write succeeds depends on the connected catalog. Many connectors are read-only or support only part of SQL, so a valid statement can still return `NOT_SUPPORTED` from the connector. Changing a column's type in particular is only supported by a few connectors. ## Authentication From 5e284fe66a114c1ddcf783a322420251203d1b37 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 22:52:26 +0700 Subject: [PATCH 4/5] feat(plugin-trino): add mutual TLS client certificate authentication --- CHANGELOG.md | 2 +- .../TableProTrinoCore/TrinoClientConfig.swift | 11 +- .../TableProTrinoCore/TrinoTransport.swift | 110 +++++++++++++++++- .../TrinoDriverPlugin/TrinoSSLMapping.swift | 13 ++- docs/databases/trino.mdx | 1 + 5 files changed, 130 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eae5bb44a..6bdcfef35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Trino support through a downloadable native Swift driver. Connect over the HTTP client protocol with a username and password or a JWT token, browse catalogs, schemas, tables, materialized views, and columns, run SQL and EXPLAIN, edit rows, and create or alter tables. TLS supports Verify CA and Verify Identity with a custom CA certificate. Large exports stream page by page. (#1906) +- Trino support through a downloadable native Swift driver. Connect over the HTTP client protocol with a username and password or a JWT token, browse catalogs, schemas, tables, materialized views, and columns, run SQL and EXPLAIN, edit rows, and create or alter tables. TLS supports Verify CA and Verify Identity with a custom CA certificate, and client certificates for mutual TLS. Large exports stream page by page. (#1906) ## [0.58.0] - 2026-07-18 diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift index af4206943..ed0273955 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift @@ -15,10 +15,19 @@ public struct TrinoTLSOptions: Sendable, Equatable { public var mode: VerificationMode public var caCertificatePath: String + public var clientCertificatePath: String + public var clientKeyPath: String - public init(mode: VerificationMode = .full, caCertificatePath: String = "") { + public init( + mode: VerificationMode = .full, + caCertificatePath: String = "", + clientCertificatePath: String = "", + clientKeyPath: String = "" + ) { self.mode = mode self.caCertificatePath = caCertificatePath + self.clientCertificatePath = clientCertificatePath + self.clientKeyPath = clientKeyPath } public static let systemDefault = TrinoTLSOptions(mode: .full) diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift index 37eb6f9da..0efb260cc 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift @@ -140,8 +140,21 @@ private final class TrinoTLSDelegate: NSObject, URLSessionDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let serverTrust = challenge.protectionSpace.serverTrust else { + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + handleServerTrust(challenge, completionHandler: completionHandler) + case NSURLAuthenticationMethodClientCertificate: + handleClientCertificate(completionHandler: completionHandler) + default: + completionHandler(.performDefaultHandling, nil) + } + } + + private func handleServerTrust( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let serverTrust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil) return } @@ -174,4 +187,97 @@ private final class TrinoTLSDelegate: NSObject, URLSessionDelegate { completionHandler(.cancelAuthenticationChallenge, nil) } } + + private func handleClientCertificate( + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard !tls.clientCertificatePath.isEmpty, !tls.clientKeyPath.isEmpty else { + completionHandler(.performDefaultHandling, nil) + return + } + guard let p12Data = Self.buildPkcs12(certPath: tls.clientCertificatePath, keyPath: tls.clientKeyPath) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + var items: CFArray? + let status = SecPKCS12Import( + p12Data as CFData, + [kSecImportExportPassphrase as String: ""] as CFDictionary, + &items + ) + guard status == errSecSuccess, + let itemArray = items as? [[String: Any]], + let identityRef = itemArray.first?[kSecImportItemIdentity as String], + CFGetTypeID(identityRef as CFTypeRef) == SecIdentityGetTypeID() else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + // swiftlint:disable:next force_cast + let identity = identityRef as! SecIdentity + completionHandler(.useCredential, URLCredential(identity: identity, certificates: nil, persistence: .forSession)) + } + + private static func buildPkcs12(certPath: String, keyPath: String) -> Data? { + guard let certData = try? Data(contentsOf: URL(fileURLWithPath: certPath)), + let keyData = try? Data(contentsOf: URL(fileURLWithPath: keyPath)) else { + return nil + } + var certItems: CFArray? + var certFormat = SecExternalFormat.formatPEMSequence + var certType = SecExternalItemType.itemTypeCertificate + let certStatus = SecItemImport(certData as CFData, nil, &certFormat, &certType, [], nil, nil, &certItems) + guard certStatus == errSecSuccess, let certs = certItems as? [SecCertificate], let cert = certs.first else { + return nil + } + var keyItems: CFArray? + var keyFormat = SecExternalFormat.formatPEMSequence + var keyType = SecExternalItemType.itemTypePrivateKey + let keyStatus = SecItemImport(keyData as CFData, nil, &keyFormat, &keyType, [], nil, nil, &keyItems) + guard keyStatus == errSecSuccess, let keys = keyItems as? [SecKey], let privateKey = keys.first else { + return nil + } + guard let identity = createIdentity(certificate: cert, privateKey: privateKey) else { + return nil + } + var exportParams = SecItemImportExportKeyParameters() + var exported: CFData? + guard SecItemExport(identity, .formatPKCS12, [], &exportParams, &exported) == errSecSuccess, + let data = exported else { + return nil + } + return data as Data + } + + private static func createIdentity(certificate: SecCertificate, privateKey: SecKey) -> SecIdentity? { + var certRef: CFTypeRef? + let certAddStatus = SecItemAdd([ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecReturnRef as String: true + ] as CFDictionary, &certRef) + + var keyRef: CFTypeRef? + let keyAddStatus = SecItemAdd([ + kSecClass as String: kSecClassKey, + kSecValueRef as String: privateKey, + kSecReturnRef as String: true + ] as CFDictionary, &keyRef) + + var identity: SecIdentity? + let status = SecIdentityCreateWithCertificate(nil, certificate, &identity) + + if certAddStatus == errSecSuccess { + SecItemDelete([ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certRef ?? certificate + ] as CFDictionary) + } + if keyAddStatus == errSecSuccess { + SecItemDelete([ + kSecClass as String: kSecClassKey, + kSecValueRef as String: keyRef ?? privateKey + ] as CFDictionary) + } + return status == errSecSuccess ? identity : nil + } } diff --git a/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift index a483ebe77..de4b9d604 100644 --- a/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift +++ b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift @@ -4,13 +4,20 @@ import TableProTrinoCore enum TrinoSSLMapping { static func tlsOptions(for ssl: SSLConfiguration) -> TrinoTLSOptions { + let mode: TrinoTLSOptions.VerificationMode switch ssl.mode { case .disabled, .preferred, .required: - return TrinoTLSOptions(mode: .insecure) + mode = .insecure case .verifyCa: - return TrinoTLSOptions(mode: .caOnly, caCertificatePath: ssl.caCertificatePath) + mode = .caOnly case .verifyIdentity: - return TrinoTLSOptions(mode: .full, caCertificatePath: ssl.caCertificatePath) + mode = .full } + return TrinoTLSOptions( + mode: mode, + caCertificatePath: ssl.caCertificatePath, + clientCertificatePath: ssl.clientCertificatePath, + clientKeyPath: ssl.clientKeyPath + ) } } diff --git a/docs/databases/trino.mdx b/docs/databases/trino.mdx index a9639658d..8606a0f76 100644 --- a/docs/databases/trino.mdx +++ b/docs/databases/trino.mdx @@ -56,6 +56,7 @@ Whether a write succeeds depends on the connected catalog. Many connectors are r - **Username & Password** sends HTTP Basic auth. Use it for LDAP or password-file authentication. Requires TLS. - **JWT Access Token** sends the token as a bearer credential. Requires TLS. +- **Client certificate** works with any SSL mode: set the Client Certificate and Client Key files in the SSL pane and TablePro presents them for mutual TLS. Use it for Trino's certificate authentication. Kerberos and OAuth 2.0 are not yet supported. From 05aa6286e3b067c263d0850f09762192acfac29c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 19 Jul 2026 22:04:21 +0700 Subject: [PATCH 5/5] test(plugin-trino): count Trino in the allKnownTypes assertion --- .../Tests/TableProModelsTests/DatabaseTypeTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index 3ef2fab6e..97e55b829 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -53,7 +53,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 21) + #expect(DatabaseType.allKnownTypes.count == 22) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) @@ -61,6 +61,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.beancount)) #expect(DatabaseType.allKnownTypes.contains(.surrealdb)) #expect(DatabaseType.allKnownTypes.contains(.teradata)) + #expect(DatabaseType.allKnownTypes.contains(.trino)) } @Test("Hashable conformance")