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 2cfc64703..b0f427df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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) - SSH tunnels can authenticate with no password or key, for hosts that handle SSH auth themselves like Tailscale SSH. Pick **None** as the SSH auth method. (#1907) ## [0.58.0] - 2026-07-18 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..ed0273955 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift @@ -0,0 +1,104 @@ +import Foundation + +public enum TrinoAuth: Sendable, Equatable { + case none + case basic(password: String) + 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 var clientCertificatePath: String + public var clientKeyPath: 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) + 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 tls: TrinoTLSOptions + 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, + tls: TrinoTLSOptions = .systemDefault, + 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.tls = tls + 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/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/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..9c0a00ef7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift @@ -0,0 +1,82 @@ +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, comment, 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, comment, ordinal_position \ + FROM \(quoteIdentifier(catalog)).information_schema.columns \ + WHERE table_schema = \(quoteLiteral(schema)) ORDER BY table_name, ordinal_position + """ + } + + public static func tableComment(catalog: String, schema: String, table: String) -> String { + """ + 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))" + } + + 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/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/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/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/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..193e24ad4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift @@ -0,0 +1,297 @@ +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 + 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 { + 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 + } + guard let statementURL = config.statementURL else { + throw TrinoError.invalidConfiguration("Invalid Trino server URL") + } + + 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 { + throw TrinoError.query(error) + } + + var columns = results.columns + 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 + 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 + } + if !columnsEmitted, let columns { + onColumns(columns) + columnsEmitted = true + } + emitRows(results, columns: columns, onPage: onPage) + if let type = results.updateType { + updateType = type + } + if let count = results.updateCount { + updateCount = count + } + nextUri = results.nextUri + } + + lock.withLock { _currentNextUri = nil } + return StatementOutcome(updateType: updateType, updateCount: updateCount, queryId: queryId) + } + + 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) + for (index, value) in row.enumerated() { + let category = index < columns.count ? columns[index].category : .scalar + decoded.append(TrinoValueDecoder.decode(value, category: category)) + } + page.append(decoded) + } + onPage(page) + } + + private func descriptors(from columns: [TrinoColumn]) -> [TrinoColumnDescriptor] { + 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/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 new file mode 100644 index 000000000..0efb260cc --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTransport.swift @@ -0,0 +1,283 @@ +import Foundation +import Security + +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, @unchecked Sendable { + private let session: URLSession + + public init(tls: TrinoTLSOptions) { + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let delegateProxy = TrinoTLSDelegate(tls: tls) + 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 TrinoTLSDelegate: NSObject, URLSessionDelegate { + private let tls: TrinoTLSOptions + + init(tls: TrinoTLSOptions) { + self.tls = tls + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + 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 + } + + 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) + } + } + + 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/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/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") diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift new file mode 100644 index 000000000..eea42e28b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift @@ -0,0 +1,75 @@ +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("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"), + "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/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/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/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/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/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/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..deabbce87 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -0,0 +1,150 @@ +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 = true + 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 = 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, .comment] + 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 + ), + ConnectionField( + id: "trinoTimeZone", + label: String(localized: "Time Zone"), + placeholder: "Optional (e.g. America/New_York)", + section: .advanced + ), + ] + + 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)"), + 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 { + TrinoPluginDriver(config: config) + } +} 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 new file mode 100644 index 000000000..e16b7bff4 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift @@ -0,0 +1,164 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +extension TrinoPluginDriver { + var currentCatalog: String? { + session.catalog + } + + func resolveSchema(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + return session.schema + } + + 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)" + } + + 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) } + } + + 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)) + 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 + 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] { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return [] } + let result = try await execute( + query: TrinoIntrospectionSQL.listColumns(catalog: catalog, schema: targetSchema, table: table) + ) + 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.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] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + 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: "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 { + 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 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 new file mode 100644 index 000000000..7a5fc59d3 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift @@ -0,0 +1,199 @@ +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? + private var _columnTypes: [String: [String: 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, .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 } + 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(tls: clientConfig.tls) + 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() + + 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 { + 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, + 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) + ) + } + + 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/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift new file mode 100644 index 000000000..de4b9d604 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift @@ -0,0 +1,23 @@ +import Foundation +import TableProPluginKit +import TableProTrinoCore + +enum TrinoSSLMapping { + static func tlsOptions(for ssl: SSLConfiguration) -> TrinoTLSOptions { + let mode: TrinoTLSOptions.VerificationMode + switch ssl.mode { + case .disabled, .preferred, .required: + mode = .insecure + case .verifyCa: + mode = .caOnly + case .verifyIdentity: + mode = .full + } + return TrinoTLSOptions( + mode: mode, + caCertificatePath: ssl.caCertificatePath, + clientCertificatePath: ssl.clientCertificatePath, + clientKeyPath: ssl.clientKeyPath + ) + } +} 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/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/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 0a6eff2f0..5cda9f147 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -316,6 +316,131 @@ extension PluginMetadataRegistry { tagline: String(localized: "Teradata Vantage data warehouse") ) )), + ("Trino", PluginMetadataSnapshot( + displayName: "Trino", iconName: "trino-icon", defaultPort: 8_080, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "trino", parameterStyle: .questionMark, + 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", + 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, .comment] + ), + 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 + ), + 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") + ) + )), ("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/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 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..8606a0f76 --- /dev/null +++ b/docs/databases/trino.mdx @@ -0,0 +1,67 @@ +--- +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. 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, 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`. 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`. + +## 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. + +## Editing rows and schema + +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 + +- **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. + +## 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=(