diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 077ae3506..9706d0821 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -178,6 +178,11 @@ jobs: DISPLAY_NAME="MSSQL Driver"; SUMMARY="Microsoft SQL Server driver via FreeTDS" DB_TYPE_IDS='["SQL Server"]'; ICON="mssql-icon"; BUNDLE_NAME="MSSQLDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/mssql" ;; + teradata) + TARGET="TeradataDriver"; BUNDLE_ID="com.TablePro.TeradataDriver" + 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" ;; 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 0b709c4ed..1f0294f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Teradata support through a downloadable driver written in native Swift. Connect over TD2 or TDNEGO logon, optionally with TLS, browse databases, tables, and columns, run SQL, edit rows, and create or alter tables. (#1867) + ### Fixed - Fixed SSH tunnels that could accept a database connection and then go quiet instead of forwarding it or failing, which showed up as MySQL and MariaDB connections timing out while reading the server greeting. A tunnel that cannot open its forwarding channel now gives up after 10 seconds, closes the connection, and logs the reason, instead of leaving the driver to wait out its own timeout with no explanation. (#1883) diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 07d76a7ad..74387b1fc 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -17,7 +17,8 @@ let package = Package( .library(name: "TableProQuery", targets: ["TableProQuery"]), .library(name: "TableProSync", targets: ["TableProSync"]), .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]), - .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]) + .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]), + .library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]) ], targets: [ .target( @@ -66,6 +67,11 @@ let package = Package( dependencies: [], path: "Sources/TableProMSSQLCore" ), + .target( + name: "TableProTeradataCore", + dependencies: [], + path: "Sources/TableProTeradataCore" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], @@ -96,6 +102,11 @@ let package = Package( dependencies: ["TableProMSSQLCore"], path: "Tests/TableProMSSQLCoreTests" ), + .testTarget( + name: "TableProTeradataCoreTests", + dependencies: ["TableProTeradataCore"], + path: "Tests/TableProTeradataCoreTests" + ), .testTarget( name: "TableProSyncTests", dependencies: ["TableProSync", "TableProModels"], diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 4380e88f0..ad591a9c6 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -32,12 +32,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let scylladb = DatabaseType(rawValue: "ScyllaDB") public static let turso = DatabaseType(rawValue: "Turso") public static let surrealdb = DatabaseType(rawValue: "SurrealDB") + public static let teradata = DatabaseType(rawValue: "Teradata") 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 + .surrealdb, .teradata ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -63,6 +64,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .libsql: return "libsql-icon" case .beancount: return "beancount-icon" case .surrealdb: return "surrealdb-icon" + case .teradata: return "teradata-icon" default: return "externaldrive" } } diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/BigUInt.swift b/Packages/TableProCore/Sources/TableProTeradataCore/BigUInt.swift new file mode 100644 index 000000000..ebf95911b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/BigUInt.swift @@ -0,0 +1,236 @@ +import Foundation + +struct BigUInt: Equatable, CustomStringConvertible { + private(set) var limbs: [UInt32] + + init() { limbs = [] } + + init(_ value: UInt32) { limbs = value == 0 ? [] : [value] } + + init(limbs: [UInt32]) { + var normalized = limbs + while normalized.last == 0 { normalized.removeLast() } + self.limbs = normalized + } + + init(bytesBE bytes: [UInt8]) { + var built: [UInt32] = [] + var index = bytes.count + while index > 0 { + var limb: UInt32 = 0 + var shift: UInt32 = 0 + var taken = 0 + while taken < 4 && index > 0 { + index -= 1 + limb |= UInt32(bytes[index]) << shift + shift += 8 + taken += 1 + } + built.append(limb) + } + self.init(limbs: built) + } + + init?(hex: String) { + var trimmed = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex + if trimmed.count % 2 == 1 { trimmed = "0" + trimmed } + var bytes: [UInt8] = [] + var cursor = trimmed.startIndex + while cursor < trimmed.endIndex { + let next = trimmed.index(cursor, offsetBy: 2) + guard let byte = UInt8(trimmed[cursor.. [UInt8] { + let count = max(byteCount, minCount) + guard count > 0 else { return [] } + var out = [UInt8]() + out.reserveCapacity(count) + for byteIndex in stride(from: count - 1, through: 0, by: -1) { + let limbIndex = byteIndex / 4 + let shift = UInt32((byteIndex % 4) * 8) + let limb = limbIndex < limbs.count ? limbs[limbIndex] : 0 + out.append(UInt8((limb >> shift) & 0xFF)) + } + return out + } + + func bit(_ position: Int) -> Bool { + let limbIndex = position / 32 + guard limbIndex < limbs.count else { return false } + return (limbs[limbIndex] >> UInt32(position % 32)) & 1 == 1 + } + + var description: String { + isZero ? "0" : bytesBE().map { String(format: "%02x", $0) }.joined() + } + + static func compare(_ lhs: [UInt32], _ rhs: [UInt32]) -> Int { + let count = max(lhs.count, rhs.count) + for i in stride(from: count - 1, through: 0, by: -1) { + let left = i < lhs.count ? lhs[i] : 0 + let right = i < rhs.count ? rhs[i] : 0 + if left != right { return left < right ? -1 : 1 } + } + return 0 + } + + func shiftedLeftOneBit() -> BigUInt { + var out = [UInt32]() + out.reserveCapacity(limbs.count + 1) + var carry: UInt32 = 0 + for limb in limbs { + out.append((limb << 1) | carry) + carry = limb >> 31 + } + if carry != 0 { out.append(carry) } + return BigUInt(limbs: out) + } + + func settingLowBit() -> BigUInt { + var out = limbs + if out.isEmpty { out = [1] } else { out[0] |= 1 } + return BigUInt(limbs: out) + } + + func subtracting(_ other: BigUInt) -> BigUInt { + var out = [UInt32]() + out.reserveCapacity(limbs.count) + var borrow: UInt64 = 0 + for i in 0..= b { + out.append(UInt32(a - b)) + borrow = 0 + } else { + out.append(UInt32(a + 0x1_0000_0000 - b)) + borrow = 1 + } + } + return BigUInt(limbs: out) + } + + func mod(_ modulus: BigUInt) -> BigUInt { + precondition(!modulus.isZero, "modulo by zero") + var remainder = BigUInt() + for position in stride(from: bitLength - 1, through: 0, by: -1) { + remainder = remainder.shiftedLeftOneBit() + if bit(position) { remainder = remainder.settingLowBit() } + if BigUInt.compare(remainder.limbs, modulus.limbs) >= 0 { + remainder = remainder.subtracting(modulus) + } + } + return remainder + } + + func modPow(_ exponent: BigUInt, modulus: BigUInt) -> BigUInt { + precondition(!modulus.isZero, "modulo by zero") + if modulus.limbs == [1] { return BigUInt() } + precondition(modulus.limbs[0] & 1 == 1, "Montgomery modPow requires an odd modulus") + if exponent.isZero { return BigUInt(1).mod(modulus) } + + let size = modulus.limbs.count + let n = BigUInt.padded(modulus.limbs, to: size) + let nInv = BigUInt.montgomeryInverse(n[0]) + + let rSquaredLimbs = [UInt32](repeating: 0, count: 2 * size) + [1] + let rSquared = BigUInt.padded(BigUInt(limbs: rSquaredLimbs).mod(modulus).limbs, to: size) + + let baseMont = BigUInt.montgomeryMultiply( + BigUInt.padded(mod(modulus).limbs, to: size), rSquared, n, nInv, size) + var accumulator = BigUInt.montgomeryMultiply( + BigUInt.padded([1], to: size), rSquared, n, nInv, size) + + for position in stride(from: exponent.bitLength - 1, through: 0, by: -1) { + accumulator = BigUInt.montgomeryMultiply(accumulator, accumulator, n, nInv, size) + if exponent.bit(position) { + accumulator = BigUInt.montgomeryMultiply(accumulator, baseMont, n, nInv, size) + } + } + + let result = BigUInt.montgomeryMultiply( + accumulator, BigUInt.padded([1], to: size), n, nInv, size) + return BigUInt(limbs: result) + } + + private static func padded(_ limbs: [UInt32], to size: Int) -> [UInt32] { + if limbs.count >= size { return Array(limbs[0.. UInt32 { + var inverse: UInt32 = 1 + for _ in 0..<5 { inverse = inverse &* (2 &- n0 &* inverse) } + return 0 &- inverse + } + + private static func montgomeryMultiply( + _ a: [UInt32], _ b: [UInt32], _ n: [UInt32], _ nInv: UInt32, _ size: Int + ) -> [UInt32] { + var t = [UInt32](repeating: 0, count: size + 2) + for i in 0..> 32 + } + let sum = UInt64(t[size]) + carry + t[size] = UInt32(truncatingIfNeeded: sum) + t[size + 1] = UInt32(sum >> 32) + + let m = UInt64(UInt32(truncatingIfNeeded: UInt64(t[0]) &* UInt64(nInv))) + var reduceCarry = (UInt64(t[0]) + m * UInt64(n[0])) >> 32 + for j in 1..> 32 + } + let tail = UInt64(t[size]) + reduceCarry + t[size - 1] = UInt32(truncatingIfNeeded: tail) + t[size] = t[size + 1] &+ UInt32(tail >> 32) + } + + var result = Array(t[0..= 0 { + var borrow: UInt64 = 0 + for j in 0..= b { + result[j] = UInt32(a - b) + borrow = 0 + } else { + result[j] = UInt32(a + 0x1_0000_0000 - b) + borrow = 1 + } + } + } + return result + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift b/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift new file mode 100644 index 000000000..8d7b3e65a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift @@ -0,0 +1,89 @@ +import Foundation + +enum HexBytes { + static func decode(_ hex: String) -> [UInt8] { + let clean = hex.unicodeScalars.filter { $0 != " " && $0 != "\n" && $0 != "\t" } + let chars = Array(String.UnicodeScalarView(clean)) + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var index = 0 + while index + 1 < chars.count { + let pair = String(chars[index]) + String(chars[index + 1]) + bytes.append(UInt8(pair, radix: 16) ?? 0) + index += 2 + } + return bytes + } +} + +struct ByteWriter { + private(set) var bytes: [UInt8] = [] + + mutating func u8(_ value: UInt8) { bytes.append(value) } + + mutating func u16(_ value: UInt16) { + bytes.append(UInt8(value >> 8)) + bytes.append(UInt8(value & 0xFF)) + } + + mutating func u32(_ value: UInt32) { + bytes.append(UInt8((value >> 24) & 0xFF)) + bytes.append(UInt8((value >> 16) & 0xFF)) + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + } + + mutating func raw(_ data: [UInt8]) { bytes.append(contentsOf: data) } + + mutating func zeros(_ count: Int) { bytes.append(contentsOf: [UInt8](repeating: 0, count: count)) } + + mutating func fixedString(_ text: String, length: Int, pad: UInt8 = 0x20) { + var encoded = Array(text.utf8.prefix(length)) + if encoded.count < length { + encoded.append(contentsOf: [UInt8](repeating: pad, count: length - encoded.count)) + } + bytes.append(contentsOf: encoded) + } +} + +struct ByteReader { + private let bytes: [UInt8] + private(set) var offset: Int + + init(_ bytes: [UInt8], offset: Int = 0) { + self.bytes = bytes + self.offset = offset + } + + var remaining: Int { bytes.count - offset } + + mutating func u8() throws -> UInt8 { + guard remaining >= 1 else { throw TeradataWireError.truncated("u8") } + defer { offset += 1 } + return bytes[offset] + } + + mutating func u16() throws -> UInt16 { + guard remaining >= 2 else { throw TeradataWireError.truncated("u16") } + defer { offset += 2 } + return UInt16(bytes[offset]) << 8 | UInt16(bytes[offset + 1]) + } + + mutating func u32() throws -> UInt32 { + guard remaining >= 4 else { throw TeradataWireError.truncated("u32") } + defer { offset += 4 } + return UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16 + | UInt32(bytes[offset + 2]) << 8 | UInt32(bytes[offset + 3]) + } + + mutating func take(_ count: Int) throws -> [UInt8] { + guard remaining >= count else { throw TeradataWireError.truncated("take(\(count))") } + defer { offset += count } + return Array(bytes[offset..= count else { throw TeradataWireError.truncated("skip(\(count))") } + offset += count + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Der.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Der.swift new file mode 100644 index 000000000..f321f96cf --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Der.swift @@ -0,0 +1,71 @@ +import Foundation + +enum Der { + static let objectIdentifierTag: UInt8 = 0x06 + + static func encodeLength(_ length: Int) -> [UInt8] { + if length < 0x80 { return [UInt8(length)] } + var value = length + var lengthBytes: [UInt8] = [] + while value > 0 { + lengthBytes.insert(UInt8(value & 0xFF), at: 0) + value >>= 8 + } + return [0x80 | UInt8(lengthBytes.count)] + lengthBytes + } + + static func encodeTLV(tag: UInt8, value: [UInt8]) -> [UInt8] { + [tag] + encodeLength(value.count) + value + } + + static func encodeObjectIdentifier(_ dotted: String) throws -> [UInt8] { + let parts = dotted.split(separator: ".").map { Int($0) ?? -1 } + guard parts.count >= 2, !parts.contains(where: { $0 < 0 }) else { + throw TeradataWireError.malformed("invalid OID \(dotted)") + } + var content: [UInt8] = [UInt8(parts[0] * 40 + parts[1])] + for component in parts.dropFirst(2) { + content.append(contentsOf: base128(component)) + } + return encodeTLV(tag: objectIdentifierTag, value: content) + } + + static func decodeObjectIdentifier(_ bytes: [UInt8]) throws -> String { + var reader = ByteReader(bytes) + guard try reader.u8() == objectIdentifierTag else { throw TeradataWireError.malformed("expected OID tag") } + let length = try decodeLength(&reader) + let content = try reader.take(length) + guard let first = content.first else { throw TeradataWireError.malformed("empty OID") } + var components = [Int(first) / 40, Int(first) % 40] + var accumulator = 0 + for byte in content.dropFirst() { + accumulator = (accumulator << 7) | Int(byte & 0x7F) + if byte & 0x80 == 0 { + components.append(accumulator) + accumulator = 0 + } + } + return components.map(String.init).joined(separator: ".") + } + + static func decodeLength(_ reader: inout ByteReader) throws -> Int { + let first = try reader.u8() + if first < 0x80 { return Int(first) } + let count = Int(first & 0x7F) + var length = 0 + for _ in 0.. [UInt8] { + guard value >= 0x80 else { return [UInt8(value)] } + var chunks: [UInt8] = [] + var remaining = value + while remaining > 0 { + chunks.insert(UInt8(remaining & 0x7F), at: 0) + remaining >>= 7 + } + for index in 0..<(chunks.count - 1) { chunks[index] |= 0x80 } + return chunks + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/DiffieHellman.swift b/Packages/TableProCore/Sources/TableProTeradataCore/DiffieHellman.swift new file mode 100644 index 000000000..302505fa7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/DiffieHellman.swift @@ -0,0 +1,51 @@ +import Foundation +import Security + +struct DiffieHellman { + let prime: BigUInt + let generator: BigUInt + let modulusByteCount: Int + let privateExponent: BigUInt + let publicKey: BigUInt + + init(primeBytes: [UInt8], generatorBytes: [UInt8], privateExponentBytes: [UInt8]? = nil) { + prime = BigUInt(bytesBE: primeBytes) + generator = BigUInt(bytesBE: generatorBytes) + modulusByteCount = prime.byteCount + let exponentBytes = privateExponentBytes ?? DiffieHellman.randomBytes(64) + privateExponent = BigUInt(bytesBE: exponentBytes) + publicKey = generator.modPow(privateExponent, modulus: prime) + } + + func publicKeyBytes() -> [UInt8] { + publicKey.bytesBE(minCount: modulusByteCount) + } + + func sharedSecret(peerPublicKeyBytes: [UInt8]) -> [UInt8] { + let peer = BigUInt(bytesBE: peerPublicKeyBytes) + return peer.modPow(privateExponent, modulus: prime).bytesBE(minCount: modulusByteCount) + } + + func masterKeyNormalizeTemp(peerPublicKeyBytes: [UInt8]) -> [UInt8] { + let peer = BigUInt(bytesBE: peerPublicKeyBytes) + var magnitude = peer.modPow(privateExponent, modulus: prime).bytesBE() + if magnitude.count < modulusByteCount { + magnitude += [UInt8](repeating: 0, count: modulusByteCount - magnitude.count) + } else if magnitude.count > modulusByteCount { + magnitude = Array(magnitude.suffix(modulusByteCount)) + } + return magnitude + } + + static func deriveKey(fromSharedSecret secret: [UInt8], offset: Int, length: Int) -> [UInt8] { + guard offset + length <= secret.count else { return [] } + return Array(secret[offset.. [UInt8] { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)") + return bytes + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift b/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift new file mode 100644 index 000000000..26b599e62 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift @@ -0,0 +1,36 @@ +import CryptoKit +import Foundation + +struct GcmCipher { + static let nonceLength = 12 + static let tagLength = 16 + + let key: SymmetricKey + + init(keyBytes: [UInt8]) { + key = SymmetricKey(data: Data(keyBytes)) + } + + func seal(_ plaintext: [UInt8], nonce nonceBytes: [UInt8], aad: [UInt8] = []) throws -> (ciphertext: [UInt8], tag: [UInt8]) { + guard nonceBytes.count == GcmCipher.nonceLength else { + throw TeradataWireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") + } + let nonce = try AES.GCM.Nonce(data: Data(nonceBytes)) + let sealed = try AES.GCM.seal(Data(plaintext), using: key, nonce: nonce, authenticating: Data(aad)) + return (Array(sealed.ciphertext), Array(sealed.tag)) + } + + func open(ciphertext: [UInt8], nonce nonceBytes: [UInt8], tag: [UInt8], aad: [UInt8] = []) throws -> [UInt8] { + guard nonceBytes.count == GcmCipher.nonceLength else { + throw TeradataWireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") + } + guard tag.count == GcmCipher.tagLength else { + throw TeradataWireError.malformed("GCM tag must be \(GcmCipher.tagLength) bytes") + } + let box = try AES.GCM.SealedBox( + nonce: try AES.GCM.Nonce(data: Data(nonceBytes)), + ciphertext: Data(ciphertext), + tag: Data(tag)) + return Array(try AES.GCM.open(box, using: key, authenticating: Data(aad))) + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/LanMessage.swift b/Packages/TableProCore/Sources/TableProTeradataCore/LanMessage.swift new file mode 100644 index 000000000..acf94a8c3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/LanMessage.swift @@ -0,0 +1,124 @@ +import Foundation + +enum MessageKind: UInt8 { + case assign = 1 + case reassign = 2 + case connect = 3 + case reconnect = 4 + case start = 5 + case cont = 6 + case abort = 7 + case logoff = 8 + case test = 9 + case config = 10 + case authMethods = 11 + case sso = 12 + case elicit = 13 +} + +struct LanMessage { + static let headerLength = 52 + static let versionByte: UInt8 = 3 + static let requestClass: UInt8 = 1 + static let responseClass: UInt8 = 2 + static let encryptedBodyFlag: UInt8 = 0x80 + static let charsetUnnegotiated: UInt8 = 0xFF + + var messageClass: UInt8 + var kind: UInt8 + var byteVar: UInt8 + var correlationTag: UInt32 + var sessionNumber: UInt32 + var authentication: [UInt8] + var requestNumber: UInt32 + var unionGTW: UInt8 + var hostCharSet: UInt8 + var body: [UInt8] + + var isBodyEncrypted: Bool { messageClass & LanMessage.encryptedBodyFlag != 0 } + var trueClass: UInt8 { messageClass & 0x7F } + var inTransaction: Bool { byteVar & 0x01 != 0 } + + init( + kind: MessageKind, + body: [UInt8], + sessionNumber: UInt32 = 0, + requestNumber: UInt32 = 0, + correlationTag: UInt32 = 0, + byteVar: UInt8 = 7, + authentication: [UInt8] = [UInt8](repeating: 0, count: 8), + hostCharSet: UInt8 = LanMessage.charsetUnnegotiated, + unionGTW: UInt8 = 0, + encrypted: Bool = false + ) { + self.messageClass = LanMessage.requestClass | (encrypted ? LanMessage.encryptedBodyFlag : 0) + self.kind = kind.rawValue + self.byteVar = byteVar + self.correlationTag = correlationTag + self.sessionNumber = sessionNumber + self.authentication = authentication + self.requestNumber = requestNumber + self.unionGTW = unionGTW + self.hostCharSet = hostCharSet + self.body = body + } + + private init(header: [UInt8], body: [UInt8]) { + messageClass = header[1] + kind = header[2] + byteVar = header[5] + correlationTag = LanMessage.readU32(header, 16) + sessionNumber = LanMessage.readU32(header, 20) + authentication = Array(header[24..<32]) + requestNumber = LanMessage.readU32(header, 32) + unionGTW = header[36] + hostCharSet = header[37] + self.body = body + } + + func encoded() -> [UInt8] { + let bodyLength = UInt32(body.count) + var header = [UInt8](repeating: 0, count: LanMessage.headerLength) + header[0] = LanMessage.versionByte + header[1] = messageClass + header[2] = kind + header[3] = UInt8((bodyLength >> 24) & 0xFF) + header[4] = UInt8((bodyLength >> 16) & 0xFF) + header[5] = byteVar + header[8] = UInt8((bodyLength >> 8) & 0xFF) + header[9] = UInt8(bodyLength & 0xFF) + LanMessage.writeU32(&header, 16, correlationTag) + LanMessage.writeU32(&header, 20, sessionNumber) + for i in 0..<8 { header[24 + i] = i < authentication.count ? authentication[i] : 0 } + LanMessage.writeU32(&header, 32, requestNumber) + header[36] = unionGTW + header[37] = hostCharSet + return header + body + } + + static func bodyLength(fromHeader header: [UInt8]) -> Int { + let high = Int(readU16(header, 3)) + let low = Int(readU16(header, 8)) + return (high << 16) | low + } + + static func decode(header: [UInt8], body: [UInt8]) -> LanMessage { + LanMessage(header: header, body: body) + } + + private static func readU16(_ bytes: [UInt8], _ at: Int) -> UInt16 { + UInt16(bytes[at]) << 8 | UInt16(bytes[at + 1]) + } + + private static func readU32(_ bytes: [UInt8], _ at: Int) -> UInt32 { + UInt32(bytes[at]) << 24 | UInt32(bytes[at + 1]) << 16 + | UInt32(bytes[at + 2]) << 8 | UInt32(bytes[at + 3]) + } + + private static func writeU32(_ bytes: inout [UInt8], _ at: Int, _ value: UInt32) { + bytes[at] = UInt8((value >> 24) & 0xFF) + bytes[at + 1] = UInt8((value >> 16) & 0xFF) + bytes[at + 2] = UInt8((value >> 8) & 0xFF) + bytes[at + 3] = UInt8(value & 0xFF) + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift new file mode 100644 index 000000000..e6cef5e0e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift @@ -0,0 +1,101 @@ +import Foundation + +enum ParcelFlavor: UInt16 { + case request = 1 + case response = 4 + case success = 8 + case failure = 9 + case record = 10 + case endStatement = 11 + case endRequest = 12 + case logoff = 37 + case config = 42 + case configRsp = 43 + case error = 49 + case dataInfo = 71 + case prepInfo = 86 + case indicRequest = 69 + case options = 85 + case connect = 88 + case lsn = 89 + case assign = 100 + case assignRsp = 101 + case prepInfoX = 125 + case ssoResponse = 134 + case dataInfoX = 146 + case bigResponse = 153 + case errorInfo = 164 + case gtwConfig = 165 + case clientConfig = 166 + case authMech = 167 + case statementInfo = 169 + case statementInfoEnd = 170 + case statementError = 192 + case statementStatus = 205 +} + +struct Parcel { + static let alternateFlag: UInt16 = 0x8000 + static let standardHeaderLength = 4 + static let alternateHeaderLength = 8 + static let standardMaxTotal = 0xFFFF + + var flavor: UInt16 + var body: [UInt8] + + init(flavor: UInt16, body: [UInt8] = []) { + self.flavor = flavor + self.body = body + } + + init(_ flavor: ParcelFlavor, body: [UInt8] = []) { + self.init(flavor: flavor.rawValue, body: body) + } + + var knownFlavor: ParcelFlavor? { ParcelFlavor(rawValue: flavor) } + + func encoded() -> [UInt8] { + let standardTotal = Parcel.standardHeaderLength + body.count + if standardTotal <= Parcel.standardMaxTotal { + var writer = ByteWriter() + writer.u16(flavor) + writer.u16(UInt16(standardTotal)) + writer.raw(body) + return writer.bytes + } + let total = UInt32(Parcel.alternateHeaderLength + body.count) + var writer = ByteWriter() + writer.u16(flavor | Parcel.alternateFlag) + writer.u16(0) + writer.u32(total) + writer.raw(body) + return writer.bytes + } + + static func encodeAll(_ parcels: [Parcel]) -> [UInt8] { + parcels.flatMap { $0.encoded() } + } + + static func decodeAll(_ bytes: [UInt8]) throws -> [Parcel] { + var reader = ByteReader(bytes) + var parcels: [Parcel] = [] + while reader.remaining > 0 { + let rawFlavor = try reader.u16() + let total: Int + let flavor: UInt16 + if rawFlavor & alternateFlag != 0 { + flavor = rawFlavor ^ alternateFlag + _ = try reader.u16() + total = Int(try reader.u32()) + guard total >= alternateHeaderLength else { throw TeradataWireError.malformed("APH length \(total)") } + parcels.append(Parcel(flavor: flavor, body: try reader.take(total - alternateHeaderLength))) + } else { + flavor = rawFlavor + total = Int(try reader.u16()) + guard total >= standardHeaderLength else { throw TeradataWireError.malformed("parcel length \(total)") } + parcels.append(Parcel(flavor: flavor, body: try reader.take(total - standardHeaderLength))) + } + } + return parcels + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift new file mode 100644 index 000000000..145cc9b48 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift @@ -0,0 +1,119 @@ +import Foundation + +enum RecordDecoder { + static func nullBitmapLength(columnCount: Int) -> Int { + (columnCount + 7) / 8 + } + + static func isNull(_ bitmap: [UInt8], column: Int) -> Bool { + let byteIndex = column / 8 + guard byteIndex < bitmap.count else { return false } + return bitmap[byteIndex] & (0x80 >> UInt8(column % 8)) != 0 + } + + static func decode(recordBody: [UInt8], columns: [ColumnMeta]) throws -> [TeradataValue] { + var reader = ByteReader(recordBody) + let bitmap = try reader.take(nullBitmapLength(columnCount: columns.count)) + var values: [TeradataValue] = [] + values.reserveCapacity(columns.count) + for (index, column) in columns.enumerated() { + let raw = try readFieldBytes(&reader, column: column) + if isNull(bitmap, column: index) { + values.append(.null) + } else { + values.append(try interpret(raw, column: column)) + } + } + return values + } + + private static func readFieldBytes(_ reader: inout ByteReader, column: ColumnMeta) throws -> [UInt8] { + switch column.baseCode { + case TeradataType.byteint: return try reader.take(1) + case TeradataType.smallint: return try reader.take(2) + case TeradataType.integer, TeradataType.dateInteger: return try reader.take(4) + case TeradataType.bigint, TeradataType.float: return try reader.take(8) + case TeradataType.char, TeradataType.byte: return try reader.take(column.dataLength) + case TeradataType.varchar, TeradataType.longVarchar, TeradataType.varbyte: + let length = Int(try reader.u16()) + return try reader.take(length) + case TeradataType.decimal: + return try reader.take(decimalByteWidth(precision: column.dataLength >> 8)) + default: + throw TeradataWireError.unsupported("record field type \(column.baseCode)") + } + } + + static func decimalByteWidth(precision: Int) -> Int { + switch precision { + case ...2: return 1 + case 3...4: return 2 + case 5...9: return 4 + case 10...18: return 8 + default: return 16 + } + } + + static func decimalString(_ bytes: [UInt8], scale: Int) -> String { + let negative = (bytes.first ?? 0) & 0x80 != 0 + var magnitude = bytes + if negative { + var carry = 1 + for index in stride(from: magnitude.count - 1, through: 0, by: -1) { + let value = Int(~magnitude[index] & 0xFF) + carry + magnitude[index] = UInt8(value & 0xFF) + carry = value >> 8 + } + } + var digits = base256ToDecimal(magnitude) + if scale > 0 { + while digits.count <= scale { digits = "0" + digits } + let dot = digits.index(digits.endIndex, offsetBy: -scale) + digits = digits[.. String { + var value = bytes + var result = "" + while value.contains(where: { $0 != 0 }) { + var remainder = 0 + for index in value.indices { + let accumulator = remainder * 256 + Int(value[index]) + value[index] = UInt8(accumulator / 10) + remainder = accumulator % 10 + } + result = String(remainder) + result + } + return result.isEmpty ? "0" : result + } + + private static func interpret(_ bytes: [UInt8], column: ColumnMeta) throws -> TeradataValue { + switch column.baseCode { + case TeradataType.byteint, TeradataType.smallint, TeradataType.integer, TeradataType.bigint: + return .integer(TeradataType.signedInteger(bytes)) + case TeradataType.float: + var pattern: UInt64 = 0 + for byte in bytes { pattern = (pattern << 8) | UInt64(byte) } + return .double(Double(bitPattern: pattern)) + case TeradataType.char, TeradataType.varchar, TeradataType.longVarchar: + let text = String(decoding: bytes, as: UTF8.self) + return .text(column.baseCode == TeradataType.char + ? String(text.reversed().drop { $0 == " " }.reversed()) + : text) + case TeradataType.decimal: + return .text(decimalString(bytes, scale: column.dataLength & 0xFF)) + case TeradataType.byte, TeradataType.varbyte: + return .bytes(bytes) + case TeradataType.dateInteger: + let packed = TeradataType.signedInteger(bytes) + let year = 1900 + Int(packed / 10000) + let month = Int((packed / 100) % 100) + let day = Int(packed % 100) + return .text(String(format: "%04d-%02d-%02d", year, month, day)) + default: + throw TeradataWireError.unsupported("record field type \(column.baseCode)") + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift new file mode 100644 index 000000000..08c68bdca --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift @@ -0,0 +1,92 @@ +import Foundation + +enum Td2Token { + static let keyDataOffset = 80 + + static let clientInitToken: [UInt8] = HexBytes.decode(""" + 01 01 01 00 00 00 00 9D 00 00 00 15 02 00 00 00 + 14 00 00 3A 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 5D 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + E1 5B E2 15 D0 01 02 D3 01 01 D4 01 04 D5 02 00 + 80 D5 02 00 C0 D5 02 01 00 E2 03 D1 01 04 E2 03 + D1 01 06 E2 03 D1 01 07 E2 07 D2 01 05 D6 02 08 + 00 E2 09 D0 01 02 D3 01 07 D4 01 04 E2 09 D0 01 + 02 D3 01 05 D4 01 04 E2 09 D0 01 02 D3 01 08 D4 + 01 04 E2 09 D0 01 02 D3 01 09 D4 01 04 06 0D 2B + 06 01 04 01 81 3F 01 87 74 01 01 09 46 08 00 02 + 81 00 04 04 04 00 01 00 00 00 1F 01 + """) + + struct ServerParams { + let peerCapabilities: UInt32 + let verifyDHKey: UInt32 + let prime: [UInt8] + let generator: [UInt8] + let serverPublicKey: [UInt8] + let qopDer: [UInt8] + } + + static func be32(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16 + | UInt32(bytes[offset + 2]) << 8 | UInt32(bytes[offset + 3]) + } + + static func parseServerParams(_ token: [UInt8]) throws -> ServerParams { + guard token.count >= keyDataOffset else { throw TeradataWireError.truncated("server TD2 token") } + let version = token[0] + let msgType = token[1] + guard version == 1 || version == 3 else { throw TeradataWireError.malformed("TD2 version \(version)") } + guard msgType == 1 || msgType == 2 else { throw TeradataWireError.malformed("TD2 msgType \(msgType)") } + let peerCapabilities = be32(token, 8) + guard token[16] >= 6 else { throw TeradataWireError.malformed("TD2 lib version \(token[16])") } + let nN = Int(be32(token, 20)) + let nG = Int(be32(token, 24)) + let nY = Int(be32(token, 28)) + let verifyDHKey = be32(token, 32) + let nQ = (peerCapabilities & 4) != 0 ? Int(be32(token, 36)) : 0 + + var cursor = keyDataOffset + func slice(_ length: Int, _ what: String) throws -> [UInt8] { + guard cursor + length <= token.count else { throw TeradataWireError.truncated("TD2 \(what)") } + defer { cursor += length } + return Array(token[cursor.. 0 ? try slice(nQ, "qopDer") : [] + + return ServerParams( + peerCapabilities: peerCapabilities, verifyDHKey: verifyDHKey, + prime: prime, generator: generator, serverPublicKey: serverPublicKey, qopDer: qopDer) + } + + static func qopKeyLengthBytes(_ qopDer: [UInt8]) -> Int? { + var index = 0 + while index + 4 <= qopDer.count { + if qopDer[index] == 0xD5, qopDer[index + 1] == 0x02 { + let bits = Int(qopDer[index + 2]) << 8 | Int(qopDer[index + 3]) + return bits > 0 ? bits / 8 : nil + } + index += 1 + } + return nil + } + + static func buildResponseToken(clientPublicKey: [UInt8]) -> [UInt8] { + let length = UInt32(clientPublicKey.count) + var header = [UInt8](repeating: 0, count: 16) + header[0] = 0x03 + header[1] = 0x01 + header[2] = 0x02 + header[3] = 0x00 + header[4] = UInt8((length >> 24) & 0xFF) + header[5] = UInt8((length >> 16) & 0xFF) + header[6] = UInt8((length >> 8) & 0xFF) + header[7] = UInt8(length & 0xFF) + header[12] = 0x02 + return header + clientPublicKey + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift new file mode 100644 index 000000000..85108c9f9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift @@ -0,0 +1,115 @@ +import Foundation + +enum Td2Wrap { + static let macLen = 32 + static let tagLen = 16 + + private static func integerBytes(_ value: UInt64) -> [UInt8] { + if value == 0 { return [0x00] } + var v = value + var bytes = [UInt8]() + while v > 0 { bytes.insert(UInt8(v & 0xFF), at: 0); v >>= 8 } + if bytes[0] & 0x80 != 0 { bytes.insert(0, at: 0) } + return bytes + } + + private static func tlv(_ tag: UInt8, _ value: [UInt8]) -> [UInt8] { + [tag] + Der.encodeLength(value.count) + value + } + + static func wrap(plaintext: [UInt8], key: [UInt8], sequenceNumber: UInt64) throws -> [UInt8] { + let plen = plaintext.count + let msgLength = UInt64(plen + 48 + macLen) + + var nonce = [UInt8]() + nonce.append(UInt8((msgLength >> 24) & 0xFF)) + nonce.append(UInt8((msgLength >> 16) & 0xFF)) + nonce.append(UInt8((msgLength >> 8) & 0xFF)) + nonce.append(UInt8(msgLength & 0xFF)) + for shift in stride(from: 56, through: 0, by: -8) { + nonce.append(UInt8((sequenceNumber >> UInt64(shift)) & 0xFF)) + } + + let buffer = plaintext + nonce + [0, 0, 0, 0] + let sealed = try GcmCipher(keyBytes: key).seal(buffer, nonce: nonce) + let ciphertext = sealed.ciphertext + let authTag = sealed.tag + + let tokenHeader = + [0xC0, 0x01, 0x03] + + [0xC1, 0x01, 0x07] + + [0xC2, 0x01, 0x04] + + [0xC3, 0x04, 0x00, 0x00, 0x00, 0x00] + + tlv(0xC4, integerBytes(msgLength)) + + tlv(0xC5, integerBytes(sequenceNumber)) + + let body = tlv(0xC0, ciphertext) + tlv(0xE1, tokenHeader) + tlv(0xC3, authTag) + return tlv(0xE0, body) + } + + static func unwrap(der: [UInt8], key: [UInt8]) throws -> [UInt8] { + var reader = ByteReader(der) + guard try reader.u8() == 0xE0 else { throw TeradataWireError.malformed("wrap: expected E0") } + _ = try Der.decodeLength(&reader) + + var ciphertext: [UInt8] = [] + var authTag: [UInt8] = [] + var msgLength: UInt64 = 0 + var sequenceNumber: UInt64 = 0 + + while reader.remaining > 0 { + let tag = try reader.u8() + let length = try Der.decodeLength(&reader) + let value = try reader.take(length) + switch tag { + case 0xC0: ciphertext = value + case 0xC3: authTag = value + case 0xE1: + var inner = ByteReader(value) + while inner.remaining > 0 { + let innerTag = try inner.u8() + let innerLen = try Der.decodeLength(&inner) + let innerValue = try inner.take(innerLen) + if innerTag == 0xC4 { msgLength = beInteger(innerValue) } + if innerTag == 0xC5 { sequenceNumber = beInteger(innerValue) } + } + default: break + } + } + + guard !ciphertext.isEmpty, authTag.count == tagLen else { + throw TeradataWireError.malformed("wrap: missing ciphertext/tag") + } + var nonce = [UInt8]() + nonce.append(UInt8((msgLength >> 24) & 0xFF)) + nonce.append(UInt8((msgLength >> 16) & 0xFF)) + nonce.append(UInt8((msgLength >> 8) & 0xFF)) + nonce.append(UInt8(msgLength & 0xFF)) + for shift in stride(from: 56, through: 0, by: -8) { + nonce.append(UInt8((sequenceNumber >> UInt64(shift)) & 0xFF)) + } + let buffer = try GcmCipher(keyBytes: key).open(ciphertext: ciphertext, nonce: nonce, tag: authTag) + guard buffer.count >= tagLen else { throw TeradataWireError.malformed("wrap: short plaintext") } + return Array(buffer[0.. UInt64 { + var value: UInt64 = 0 + for byte in bytes { value = (value << 8) | UInt64(byte) } + return value + } + + static func encryptMessage(_ message: [UInt8], key: [UInt8], sequenceNumber: UInt64) throws -> [UInt8] { + let clear = Array(message[0..<24]) + let plaintext = Array(message[24...]) + let der = try wrap(plaintext: plaintext, key: key, sequenceNumber: sequenceNumber) + var out = clear + der + let bodyLength = der.count - 28 + out[1] |= LanMessage.encryptedBodyFlag + out[3] = UInt8((bodyLength >> 24) & 0xFF) + out[4] = UInt8((bodyLength >> 16) & 0xFF) + out[8] = UInt8((bodyLength >> 8) & 0xFF) + out[9] = UInt8(bodyLength & 0xFF) + return out + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift new file mode 100644 index 000000000..155fe1406 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift @@ -0,0 +1,105 @@ +import Foundation + +public enum TeradataTypeCategory: Sendable { + case numeric + case text + case temporal + case binary + case interval + case largeObject + case other +} + +public enum TeradataColumnType { + public static func displayName( + dbcColumnType: String, length: Int, totalDigits: Int, fractionalDigits: Int + ) -> String { + let code = dbcColumnType.trimmingCharacters(in: .whitespaces).uppercased() + switch code { + case "I1": return "BYTEINT" + case "I2": return "SMALLINT" + case "I": return "INTEGER" + case "I8": return "BIGINT" + case "F": return "FLOAT" + case "D": return "DECIMAL(\(totalDigits),\(fractionalDigits))" + case "N": return totalDigits > 0 ? "NUMBER(\(totalDigits),\(fractionalDigits))" : "NUMBER" + case "DA": return "DATE" + case "AT": return "TIME" + case "TS": return "TIMESTAMP" + case "TZ": return "TIME WITH TIME ZONE" + case "SZ": return "TIMESTAMP WITH TIME ZONE" + case "CF": return "CHAR(\(length))" + case "CV": return "VARCHAR(\(length))" + case "CO": return "CLOB" + case "BF": return "BYTE(\(length))" + case "BV": return "VARBYTE(\(length))" + case "BO": return "BLOB" + case "JN": return "JSON" + case "XM": return "XML" + case "UT": return "UDT" + case "PD": return "PERIOD(DATE)" + case "PT": return "PERIOD(TIME)" + case "PS": return "PERIOD(TIMESTAMP)" + case "YR": return "INTERVAL YEAR" + case "YM": return "INTERVAL YEAR TO MONTH" + case "MO": return "INTERVAL MONTH" + case "DY": return "INTERVAL DAY" + case "DH": return "INTERVAL DAY TO HOUR" + case "DM": return "INTERVAL DAY TO MINUTE" + case "DS": return "INTERVAL DAY TO SECOND" + case "HR": return "INTERVAL HOUR" + case "HM": return "INTERVAL HOUR TO MINUTE" + case "HS": return "INTERVAL HOUR TO SECOND" + case "MI": return "INTERVAL MINUTE" + case "MS": return "INTERVAL MINUTE TO SECOND" + case "SC": return "INTERVAL SECOND" + default: return code.isEmpty ? "UNKNOWN" : code + } + } + + public static func wireTypeName(_ code: UInt16) -> String { + switch code & 0xFFFE { + case TeradataType.byteint: return "BYTEINT" + case TeradataType.smallint: return "SMALLINT" + case TeradataType.integer: return "INTEGER" + case TeradataType.bigint: return "BIGINT" + case TeradataType.float: return "FLOAT" + case TeradataType.decimal: return "DECIMAL" + case 604: return "NUMBER" + case TeradataType.char: return "CHAR" + case TeradataType.varchar: return "VARCHAR" + case TeradataType.longVarchar: return "LONG VARCHAR" + case TeradataType.byte: return "BYTE" + case TeradataType.varbyte: return "VARBYTE" + case TeradataType.dateInteger, TeradataType.dateAnsi: return "DATE" + case 760: return "TIME" + case 764: return "TIMESTAMP" + case 768: return "TIME WITH TIME ZONE" + case 772: return "TIMESTAMP WITH TIME ZONE" + case 400: return "BLOB" + case 416: return "CLOB" + case 880: return "JSON" + default: return "VARCHAR" + } + } + + public static func category(wireTypeCode: UInt16) -> TeradataTypeCategory { + switch wireTypeCode & 0xFFFE { + case TeradataType.integer, TeradataType.smallint, TeradataType.bigint, + TeradataType.byteint, TeradataType.float, TeradataType.decimal, 604: + return .numeric + case TeradataType.char, TeradataType.varchar, TeradataType.longVarchar: + return .text + case TeradataType.dateInteger, TeradataType.dateAnsi, 760, 764, 768, 772: + return .temporal + case TeradataType.byte, TeradataType.varbyte: + return .binary + case 400, 416, 880, 852: + return .largeObject + case 776, 780, 784, 788, 792, 796, 800, 804, 808, 812, 816, 820, 824: + return .interval + default: + return .other + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift new file mode 100644 index 000000000..a31cbacdd --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift @@ -0,0 +1,276 @@ +import Darwin +import Foundation + +public final class TeradataConnection { + private let config: TeradataConnectionConfig + private var socket: (any TeradataTransport)? + private var sessionNumber: UInt32 = 0 + private var aesKey: [UInt8] = [] + private var charsetCode: UInt8 = 0xBF + private var serverIP = "0.0.0.0" + private var messageCounter: UInt64 = 0 + private var requestCounter: UInt64 = 0 + private var gssSequence: UInt64 = 0 + private var confidentialityBypassed = false + + public init(config: TeradataConnectionConfig) { + self.config = config + } + + public var isConnected: Bool { socket != nil } + + public func connect() throws { + try validateLogMech() + let socket = try makeTransport() + self.socket = socket + serverIP = Self.resolveAddress(config.host) ?? config.host + + try runConfig() + let serverToken = try runAssign() + let params = try Td2Token.parseServerParams(serverToken) + try runKeyExchange(params) + try runConnect() + + if let database = config.database, !database.isEmpty { + _ = try execute(TeradataSchemaQueries.setDatabase(database)) + } + } + + public func execute(_ sql: String) throws -> TeradataResultSet { + guard socket != nil else { throw TeradataWireError.connectionFailed("not connected") } + let requestNumber = nextRequest() + let body = TeradataMessages.optionsParcel(returnStatementInfo: false).encoded() + + TeradataMessages.requestParcel(sql: sql).encoded() + + TeradataMessages.responseParcel().encoded() + try sendEncrypted(kind: .start, body: body, requestNumber: requestNumber) + + var parcels: [Parcel] = [] + var responseComplete = false + while !responseComplete { + let batch = try readReply() + parcels.append(contentsOf: batch) + if Self.responseIsComplete(batch) { + responseComplete = true + } else { + try sendContinue(requestNumber: requestNumber) + } + } + return try TeradataResultParser.parse(parcels) + } + + public func disconnect() { + guard let socket else { return } + if sessionNumber != 0 { + let message = LanMessage( + kind: .logoff, body: TeradataMessages.logoffParcel().encoded(), + sessionNumber: sessionNumber, byteVar: 0, + authentication: TeradataMessages.authBytes(nextMessage()), hostCharSet: charsetCode) + try? socket.send(message.encoded()) + } + socket.close() + self.socket = nil + } + + public func cancel() { + socket?.cancel() + } + + static func responseIsComplete(_ parcels: [Parcel]) -> Bool { + let terminators: Set = [ + ParcelFlavor.endRequest.rawValue, + ParcelFlavor.failure.rawValue, + ParcelFlavor.error.rawValue, + ParcelFlavor.statementError.rawValue, + ] + return parcels.contains { terminators.contains($0.flavor) } + } + + private func makeTransport() throws -> any TeradataTransport { + guard config.tls.enabled else { + return try TeradataSocket( + host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) + } + do { + let transport = try TeradataTLSTransport( + host: config.host, options: config.tls, + timeoutSeconds: config.connectTimeoutSeconds) + confidentialityBypassed = true + return transport + } catch { + guard config.tls.allowPlaintextFallback else { throw error } + return try TeradataSocket( + host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) + } + } + + private func validateLogMech() throws { + switch config.logMech { + case .td2, .tdnego: + return + case .ldap, .krb5, .jwt: + throw TeradataWireError.unsupported( + "logon mechanism \(config.logMech.rawValue); only TD2 and TDNEGO are supported") + } + } + + private func runConfig() throws { + let message = LanMessage( + kind: .config, body: TeradataMessages.clientConfigParcel().encoded(), + byteVar: 7, authentication: TeradataMessages.authBytes(nextMessage()), + hostCharSet: LanMessage.charsetUnnegotiated) + try socket?.send(message.encoded()) + let parcels = try readClearReply() + try throwIfError(parcels) + if let config = parcels.first(where: { $0.flavor == ParcelFlavor.configRsp.rawValue }) { + charsetCode = TeradataMessages.charsetCode(fromConfigResponse: config.body, name: "UTF8") + ?? TeradataMessages.charsetCode(fromConfigResponse: config.body, name: "ASCII") + ?? 0xBF + } + } + + private func runAssign() throws -> [UInt8] { + let sso = TeradataMessages.ssoRequestParcel(method: 0, trip: 0, token: Td2Token.clientInitToken) + let assign = TeradataMessages.assignParcel(username: config.username) + let message = LanMessage( + kind: .assign, body: sso.encoded() + assign.encoded(), + sessionNumber: 0, byteVar: 7, + authentication: TeradataMessages.authBytes(nextMessage()), hostCharSet: charsetCode) + let reply = try readFramed(after: message) + sessionNumber = reply.sessionNumber + let parcels = try Parcel.decodeAll(reply.body) + try throwIfError(parcels) + guard let sso = parcels.first(where: { $0.flavor == 134 }), sso.body.count >= 6 else { + throw TeradataWireError.malformed("assign reply missing SSOResponse") + } + let length = Int(sso.body[4]) << 8 | Int(sso.body[5]) + return Array(sso.body[6.. [Parcel] { + guard let socket else { throw TeradataWireError.connectionFailed("not connected") } + let header = try socket.receive(LanMessage.headerLength) + let bodyLength = LanMessage.bodyLength(fromHeader: header) + let rest = try socket.receive(bodyLength) + if header[1] & LanMessage.encryptedBodyFlag != 0 { + let der = Array((header + rest)[24...]) + let plaintext = try Td2Wrap.unwrap(der: der, key: aesKey) + guard plaintext.count >= 28 else { + throw TeradataWireError.truncated("encrypted reply body \(plaintext.count) < 28") + } + return try Parcel.decodeAll(Array(plaintext[28...])) + } + return try Parcel.decodeAll(rest) + } + + private func readClearReply() throws -> [Parcel] { + guard let socket else { throw TeradataWireError.connectionFailed("not connected") } + let header = try socket.receive(LanMessage.headerLength) + let rest = try socket.receive(LanMessage.bodyLength(fromHeader: header)) + return try Parcel.decodeAll(rest) + } + + private func readFramed(after message: LanMessage) throws -> LanMessage { + guard let socket else { throw TeradataWireError.connectionFailed("not connected") } + try socket.send(message.encoded()) + let header = try socket.receive(LanMessage.headerLength) + let rest = try socket.receive(LanMessage.bodyLength(fromHeader: header)) + return LanMessage.decode(header: header, body: rest) + } + + private func throwIfError(_ parcels: [Parcel]) throws { + for parcel in parcels where parcel.flavor == ParcelFlavor.failure.rawValue + || parcel.flavor == ParcelFlavor.error.rawValue { + let (code, message) = TeradataResultParser.errorDetail(parcel) + throw TeradataWireError.server(code: code, message: message) + } + } + + private func nextMessage() -> UInt64 { + messageCounter += 1 + return messageCounter + } + + private func nextRequest() -> UInt32 { + requestCounter += 1 + return UInt32(truncatingIfNeeded: requestCounter) + } + + private func nextGssSequence() -> UInt64 { + gssSequence += 1 + return gssSequence + } + + private static func resolveAddress(_ host: String) -> String? { + var hints = addrinfo( + ai_flags: 0, ai_family: AF_INET, ai_socktype: SOCK_STREAM, + ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0, let addr = result?.pointee.ai_addr else { + return nil + } + defer { freeaddrinfo(result) } + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + let sockaddrIn = addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + var inAddr = sockaddrIn.sin_addr + guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer) + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift new file mode 100644 index 000000000..08c5e1e10 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift @@ -0,0 +1,114 @@ +import Foundation + +public enum TeradataLogMech: String, Sendable { + case td2 = "TD2" + case ldap = "LDAP" + case krb5 = "KRB5" + case jwt = "JWT" + case tdnego = "TDNEGO" +} + +public enum TeradataTransactionMode: String, Sendable { + case `default` = "DEFAULT" + case ansi = "ANSI" + case tera = "TERA" + + var semanticsByte: UInt8 { + switch self { + case .default: return 0x44 + case .ansi: return 0x41 + case .tera: return 0x54 + } + } +} + +public struct TeradataTLSOptions: Sendable { + public var enabled: Bool + public var allowPlaintextFallback: Bool + public var verifiesCertificate: Bool + public var verifiesHostname: Bool + public var caCertificatePath: String + public var modeLabel: String + public var httpsPort: UInt16 + public var webSocketPath: String + + public init( + enabled: Bool = false, + allowPlaintextFallback: Bool = false, + verifiesCertificate: Bool = false, + verifiesHostname: Bool = false, + caCertificatePath: String = "", + modeLabel: String = "DISABLE", + httpsPort: UInt16 = 443, + webSocketPath: String = "/gateway" + ) { + self.enabled = enabled + self.allowPlaintextFallback = allowPlaintextFallback + self.verifiesCertificate = verifiesCertificate + self.verifiesHostname = verifiesHostname + self.caCertificatePath = caCertificatePath + self.modeLabel = modeLabel + self.httpsPort = httpsPort + self.webSocketPath = webSocketPath + } + + public static let disabled = TeradataTLSOptions() +} + +public struct TeradataConnectionConfig: Sendable { + public var host: String + public var port: UInt16 + public var username: String + public var password: String + public var database: String? + public var account: String? + public var logMech: TeradataLogMech + public var transactionMode: TeradataTransactionMode + public var tls: TeradataTLSOptions + public var connectTimeoutSeconds: Int + + public init( + host: String, + port: UInt16 = 1025, + username: String, + password: String, + database: String? = nil, + account: String? = nil, + logMech: TeradataLogMech = .td2, + transactionMode: TeradataTransactionMode = .default, + tls: TeradataTLSOptions = .disabled, + connectTimeoutSeconds: Int = 20 + ) { + self.host = host + self.port = port + self.username = username + self.password = password + self.database = database + self.account = account + self.logMech = logMech + self.transactionMode = transactionMode + self.tls = tls + self.connectTimeoutSeconds = connectTimeoutSeconds + } +} + +public struct TeradataColumn: Sendable { + public let name: String + public let typeCode: UInt16 + public let dataLength: Int + + public var baseTypeCode: UInt16 { typeCode & 0xFFFE } + public var isNullable: Bool { typeCode & 1 == 1 } +} + +public struct TeradataResultSet: Sendable { + public let columns: [TeradataColumn] + public let rows: [[TeradataValue]] + public let activityCount: Int + + public init(columns: [TeradataColumn], rows: [[TeradataValue]], activityCount: Int) { + self.columns = columns + self.rows = rows + self.activityCount = activityCount + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift new file mode 100644 index 000000000..9a21a815c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift @@ -0,0 +1,139 @@ +import Foundation + +enum TeradataMessages { + static func authBytes(_ value: UInt64) -> [UInt8] { + (0..<8).map { UInt8((value >> UInt64(8 * (7 - $0))) & 0xFF) } + } + + static func clientConfigParcel() -> Parcel { + var writer = ByteWriter() + writer.u32(1) + writer.u16(2); writer.u16(4); writer.raw([0x14, 0x00, 0x00, 0x00]) + let ttu = Array("TTU 20.00.00".utf8) + writer.u16(1); writer.u16(UInt16(ttu.count)); writer.raw(ttu) + writer.u16(4); writer.u16(0) + writer.u16(8); writer.u16(1); writer.u8(1) + writer.u16(3); writer.u16(0) + writer.u16(5); writer.u16(0) + writer.u16(9); writer.u16(1); writer.u8(1) + writer.u16(11); writer.u16(1); writer.u8(1) + writer.u16(14); writer.u16(0) + writer.u16(15); writer.u16(0) + return Parcel(.clientConfig, body: writer.bytes) + } + + static func ssoRequestParcel(method: UInt8, trip: UInt8, token: [UInt8]) -> Parcel { + var writer = ByteWriter() + writer.u8(method) + writer.u8(trip) + writer.u16(UInt16(token.count)) + writer.raw(token) + return Parcel(flavor: 132, body: writer.bytes) + } + + static func assignParcel(username: String) -> Parcel { + var field = Array(username.utf8.prefix(32)) + field.append(contentsOf: [UInt8](repeating: 0x20, count: 32 - field.count)) + return Parcel(.assign, body: field) + } + + static func logonParcel(username: String, password: String, account: String?) -> Parcel { + var text = quoteDouble(username) + if !password.isEmpty || account?.isEmpty == false { text += "," } + if !password.isEmpty { text += quoteDouble(password) } + if let account, !account.isEmpty { text += "," + quoteSingle(account) } + return Parcel(flavor: 36, body: Array(text.utf8)) + } + + static func sessionOptionsParcel(semantics: UInt8, essEnabled: Bool) -> Parcel { + let essFlag: UInt8 = essEnabled ? 0x45 : 0 + let essLevel: UInt8 = essEnabled ? 0x01 : 0 + return Parcel(flavor: 114, body: [semantics, 0x4E, 0x4E, 0x44, essFlag, 0x31, 0, 0, essLevel, 0]) + } + + static func connectParcel(partition: String) -> Parcel { + var body = Array(partition.utf8.prefix(16)) + body.append(contentsOf: [UInt8](repeating: 0x20, count: 16 - body.count)) + body.append(contentsOf: [0, 0, 0, 0, 0, 0, 0, 0]) + return Parcel(.connect, body: body) + } + + static func logonDataParcel(host: String, port: UInt16, username: String) -> Parcel { + let source = "\(host);127.0.0.1:\(port) CID=tablepro \(username) TeraPro 01 LSS" + return Parcel(flavor: 3, body: Array(source.utf8)) + } + + static func clientAttributesParcel( + username: String, session: UInt32, charset: UInt8, + serverIP: String, logMech: String, transactionMode: String, sslMode: String, + encryptData: Bool = true, database: String? + ) -> Parcel { + var writer = ByteWriter() + func stringAttribute(_ code: UInt16, _ value: String) { + let bytes = Array(value.utf8) + writer.u16(code) + writer.u16(UInt16(1 + bytes.count)) + writer.u8(charset) + writer.raw(bytes) + } + func portAttribute(_ code: UInt16, _ port: UInt16) { + writer.u16(code); writer.u16(2); writer.u16(port) + } + stringAttribute(7, "127.0.0.1") + stringAttribute(8, "1") + stringAttribute(9, username) + stringAttribute(10, "TablePro") + stringAttribute(11, "macOS") + stringAttribute(22, "Swift macOS") + stringAttribute(16, "macOS CryptoKit") + stringAttribute(25, "C=N;") + stringAttribute(28, "P") + stringAttribute(29, "20.0.0.63") + let ess = "BA=N;CCS=UTF8;CERT=U;CF=0;DP=1025;ENC=\(encryptData ? "Y" : "N");ES=\(session);" + + "LM=\(logMech);LOB=Y;PART=DBC/SQL;SCS=UTF8;SIP=Y;SSLM=\(sslMode);TM=\(transactionMode);TVD=plain;" + stringAttribute(30, ess) + stringAttribute(31, "127.0.0.1") + portAttribute(32, 50000) + stringAttribute(33, serverIP) + portAttribute(34, 1025) + writer.u16(58); writer.u16(1); writer.u8(2) + writer.u16(0x7FFF) + writer.u16(0) + return Parcel(flavor: 189, body: writer.bytes) + } + + static func optionsParcel(returnStatementInfo: Bool) -> Parcel { + Parcel(flavor: 85, body: [0x49, 0x42, 0, 0, 0, returnStatementInfo ? 0x59 : 0, 0, 0, 0, 0]) + } + + static func requestParcel(sql: String) -> Parcel { + Parcel(flavor: 69, body: Array(sql.utf8)) + } + + static func responseParcel(maxMessageSize: UInt16 = 0xFE50) -> Parcel { + Parcel(.response, body: [UInt8(maxMessageSize >> 8), UInt8(maxMessageSize & 0xFF)]) + } + + static func logoffParcel() -> Parcel { + Parcel(.logoff) + } + + static func charsetCode(fromConfigResponse body: [UInt8], name: String) -> UInt8? { + let needle = Array(name.utf8) + guard !needle.isEmpty else { return nil } + var index = 0 + while index + needle.count <= body.count { + if Array(body[index..= 2 { return body[index - 2] } + index += 1 + } + return nil + } + + private static func quoteDouble(_ value: String) -> String { + "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + private static func quoteSingle(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "''") + "'" + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift new file mode 100644 index 000000000..18ea3d0d9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift @@ -0,0 +1,84 @@ +import Foundation + +enum TeradataResultParser { + static func parse(_ parcels: [Parcel]) throws -> TeradataResultSet { + for parcel in parcels where parcel.flavor == ParcelFlavor.failure.rawValue + || parcel.flavor == ParcelFlavor.error.rawValue + || parcel.flavor == ParcelFlavor.statementError.rawValue { + let (code, message) = errorDetail(parcel) + throw TeradataWireError.server(code: code, message: message) + } + + let activityCount = parcels + .first { $0.flavor == ParcelFlavor.success.rawValue } + .map { readActivityCount(fromSuccess: $0.body) } ?? 0 + + var columns: [ColumnMeta] = [] + if let prepInfoX = parcels.first(where: { $0.flavor == ParcelFlavor.prepInfoX.rawValue }) { + columns = try parsePrepInfo(prepInfoX.body, extended: true) + } else if let prepInfo = parcels.first(where: { $0.flavor == ParcelFlavor.prepInfo.rawValue }) { + columns = try parsePrepInfo(prepInfo.body, extended: false) + } + + var rows: [[TeradataValue]] = [] + if !columns.isEmpty { + for record in parcels where record.flavor == ParcelFlavor.record.rawValue { + rows.append(try RecordDecoder.decode(recordBody: record.body, columns: columns)) + } + } + + let resultColumns = columns.map { + TeradataColumn(name: $0.name, typeCode: $0.typeCode, dataLength: $0.dataLength) + } + return TeradataResultSet(columns: resultColumns, rows: rows, activityCount: activityCount) + } + + static func errorDetail(_ parcel: Parcel) -> (code: Int, message: String) { + let body = parcel.body + guard body.count >= 12 else { return (0, String(decoding: body, as: UTF8.self)) } + let code = Int(body[8]) << 8 | Int(body[9]) + let messageLength = Int(body[10]) << 8 | Int(body[11]) + let end = min(12 + messageLength, body.count) + let message = String(decoding: body[12.. Int { + guard body.count >= 6 else { return 0 } + return Int(body[2]) << 24 | Int(body[3]) << 16 | Int(body[4]) << 8 | Int(body[5]) + } + + private static func parsePrepInfo(_ body: [UInt8], extended: Bool) throws -> [ColumnMeta] { + var reader = ByteReader(body) + _ = try reader.take(8) + let summaryCount = Int(try reader.u16()) + let columnCount = Int(try reader.u16()) + var items: [ColumnMeta] = [] + for _ in 0..<(summaryCount + columnCount) { + let dataType = try reader.u16() + let dataLength: Int + if extended { + let raw = try reader.take(8) + dataLength = Int(TeradataType.signedInteger(raw)) + _ = try reader.u8() + _ = try reader.u8() + } else { + dataLength = Int(try reader.u16()) + } + let name = try readShortString(&reader) + _ = try readShortString(&reader) + let title = try readShortString(&reader) + items.append(ColumnMeta( + typeCode: dataType, dataLength: dataLength, name: name.isEmpty ? title : name)) + } + guard items.count >= columnCount else { return items } + return Array(items.suffix(columnCount)) + } + + private static func readShortString(_ reader: inout ByteReader) throws -> String { + let length = Int(try reader.u16()) + guard length > 0 else { return "" } + return String(decoding: try reader.take(length), as: UTF8.self) + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift new file mode 100644 index 000000000..7a1c4cb7b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift @@ -0,0 +1,106 @@ +import Foundation + +public enum TeradataSchemaQueries { + 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(database: String?, table: String) -> String { + guard let database, !database.isEmpty else { return quoteIdentifier(table) } + return quoteIdentifier(database) + "." + quoteIdentifier(table) + } + + public static func listDatabases() -> String { + "SELECT DatabaseName, DBKind FROM DBC.DatabasesV ORDER BY DatabaseName" + } + + public static func listTables(database: String) -> String { + """ + SELECT TableName, TableKind FROM DBC.TablesV \ + WHERE DatabaseName = \(quoteLiteral(database)) \ + AND TableKind IN ('T', 'O', 'Q', 'V') \ + ORDER BY TableName + """ + } + + public static func columns(database: String, table: String) -> String { + """ + SELECT ColumnName, ColumnType, ColumnLength, DecimalTotalDigits, \ + DecimalFractionalDigits, Nullable, DefaultValue, ColumnId \ + FROM DBC.ColumnsV \ + WHERE DatabaseName = \(quoteLiteral(database)) AND TableName = \(quoteLiteral(table)) \ + ORDER BY ColumnId + """ + } + + public static func indexes(database: String, table: String) -> String { + """ + SELECT IndexName, IndexType, UniqueFlag, ColumnName, ColumnPosition \ + FROM DBC.IndicesV \ + WHERE DatabaseName = \(quoteLiteral(database)) AND TableName = \(quoteLiteral(table)) \ + ORDER BY IndexNumber, ColumnPosition + """ + } + + public static func showTableDDL(database: String?, table: String) -> String { + "SHOW TABLE \(qualifiedName(database: database, table: table))" + } + + public static func viewDefinition(database: String, view: String) -> String { + """ + SELECT RequestText FROM DBC.TablesV \ + WHERE DatabaseName = \(quoteLiteral(database)) AND TableName = \(quoteLiteral(view)) \ + AND TableKind = 'V' + """ + } + + public static func currentDatabase() -> String { + "SELECT DATABASE" + } + + public static func setDatabase(_ database: String) -> String { + "DATABASE \(quoteIdentifier(database))" + } + + public static func browse( + database: String?, table: String, + columns: [String]?, sortColumns: [(name: String, ascending: Bool)], + limit: Int, offset: Int + ) -> String { + let target = qualifiedName(database: database, table: table) + let projection = selectList(columns) + let orderClause = orderBy(sortColumns, fallbackColumns: columns) + + if offset <= 0 { + let top = limit > 0 ? "TOP \(limit) " : "" + let order = orderClause.map { " \($0)" } ?? "" + return "SELECT \(top)\(projection) FROM \(target)\(order)" + } + + let window = orderClause ?? "ORDER BY 1" + let lower = offset + 1 + let upper = offset + max(limit, 1) + return "SELECT \(projection) FROM \(target) " + + "QUALIFY ROW_NUMBER() OVER (\(window)) BETWEEN \(lower) AND \(upper)" + } + + private static func selectList(_ columns: [String]?) -> String { + guard let columns, !columns.isEmpty else { return "*" } + return columns.map(quoteIdentifier).joined(separator: ", ") + } + + private static func orderBy( + _ sortColumns: [(name: String, ascending: Bool)], fallbackColumns: [String]? + ) -> String? { + if !sortColumns.isEmpty { + let terms = sortColumns.map { "\(quoteIdentifier($0.name)) \($0.ascending ? "ASC" : "DESC")" } + return "ORDER BY " + terms.joined(separator: ", ") + } + guard let first = fallbackColumns?.first else { return nil } + return "ORDER BY " + quoteIdentifier(first) + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift new file mode 100644 index 000000000..94d66e2b5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift @@ -0,0 +1,132 @@ +import Darwin +import Foundation + +public enum TeradataWireError: Error, CustomStringConvertible, LocalizedError { + case connectionFailed(String) + case truncated(String) + case malformed(String) + case server(code: Int, message: String) + case unsupported(String) + case cancelled + + public var description: String { + switch self { + case .connectionFailed(let detail): return "connection failed: \(detail)" + case .truncated(let what): return "truncated: \(what)" + case .malformed(let what): return "malformed: \(what)" + case .server(let code, let message): return "\(message) (\(code))" + case .unsupported(let what): return "unsupported: \(what)" + case .cancelled: return "cancelled" + } + } + + public var errorDescription: String? { description } +} + +protocol TeradataTransport: AnyObject { + func send(_ bytes: [UInt8]) throws + func receive(_ count: Int) throws -> [UInt8] + func cancel() + func close() +} + +final class TeradataSocket: TeradataTransport { + private let lock = NSLock() + private var descriptor: Int32 = -1 + private var closed = false + + init(host: String, port: UInt16, timeoutSeconds: Int) throws { + var hints = addrinfo( + ai_flags: 0, ai_family: AF_UNSPEC, ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) + var resolved: UnsafeMutablePointer? + guard getaddrinfo(host, String(port), &hints, &resolved) == 0, let head = resolved else { + throw TeradataWireError.connectionFailed("cannot resolve \(host)") + } + defer { freeaddrinfo(resolved) } + + var lastErrno: Int32 = 0 + var candidate: UnsafeMutablePointer? = head + while let node = candidate { + let handle = socket(node.pointee.ai_family, node.pointee.ai_socktype, node.pointee.ai_protocol) + if handle >= 0 { + if connect(handle, node.pointee.ai_addr, node.pointee.ai_addrlen) == 0 { + descriptor = handle + break + } + lastErrno = errno + Darwin.close(handle) + } else { + lastErrno = errno + } + candidate = node.pointee.ai_next + } + guard descriptor >= 0 else { + throw TeradataWireError.connectionFailed("connect \(host):\(port) errno \(lastErrno)") + } + var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0) + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var one: Int32 = 1 + setsockopt(descriptor, IPPROTO_TCP, TCP_NODELAY, &one, socklen_t(MemoryLayout.size)) + } + + func cancel() { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + if descriptor >= 0 { Darwin.shutdown(descriptor, SHUT_RDWR) } + } + + func close() { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + if descriptor >= 0 { Darwin.close(descriptor) } + descriptor = -1 + } + + private var isClosed: Bool { + lock.lock() + defer { lock.unlock() } + return closed + } + + func send(_ bytes: [UInt8]) throws { + if isClosed { throw TeradataWireError.cancelled } + var offset = 0 + try bytes.withUnsafeBytes { raw in + while offset < bytes.count { + let written = Darwin.send(descriptor, raw.baseAddress!.advanced(by: offset), bytes.count - offset, 0) + if written <= 0 { + if isClosed { throw TeradataWireError.cancelled } + throw TeradataWireError.truncated("send errno \(errno)") + } + offset += written + } + } + } + + func receive(_ count: Int) throws -> [UInt8] { + guard count > 0 else { return [] } + var buffer = [UInt8](repeating: 0, count: count) + var offset = 0 + try buffer.withUnsafeMutableBytes { raw in + while offset < count { + let read = Darwin.recv(descriptor, raw.baseAddress!.advanced(by: offset), count - offset, 0) + if read == 0 { + if isClosed { throw TeradataWireError.cancelled } + throw TeradataWireError.truncated("peer closed after \(offset)/\(count)") + } + if read < 0 { + if isClosed { throw TeradataWireError.cancelled } + throw TeradataWireError.truncated("recv errno \(errno)") + } + offset += read + } + } + return buffer + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift new file mode 100644 index 000000000..138b4c55a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift @@ -0,0 +1,228 @@ +import Foundation +import Network +import Security + +final class TeradataTLSTransport: TeradataTransport { + private let connection: NWConnection + private let condition = NSCondition() + private let timeoutSeconds: Int + private var rawBuffer: [UInt8] = [] + private var messageBuffer: [UInt8] = [] + private var receiveError: Error? + private var peerClosed = false + private var cancelled = false + private var handshakeComplete = false + + init(host: String, options: TeradataTLSOptions, timeoutSeconds: Int) throws { + self.timeoutSeconds = timeoutSeconds + guard let endpointPort = NWEndpoint.Port(rawValue: options.httpsPort) else { + throw TeradataWireError.connectionFailed("invalid TLS port \(options.httpsPort)") + } + let queue = DispatchQueue(label: "com.TablePro.teradata.tls") + let verifyQueue = DispatchQueue(label: "com.TablePro.teradata.tls.verify") + + let tlsOptions = NWProtocolTLS.Options() + let anchors = Self.loadAnchors(options.caCertificatePath) + let verifiesCertificate = options.verifiesCertificate + let verifiesHostname = options.verifiesHostname + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { _, trustRef, complete in + guard verifiesCertificate else { complete(true); return } + let trust = sec_trust_copy_ref(trustRef).takeRetainedValue() + let policy = SecPolicyCreateSSL(true, verifiesHostname ? (host as CFString) : nil) + SecTrustSetPolicies(trust, policy) + if let anchors, !anchors.isEmpty { + SecTrustSetAnchorCertificates(trust, anchors as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + } + complete(SecTrustEvaluateWithError(trust, nil)) + }, + verifyQueue) + + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions) + connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: parameters) + + let ready = DispatchSemaphore(value: 0) + var failure: Error? + connection.stateUpdateHandler = { state in + switch state { + case .ready: ready.signal() + case .failed(let error): failure = error; ready.signal() + case .cancelled: ready.signal() + default: break + } + } + connection.start(queue: queue) + if ready.wait(timeout: .now() + .seconds(timeoutSeconds)) == .timedOut { + connection.cancel() + throw TeradataWireError.connectionFailed("TLS handshake to \(host):\(options.httpsPort) timed out") + } + if let failure { + connection.cancel() + throw TeradataWireError.connectionFailed("TLS handshake failed: \(failure)") + } + startReceiveLoop() + try performWebSocketHandshake(host: host, path: options.webSocketPath) + handshakeComplete = true + } + + func send(_ bytes: [UInt8]) throws { + try sendRaw(WebSocketFrame.encodeBinary(bytes)) + } + + func receive(_ count: Int) throws -> [UInt8] { + guard count > 0 else { return [] } + condition.lock() + defer { condition.unlock() } + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while messageBuffer.count < count { + try drainFramesLocked() + if messageBuffer.count >= count { break } + if cancelled { throw TeradataWireError.cancelled } + if let receiveError { throw TeradataWireError.truncated("TLS recv: \(receiveError)") } + if peerClosed { throw TeradataWireError.truncated("WebSocket closed after \(messageBuffer.count)/\(count)") } + if !condition.wait(until: deadline) { throw TeradataWireError.truncated("TLS recv timed out") } + } + let result = Array(messageBuffer.prefix(count)) + messageBuffer.removeFirst(count) + return result + } + + func cancel() { stop() } + func close() { stop() } + + private func stop() { + condition.lock() + cancelled = true + condition.signal() + condition.unlock() + connection.cancel() + } + + private func performWebSocketHandshake(host: String, path: String) throws { + let keyBytes = (0..<16).map { _ in UInt8.random(in: 0...255) } + let key = Data(keyBytes).base64EncodedString() + let request = "GET \(path) HTTP/1.1\r\n" + + "Host: \(host)\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: \(key)\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + try sendRaw(Array(request.utf8)) + + let header = try readRawUntilHeaderEnd() + let response = String(decoding: header, as: UTF8.self) + guard response.contains(" 101 ") else { + throw TeradataWireError.connectionFailed("WebSocket upgrade rejected: \(response.split(separator: "\r\n").first ?? "")") + } + let expected = WebSocketFrame.acceptKey(for: key) + guard response.lowercased().contains("sec-websocket-accept: \(expected.lowercased())") else { + throw TeradataWireError.connectionFailed("WebSocket accept mismatch") + } + } + + private func readRawUntilHeaderEnd() throws -> [UInt8] { + condition.lock() + defer { condition.unlock() } + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + let terminator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] + while true { + if let range = Self.range(of: terminator, in: rawBuffer) { + let end = range.upperBound + let header = Array(rawBuffer[.. Range? { + guard !needle.isEmpty, haystack.count >= needle.count else { return nil } + for start in 0...(haystack.count - needle.count) where Array(haystack[start.. [SecCertificate]? { + guard !path.isEmpty, let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + if let certificate = SecCertificateCreateWithData(nil, data as CFData) { return [certificate] } + guard let text = String(data: data, encoding: .utf8) else { return nil } + var certificates: [SecCertificate] = [] + var encoded = "" + var inCertificate = false + for line in text.components(separatedBy: .newlines) { + if line.contains("BEGIN CERTIFICATE") { inCertificate = true; encoded = ""; continue } + if line.contains("END CERTIFICATE") { + inCertificate = false + if let der = Data(base64Encoded: encoded), + let certificate = SecCertificateCreateWithData(nil, der as CFData) { + certificates.append(certificate) + } + continue + } + if inCertificate { encoded += line.trimmingCharacters(in: .whitespaces) } + } + return certificates.isEmpty ? nil : certificates + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift new file mode 100644 index 000000000..44c90928d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift @@ -0,0 +1,40 @@ +import Foundation + +struct ColumnMeta { + let typeCode: UInt16 + let dataLength: Int + let name: String + + var isNullable: Bool { typeCode & 1 == 1 } + var baseCode: UInt16 { typeCode & 0xFFFE } +} + +public enum TeradataValue: Equatable, Sendable { + case null + case integer(Int64) + case double(Double) + case text(String) + case bytes([UInt8]) +} + +enum TeradataType { + static let integer: UInt16 = 496 + static let smallint: UInt16 = 500 + static let bigint: UInt16 = 600 + static let byteint: UInt16 = 756 + static let float: UInt16 = 480 + static let decimal: UInt16 = 484 + static let char: UInt16 = 452 + static let varchar: UInt16 = 448 + static let longVarchar: UInt16 = 456 + static let byte: UInt16 = 692 + static let varbyte: UInt16 = 688 + static let dateInteger: UInt16 = 752 + static let dateAnsi: UInt16 = 748 + + static func signedInteger(_ bytes: [UInt8]) -> Int64 { + var value: Int64 = (bytes.first ?? 0) & 0x80 != 0 ? -1 : 0 + for byte in bytes { value = (value << 8) | Int64(byte) } + return value + } +} diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/WebSocketFrame.swift b/Packages/TableProCore/Sources/TableProTeradataCore/WebSocketFrame.swift new file mode 100644 index 000000000..9c5a88780 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/WebSocketFrame.swift @@ -0,0 +1,87 @@ +import CryptoKit +import Foundation + +enum WebSocketOpcode: UInt8 { + case continuation = 0x0 + case binary = 0x2 + case close = 0x8 + case ping = 0x9 + case pong = 0xA +} + +struct WebSocketFrame { + let opcode: WebSocketOpcode + let payload: [UInt8] + + private static let maxPayload = 64 * 1_024 * 1_024 + + static func acceptKey(for clientKey: String) -> String { + let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + let digest = Insecure.SHA1.hash(data: Data((clientKey + magic).utf8)) + return Data(digest).base64EncodedString() + } + + static func encodeBinary(_ payload: [UInt8]) -> [UInt8] { + encode(opcode: .binary, payload: payload) + } + + static func encode(opcode: WebSocketOpcode, payload: [UInt8]) -> [UInt8] { + var frame: [UInt8] = [0x80 | opcode.rawValue] + let length = payload.count + if length < 126 { + frame.append(UInt8(length)) + } else if length <= 0xFFFF { + frame.append(126) + frame.append(UInt8((length >> 8) & 0xFF)) + frame.append(UInt8(length & 0xFF)) + } else { + frame.append(127) + for shift in stride(from: 56, through: 0, by: -8) { + frame.append(UInt8((UInt64(length) >> UInt64(shift)) & 0xFF)) + } + } + frame.append(contentsOf: payload) + return frame + } + + static func decode(_ buffer: inout [UInt8]) throws -> WebSocketFrame? { + guard buffer.count >= 2 else { return nil } + let first = buffer[0] + let second = buffer[1] + let masked = second & 0x80 != 0 + var length = Int(second & 0x7F) + var offset = 2 + + if length == 126 { + guard buffer.count >= offset + 2 else { return nil } + length = Int(buffer[offset]) << 8 | Int(buffer[offset + 1]) + offset += 2 + } else if length == 127 { + guard buffer.count >= offset + 8 else { return nil } + var value: UInt64 = 0 + for index in 0..<8 { value = value << 8 | UInt64(buffer[offset + index]) } + length = Int(value) + offset += 8 + } + guard length <= maxPayload else { throw TeradataWireError.malformed("WebSocket frame length \(length)") } + + var maskKey: [UInt8] = [] + if masked { + guard buffer.count >= offset + 4 else { return nil } + maskKey = Array(buffer[offset..= offset + length else { return nil } + + var payload = Array(buffer[offset.. [UInt8] { BigUInt(hex: DHVectors.primeHex)!.bytesBE() } + + func testSharedSecretMatches() { + let dhA = DiffieHellman(primeBytes: primeBytes(), generatorBytes: [0x02], + privateExponentBytes: BigUInt(hex: "a1b2c3d4e5f6071829")!.bytesBE()) + let dhB = DiffieHellman(primeBytes: primeBytes(), generatorBytes: [0x02], + privateExponentBytes: BigUInt(hex: "0f1e2d3c4b5a69788796a5b4c3d2e1f0")!.bytesBE()) + let sharedA = dhA.sharedSecret(peerPublicKeyBytes: dhB.publicKeyBytes()) + let sharedB = dhB.sharedSecret(peerPublicKeyBytes: dhA.publicKeyBytes()) + XCTAssertEqual(sharedA, sharedB) + XCTAssertEqual(dhA.publicKeyBytes().count, 256) + XCTAssertEqual(sharedA.count, 256) + } + + func testRandomExponentIsUnique() { + let dh1 = DiffieHellman(primeBytes: primeBytes(), generatorBytes: [0x02]) + let dh2 = DiffieHellman(primeBytes: primeBytes(), generatorBytes: [0x02]) + XCTAssertNotEqual(dh1.publicKeyBytes(), dh2.publicKeyBytes()) + } + + func testDeriveKeySlice() { + let secret = (0..<32).map { UInt8($0) } + XCTAssertEqual(DiffieHellman.deriveKey(fromSharedSecret: secret, offset: 0, length: 16), + Array(0..<16).map(UInt8.init)) + XCTAssertEqual(DiffieHellman.deriveKey(fromSharedSecret: secret, offset: 16, length: 8), + Array(16..<24).map(UInt8.init)) + } +} + +final class RecordDecoderTests: XCTestCase { + func testNullBitmap() { + XCTAssertEqual(RecordDecoder.nullBitmapLength(columnCount: 1), 1) + XCTAssertEqual(RecordDecoder.nullBitmapLength(columnCount: 8), 1) + XCTAssertEqual(RecordDecoder.nullBitmapLength(columnCount: 9), 2) + XCTAssertTrue(RecordDecoder.isNull([0x20], column: 2)) + XCTAssertFalse(RecordDecoder.isNull([0x20], column: 0)) + XCTAssertTrue(RecordDecoder.isNull([0x80], column: 0)) + } + + func testDecodeMixedRow() throws { + let columns = [ + ColumnMeta(typeCode: 497, dataLength: 4, name: "id"), + ColumnMeta(typeCode: 449, dataLength: 10, name: "label"), + ColumnMeta(typeCode: 497, dataLength: 4, name: "maybe"), + ] + let body: [UInt8] = [0x20] + + [0x00, 0x00, 0x00, 0x01] + + [0x00, 0x02, 0x68, 0x69] + + [0x00, 0x00, 0x00, 0x00] + let values = try RecordDecoder.decode(recordBody: body, columns: columns) + XCTAssertEqual(values, [.integer(1), .text("hi"), .null]) + } + + func testDecodeNegativeAndChar() throws { + let columns = [ + ColumnMeta(typeCode: 500, dataLength: 2, name: "n"), + ColumnMeta(typeCode: 452, dataLength: 5, name: "code"), + ] + let body: [UInt8] = [0x00] + [0xFF, 0xFF] + Array("AB ".utf8) + let values = try RecordDecoder.decode(recordBody: body, columns: columns) + XCTAssertEqual(values, [.integer(-1), .text("AB")]) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderDecimalTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderDecimalTests.swift new file mode 100644 index 000000000..697b15f0c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderDecimalTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import TableProTeradataCore + +final class RecordDecoderDecimalTests: XCTestCase { + func testDecimalByteWidthByPrecision() { + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 2), 1) + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 4), 2) + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 9), 4) + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 10), 8) + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 18), 8) + XCTAssertEqual(RecordDecoder.decimalByteWidth(precision: 38), 16) + } + + func testDecimalStringFormatsScaleAndSign() { + XCTAssertEqual(RecordDecoder.decimalString([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x39], scale: 2), "123.45") + XCTAssertEqual(RecordDecoder.decimalString([0x00, 0x4B], scale: 1), "7.5") + XCTAssertEqual(RecordDecoder.decimalString([0xFF, 0xFF, 0xCF, 0x2C], scale: 3), "-12.500") + XCTAssertEqual(RecordDecoder.decimalString([0x00, 0x00], scale: 0), "0") + } + + func testDecimalStringHandlesSixteenByteMagnitude() { + var bytes = [UInt8](repeating: 0, count: 16) + bytes[15] = 0x01 + XCTAssertEqual(RecordDecoder.decimalString(bytes, scale: 0), "1") + } + + func testDecodeRecordWithDecimalColumn() throws { + let columns = [ + ColumnMeta(typeCode: 496, dataLength: 4, name: "id"), + ColumnMeta(typeCode: 485, dataLength: 0x0A02, name: "amount"), + ] + let bitmap: [UInt8] = [0x00] + let idBytes: [UInt8] = [0x00, 0x00, 0x00, 0x07] + let amountBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x39] + let values = try RecordDecoder.decode(recordBody: bitmap + idBytes + amountBytes, columns: columns) + XCTAssertEqual(values, [.integer(7), .text("123.45")]) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataColumnTypeTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataColumnTypeTests.swift new file mode 100644 index 000000000..c8c9ad4ca --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataColumnTypeTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import TableProTeradataCore + +final class TeradataColumnTypeTests: XCTestCase { + func testDisplayNames() { + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "I ", length: 4, totalDigits: 0, fractionalDigits: 0), "INTEGER") + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "CV", length: 50, totalDigits: 0, fractionalDigits: 0), "VARCHAR(50)") + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "CF", length: 10, totalDigits: 0, fractionalDigits: 0), "CHAR(10)") + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "D ", length: 8, totalDigits: 18, fractionalDigits: 2), "DECIMAL(18,2)") + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "TS", length: 26, totalDigits: 0, fractionalDigits: 0), "TIMESTAMP") + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "I8", length: 8, totalDigits: 0, fractionalDigits: 0), "BIGINT") + } + + func testUnknownFallsBackToCode() { + XCTAssertEqual(TeradataColumnType.displayName( + dbcColumnType: "ZZ", length: 0, totalDigits: 0, fractionalDigits: 0), "ZZ") + } + + func testWireCategories() { + XCTAssertEqual(TeradataColumnType.category(wireTypeCode: 497), .numeric) + XCTAssertEqual(TeradataColumnType.category(wireTypeCode: 448), .text) + XCTAssertEqual(TeradataColumnType.category(wireTypeCode: 752), .temporal) + XCTAssertEqual(TeradataColumnType.category(wireTypeCode: 688), .binary) + XCTAssertEqual(TeradataColumnType.category(wireTypeCode: 400), .largeObject) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift new file mode 100644 index 000000000..ec0b832a5 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import TableProTeradataCore + +final class TeradataExecuteLoopTests: XCTestCase { + func testRecordOnlyBatchIsNotComplete() { + let batch = [Parcel(.record, body: [0x00]), Parcel(.record, body: [0x00])] + XCTAssertFalse(TeradataConnection.responseIsComplete(batch)) + } + + func testEndRequestTerminatesButEndStatementDoesNot() { + XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.endRequest)])) + XCTAssertFalse(TeradataConnection.responseIsComplete([Parcel(.record), Parcel(.endStatement)])) + } + + func testServerFailuresTerminateInsteadOfLooping() { + XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.failure)])) + XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.error)])) + XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.statementError)])) + } + + func testServerErrorHasReadableLocalizedDescription() { + let error = TeradataWireError.server(code: 21_608, message: "user does not have SELECT access") + XCTAssertEqual(error.errorDescription, "user does not have SELECT access (21608)") + XCTAssertEqual((error as Error).localizedDescription, "user does not have SELECT access (21608)") + } + + func testFailureParcelSurfacesServerErrorCodeAndMessage() throws { + let message = Array("no SELECT access".utf8) + let body: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0x54, 0x68, 0x00, UInt8(message.count)] + message + let parcels = [Parcel(.failure, body: body)] + XCTAssertThrowsError(try TeradataResultParser.parse(parcels)) { error in + guard case TeradataWireError.server(let code, let text) = error else { + return XCTFail("expected server error, got \(error)") + } + XCTAssertEqual(code, 21_608) + XCTAssertEqual(text, "no SELECT access") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataLogMechTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataLogMechTests.swift new file mode 100644 index 000000000..2958c950b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataLogMechTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import TableProTeradataCore + +final class TeradataLogMechTests: XCTestCase { + private func config(_ mech: TeradataLogMech) -> TeradataConnectionConfig { + TeradataConnectionConfig(host: "127.0.0.1", username: "u", password: "p", logMech: mech) + } + + func testUnsupportedMechanismsRejectedBeforeNetwork() { + for mech in [TeradataLogMech.ldap, .krb5, .jwt] { + XCTAssertThrowsError(try TeradataConnection(config: config(mech)).connect()) { error in + guard case TeradataWireError.unsupported(let detail) = error else { + return XCTFail("expected .unsupported for \(mech.rawValue), got \(error)") + } + XCTAssertTrue(detail.contains(mech.rawValue)) + } + } + } + + func testTd2AndTdnegoPassMechanismValidation() { + for mech in [TeradataLogMech.td2, .tdnego] { + XCTAssertThrowsError(try TeradataConnection(config: config(mech)).connect()) { error in + if case TeradataWireError.unsupported = error { + XCTFail("\(mech.rawValue) must pass mechanism validation, was rejected as unsupported") + } + } + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift new file mode 100644 index 000000000..ef8ddc6fe --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import TableProTeradataCore + +final class TeradataSchemaQueriesTests: XCTestCase { + func testQuoteIdentifierDoublesInternalQuotes() { + XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("Sales"), "\"Sales\"") + XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("My\"Col"), "\"My\"\"Col\"") + } + + func testQualifiedName() { + XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: "Retail", table: "Orders"), + "\"Retail\".\"Orders\"") + XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: nil, table: "Orders"), "\"Orders\"") + XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: "", table: "Orders"), "\"Orders\"") + } + + func testListTablesUsesLiteralDatabase() { + let sql = TeradataSchemaQueries.listTables(database: "Re'tail") + XCTAssertTrue(sql.contains("FROM DBC.TablesV")) + XCTAssertTrue(sql.contains("DatabaseName = 'Re''tail'")) + } + + func testListTablesExcludesProceduresMacrosAndFunctions() { + let sql = TeradataSchemaQueries.listTables(database: "demo_user") + XCTAssertTrue(sql.contains("TableKind IN ('T', 'O', 'Q', 'V')")) + for kind in ["'M'", "'P'", "'E'", "'F'", "'R'", "'G'"] { + XCTAssertFalse(sql.contains(kind), "browsable-object list must not include \(kind)") + } + } + + func testBrowseFirstPageUsesTop() { + let sql = TeradataSchemaQueries.browse( + database: "Retail", table: "Orders", columns: nil, + sortColumns: [], limit: 100, offset: 0) + XCTAssertEqual(sql, "SELECT TOP 100 * FROM \"Retail\".\"Orders\"") + } + + func testBrowseFirstPageWithSort() { + let sql = TeradataSchemaQueries.browse( + database: "Retail", table: "Orders", columns: nil, + sortColumns: [("Total", false)], limit: 50, offset: 0) + XCTAssertEqual(sql, "SELECT TOP 50 * FROM \"Retail\".\"Orders\" ORDER BY \"Total\" DESC") + } + + func testBrowseOffsetPageUsesQualifyRowNumber() { + let sql = TeradataSchemaQueries.browse( + database: "Retail", table: "Orders", columns: ["Id", "Total"], + sortColumns: [("Id", true)], limit: 100, offset: 200) + XCTAssertEqual(sql, + "SELECT \"Id\", \"Total\" FROM \"Retail\".\"Orders\" " + + "QUALIFY ROW_NUMBER() OVER (ORDER BY \"Id\" ASC) BETWEEN 201 AND 300") + } + + func testBrowseOffsetPageFallsBackToOrderByOne() { + let sql = TeradataSchemaQueries.browse( + database: nil, table: "Orders", columns: nil, + sortColumns: [], limit: 25, offset: 25) + XCTAssertTrue(sql.contains("QUALIFY ROW_NUMBER() OVER (ORDER BY 1) BETWEEN 26 AND 50")) + } + + func testColumnsQueryTargetsColumnsV() { + let sql = TeradataSchemaQueries.columns(database: "Retail", table: "Orders") + XCTAssertTrue(sql.contains("FROM DBC.ColumnsV")) + XCTAssertTrue(sql.contains("ORDER BY ColumnId")) + XCTAssertTrue(sql.contains("TableName = 'Orders'")) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift new file mode 100644 index 000000000..d3dd92c84 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import TableProTeradataCore + +final class TeradataTLSTests: XCTestCase { + func testDisabledOptionsDoNotEnableTLS() { + XCTAssertFalse(TeradataTLSOptions.disabled.enabled) + XCTAssertFalse(TeradataTLSOptions.disabled.verifiesCertificate) + } + + func testClientAttributesEmbedSSLMode() { + let parcel = TeradataMessages.clientAttributesParcel( + username: "u", session: 1, charset: 0xBF, serverIP: "10.0.0.1", + logMech: "TD2", transactionMode: "ANSI", sslMode: "REQUIRE", database: "db") + let body = String(decoding: parcel.body, as: UTF8.self) + XCTAssertTrue(body.contains("SSLM=REQUIRE"), "attributes must carry the negotiated SSL mode") + XCTAssertTrue(body.contains("LM=TD2")) + XCTAssertTrue(body.contains("TM=ANSI")) + } + + func testPlaintextConnectIgnoresTLSFallbackFlag() { + let config = TeradataConnectionConfig( + host: "127.0.0.1", port: 9, username: "u", password: "p", + tls: TeradataTLSOptions(enabled: false, allowPlaintextFallback: true), + connectTimeoutSeconds: 1) + XCTAssertThrowsError(try TeradataConnection(config: config).connect()) { error in + guard case TeradataWireError.connectionFailed = error else { + if case TeradataWireError.truncated = error { return } + return XCTFail("expected a transport error, got \(error)") + } + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift new file mode 100644 index 000000000..708eb6000 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import TableProTeradataCore + +final class WebSocketFrameTests: XCTestCase { + func testAcceptKeyMatchesRFC6455Vector() { + XCTAssertEqual(WebSocketFrame.acceptKey(for: "dGhlIHNhbXBsZSBub25jZQ=="), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") + } + + func testBinaryFramesAreUnmaskedWithFinBit() { + let frame = WebSocketFrame.encodeBinary([0xAA, 0xBB, 0xCC]) + XCTAssertEqual(frame[0], 0x82) + XCTAssertEqual(frame[1], 3) + XCTAssertEqual(Array(frame[2...]), [0xAA, 0xBB, 0xCC]) + } + + func testExtendedLengthEncoding() { + let payload = [UInt8](repeating: 0x5A, count: 300) + let frame = WebSocketFrame.encodeBinary(payload) + XCTAssertEqual(frame[0], 0x82) + XCTAssertEqual(frame[1], 126) + XCTAssertEqual(Int(frame[2]) << 8 | Int(frame[3]), 300) + XCTAssertEqual(Array(frame[4...]), payload) + } + + func testDecodeUnmaskedServerFrame() throws { + var buffer: [UInt8] = [0x82, 0x03, 0x01, 0x02, 0x03, 0xFF] + let frame = try WebSocketFrame.decode(&buffer) + XCTAssertEqual(frame?.opcode, .binary) + XCTAssertEqual(frame?.payload, [0x01, 0x02, 0x03]) + XCTAssertEqual(buffer, [0xFF]) + } + + func testDecodeMaskedFrameUnmasksPayload() throws { + let mask: [UInt8] = [0x10, 0x20, 0x30, 0x40] + let plain: [UInt8] = [0x41, 0x42, 0x43] + var buffer: [UInt8] = [0x82, 0x83] + mask + plain.enumerated().map { $0.element ^ mask[$0.offset % 4] } + let frame = try WebSocketFrame.decode(&buffer) + XCTAssertEqual(frame?.payload, plain) + XCTAssertTrue(buffer.isEmpty) + } + + func testDecodeReturnsNilOnIncompleteFrame() throws { + var buffer: [UInt8] = [0x82, 0x05, 0x01, 0x02] + XCTAssertNil(try WebSocketFrame.decode(&buffer)) + XCTAssertEqual(buffer.count, 4) + } +} diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist new file mode 100644 index 000000000..8171adf32 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 18 + + diff --git a/Plugins/TeradataDriverPlugin/TeradataAsyncConnection.swift b/Plugins/TeradataDriverPlugin/TeradataAsyncConnection.swift new file mode 100644 index 000000000..125af2be8 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataAsyncConnection.swift @@ -0,0 +1,43 @@ +import Foundation +import TableProTeradataCore + +final class TeradataAsyncConnection: @unchecked Sendable { + private let connection: TeradataConnection + private let queue = DispatchQueue(label: "com.TablePro.teradata.connection") + + init(config: TeradataConnectionConfig) { + connection = TeradataConnection(config: config) + } + + var isConnected: Bool { + queue.sync { connection.isConnected } + } + + func connect() async throws { + try await run { try $0.connect() } + } + + func execute(_ sql: String) async throws -> TeradataResultSet { + try await run { try $0.execute(sql) } + } + + func disconnect() { + queue.sync { connection.disconnect() } + } + + func cancel() { + connection.cancel() + } + + private func run(_ body: @escaping @Sendable (TeradataConnection) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try body(self.connection)) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift new file mode 100644 index 000000000..e7f13472c --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -0,0 +1,206 @@ +import Foundation +import os +import TableProPluginKit +import TableProTeradataCore + +extension TeradataValue { + var asPluginCell: PluginCellValue { + switch self { + case .null: return .null + case .integer(let value): return .text(String(value)) + case .double(let value): return .text(String(value)) + case .text(let value): return .text(value) + case .bytes(let value): return .bytes(Data(value)) + } + } +} + +extension TeradataResultSet { + func toPluginResult(executionTime: TimeInterval) -> PluginQueryResult { + PluginQueryResult( + columns: columns.map { $0.name }, + columnTypeNames: columns.map { TeradataColumnType.wireTypeName($0.typeCode) }, + rows: rows.map { row in row.map { $0.asPluginCell } }, + rowsAffected: activityCount, + executionTime: executionTime, + isTruncated: false + ) + } +} + +final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Teradata Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Teradata Vantage support via a native Swift TD2 driver" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Teradata" + static let databaseDisplayName = "Teradata" + static let iconName = "teradata-icon" + static let defaultPort = 1_025 + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "teradataLogMech", label: "Logon Mechanism", placeholder: "TD2", defaultValue: "TD2"), + ConnectionField( + id: "teradataTMode", label: "Transaction Mode", placeholder: "DEFAULT", defaultValue: "DEFAULT"), + ] + + static let brandColorHex = "#F37440" + static let queryLanguageName = "SQL" + static let supportsDatabaseSwitching = true + static let supportsSchemaSwitching = false + static let requiresReconnectForDatabaseSwitch = false + static let databaseGroupingStrategy: GroupingStrategy = .byDatabase + static let containerEntityName = "Database" + static let supportsForeignKeys = true + static let supportsSchemaEditing = true + static let supportsSSL = true + static let systemDatabaseNames: [String] = [ + "DBC", "Sys", "SysAdmin", "SystemFe", "SYSLIB", "SYSUDTLIB", "SYSSPATIAL", + "TD_SYSFNLIB", "TD_SERVER_DB", "TDStats", "TDMaps", "TDQCD", "SQLJ", + "Sys_Calendar", "dbcmngr", "tdwm", "viewpoint", "console", "PUBLIC", "All", "Default", + ] + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["BYTEINT", "SMALLINT", "INTEGER", "BIGINT"], + "Float": ["FLOAT", "REAL", "DOUBLE PRECISION", "DECIMAL", "NUMERIC", "NUMBER"], + "String": ["CHAR", "VARCHAR", "LONG VARCHAR", "CLOB"], + "Date": ["DATE", "TIME", "TIMESTAMP", "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE"], + "Binary": ["BYTE", "VARBYTE", "BLOB"], + "Interval": ["INTERVAL YEAR", "INTERVAL MONTH", "INTERVAL DAY", "INTERVAL HOUR", "INTERVAL MINUTE", "INTERVAL SECOND"], + "Other": ["JSON", "XML", "PERIOD(DATE)", "PERIOD(TIMESTAMP)", "UDT"], + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "SAMPLE", + "ORDER", "BY", "GROUP", "HAVING", "TOP", "QUALIFY", "WITH", "RECURSIVE", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "USER", "MACRO", "PROCEDURE", + "PRIMARY", "KEY", "UNIQUE", "INDEX", "PARTITION", "BY", "HASH", "RANGE_N", "CASE_N", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", "MINUS", + "OVER", "ROW_NUMBER", "RANK", "DENSE_RANK", "HELP", "SHOW", "EXPLAIN", "COLLECT", "STATISTICS", + "VOLATILE", "MULTISET", "SET", "GLOBAL", "TEMPORARY", "AS", "LOCKING", "FOR", "ACCESS", + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "CAST", "TRIM", "SUBSTRING", "SUBSTR", + "CHARACTER_LENGTH", "CHAR_LENGTH", "COALESCE", "NULLIFZERO", "ZEROIFNULL", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "EXTRACT", "ADD_MONTHS", + "UPPER", "LOWER", "OREPLACE", "OTRANSLATE", "INDEX", "POSITION", "SOUNDEX", + ], + dataTypes: [ + "BYTEINT", "SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "NUMBER", + "FLOAT", "REAL", "DOUBLE PRECISION", + "CHAR", "VARCHAR", "LONG VARCHAR", "CLOB", + "BYTE", "VARBYTE", "BLOB", + "DATE", "TIME", "TIMESTAMP", "INTERVAL", "PERIOD", + "JSON", "XML", "ST_GEOMETRY", + ], + autoLimitStyle: .top + ) + + private static let logger = Logger(subsystem: "com.TablePro", category: "TeradataPlugin") + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + TeradataPluginDriver(config: config) + } +} + +final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + let config: DriverConnectionConfig + var connection: TeradataAsyncConnection? + var currentDatabaseName: String? + private var _serverVersion: String? + + static let logger = Logger(subsystem: "com.TablePro", category: "TeradataPluginDriver") + + var serverVersion: String? { _serverVersion } + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { true } + + var capabilities: PluginCapabilities { + [.cancelQuery, .transactions, .alterTableDDL] + } + + init(config: DriverConnectionConfig) { + self.config = config + self.currentDatabaseName = config.database.isEmpty ? nil : config.database + } + + private var logMech: TeradataLogMech { + TeradataLogMech(rawValue: config.additionalFields["teradataLogMech"] ?? "TD2") ?? .td2 + } + + private var transactionMode: TeradataTransactionMode { + TeradataTransactionMode(rawValue: config.additionalFields["teradataTMode"] ?? "DEFAULT") ?? .default + } + + func connect() async throws { + guard let port = UInt16(exactly: config.port) else { + throw TeradataWireError.connectionFailed("port \(config.port) is out of range 0...65535") + } + let coreConfig = TeradataConnectionConfig( + host: config.host, + port: port, + username: config.username, + password: config.password, + database: config.database.isEmpty ? nil : config.database, + logMech: logMech, + transactionMode: transactionMode, + tls: TeradataSSLMapping.tlsOptions(for: config.ssl)) + let connection = TeradataAsyncConnection(config: coreConfig) + try await connection.connect() + self.connection = connection + + if let result = try? await connection.execute("SELECT InfoData FROM DBC.DBCInfoV WHERE InfoKey = 'VERSION'"), + case .text(let version)? = result.rows.first?.first { + _serverVersion = version + } + } + + func disconnect() { + connection?.disconnect() + connection = nil + } + + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + + func execute(query: String) async throws -> PluginQueryResult { + guard let connection else { throw TeradataWireError.connectionFailed("not connected") } + let start = Date() + let result = try await connection.execute(query) + return result.toPluginResult(executionTime: Date().timeIntervalSince(start)) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { + try await execute(query: query) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + guard !parameters.isEmpty else { return try await execute(query: query) } + var rendered = "" + var index = 0 + for character in query { + if character == "?", index < parameters.count { + rendered += sqlLiteral(parameters[index]) + index += 1 + } else { + rendered.append(character) + } + } + return try await execute(query: rendered) + } + + func cancelQuery() throws { + connection?.cancel() + } + + func beginTransaction() async throws { _ = try await execute(query: "BEGIN TRANSACTION") } + func commitTransaction() async throws { _ = try await execute(query: "END TRANSACTION") } + func rollbackTransaction() async throws { _ = try await execute(query: "ROLLBACK") } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+DDL.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+DDL.swift new file mode 100644 index 000000000..37b5db71a --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+DDL.swift @@ -0,0 +1,68 @@ +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + private func qualified(_ table: String) -> String { + TeradataSchemaQueries.qualifiedName(database: currentDatabaseName, table: table) + } + + private func identifier(_ name: String) -> String { + TeradataSchemaQueries.quoteIdentifier(name) + } + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + var parts = ["\(identifier(column.name)) \(column.dataType)"] + if column.autoIncrement { + parts.append("GENERATED ALWAYS AS IDENTITY") + } + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + parts.append("DEFAULT \(defaultValue)") + } + parts.append(column.isNullable ? "" : "NOT NULL") + return parts.filter { !$0.isEmpty }.joined(separator: " ") + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + let columnDefinitions = definition.columns.compactMap { generateColumnDefinitionSQL(column: $0) } + guard !columnDefinitions.isEmpty else { return nil } + var sql = "CREATE MULTISET TABLE \(qualified(definition.tableName)) (\n" + sql += " " + columnDefinitions.joined(separator: ",\n ") + sql += "\n)" + if !definition.primaryKeyColumns.isEmpty { + let keys = definition.primaryKeyColumns.map(identifier).joined(separator: ", ") + sql += " PRIMARY INDEX (\(keys))" + } + return sql + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + guard let definition = generateColumnDefinitionSQL(column: column) else { return nil } + return "ALTER TABLE \(qualified(table)) ADD \(definition)" + } + + func generateModifyColumnSQL( + table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition + ) -> String? { + if oldColumn.name != newColumn.name { + return "ALTER TABLE \(qualified(table)) " + + "RENAME \(identifier(oldColumn.name)) TO \(identifier(newColumn.name))" + } + guard let definition = generateColumnDefinitionSQL(column: newColumn) else { return nil } + return "ALTER TABLE \(qualified(table)) ADD \(definition)" + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(qualified(table)) DROP \(identifier(columnName))" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + let columns = index.columns.map(identifier).joined(separator: ", ") + let unique = index.isUnique ? "UNIQUE " : "" + return "CREATE \(unique)INDEX \(identifier(index.name)) (\(columns)) ON \(qualified(table))" + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(identifier(indexName)) ON \(qualified(table))" + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift new file mode 100644 index 000000000..0215ec781 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift @@ -0,0 +1,88 @@ +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + func sqlLiteral(_ value: PluginCellValue) -> String { + switch value { + case .null: + return "NULL" + case .text(let string): + return "'" + string.replacingOccurrences(of: "'", with: "''") + "'" + case .bytes(let data): + return "'" + data.map { String(format: "%02X", $0) }.joined() + "'XB" + } + } + + 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 = TeradataSchemaQueries.qualifiedName( + database: effectiveDatabaseForSchema(schema), table: table) + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex), + let values = insertedRowData[change.rowIndex], values.count == columns.count else { continue } + let columnList = columns.map(quote).joined(separator: ", ") + let valueList = values.map(sqlLiteral).joined(separator: ", ") + statements.append(("INSERT INTO \(target) (\(columnList)) VALUES (\(valueList))", [])) + case .update: + let assignments = change.cellChanges.map { "\(quote($0.columnName)) = \(sqlLiteral($0.newValue))" } + guard !assignments.isEmpty else { continue } + let whereClause = keyPredicate(primaryKeyColumns, columns: columns, change: change) + statements.append(("UPDATE \(target) SET \(assignments.joined(separator: ", "))\(whereClause)", [])) + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + let whereClause = keyPredicate(primaryKeyColumns, columns: columns, change: change) + statements.append(("DELETE FROM \(target)\(whereClause)", [])) + } + } + return statements.isEmpty ? nil : statements + } + + private func quote(_ name: String) -> String { + TeradataSchemaQueries.quoteIdentifier(name) + } + + private func effectiveDatabaseForSchema(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + return currentDatabaseName + } + + private func keyPredicate(_ primaryKeyColumns: [String], columns: [String], change: PluginRowChange) -> String { + let keyColumns = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns + let conditions = keyColumns.map { column -> String in + let value = oldValue(for: column, columns: columns, change: change) + if case .null = value { return "\(quote(column)) IS NULL" } + return "\(quote(column)) = \(sqlLiteral(value))" + } + return conditions.isEmpty ? "" : " WHERE " + conditions.joined(separator: " AND ") + } + + private func oldValue(for column: String, columns: [String], change: PluginRowChange) -> PluginCellValue { + if let originalRow = change.originalRow, + let index = columns.firstIndex(of: column), index < originalRow.count { + return originalRow[index] + } + if let cell = change.cellChanges.first(where: { $0.columnName == column }) { + return cell.oldValue + } + return .null + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift new file mode 100644 index 000000000..dd021a6e3 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift @@ -0,0 +1,143 @@ +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + private func effectiveDatabase(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + return currentDatabaseName + } + + private func text(_ cell: PluginCellValue?) -> String? { + if case .text(let value)? = cell { return value } + return nil + } + + func fetchDatabases() async throws -> [String] { + let result = try await execute(query: TeradataSchemaQueries.listDatabases()) + return result.rows.compactMap { text($0.first) } + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata( + name: database, + isSystemDatabase: TeradataPlugin.systemDatabaseNames.contains { $0.caseInsensitiveCompare(database) == .orderedSame }) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let database = effectiveDatabase(schema) else { return [] } + let result = try await execute(query: TeradataSchemaQueries.listTables(database: database)) + return result.rows.compactMap { row in + guard let name = text(row.first) else { return nil } + let kind = row.count > 1 ? text(row[1]) ?? "T" : "T" + return PluginTableInfo(name: name, type: Self.tableType(kind), schema: database, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + guard let database = effectiveDatabase(schema) else { return [] } + let result = try await execute(query: TeradataSchemaQueries.columns(database: database, table: table)) + return result.rows.map { row in + let name = text(row.first) ?? "" + let dbcType = row.count > 1 ? text(row[1]) ?? "" : "" + let length = Int(text(row.count > 2 ? row[2] : nil) ?? "") ?? 0 + let totalDigits = Int(text(row.count > 3 ? row[3] : nil) ?? "") ?? 0 + let fractionalDigits = Int(text(row.count > 4 ? row[4] : nil) ?? "") ?? 0 + let nullable = (text(row.count > 5 ? row[5] : nil) ?? "Y") == "Y" + let defaultValue = text(row.count > 6 ? row[6] : nil) + return PluginColumnInfo( + name: name, + dataType: TeradataColumnType.displayName( + dbcColumnType: dbcType, length: length, + totalDigits: totalDigits, fractionalDigits: fractionalDigits), + isNullable: nullable, + defaultValue: defaultValue?.trimmingCharacters(in: .whitespaces).isEmpty == true ? nil : defaultValue) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + guard let database = effectiveDatabase(schema) else { return [] } + let result = try await execute(query: TeradataSchemaQueries.indexes(database: database, table: table)) + var byName: [String: (columns: [String], unique: Bool, primary: Bool)] = [:] + var order: [String] = [] + for row in result.rows { + let indexName = text(row.first)?.trimmingCharacters(in: .whitespaces) ?? "" + let indexType = row.count > 1 ? text(row[1]) ?? "" : "" + let unique = (row.count > 2 ? text(row[2]) ?? "N" : "N") == "Y" + let column = row.count > 3 ? text(row[3]) ?? "" : "" + let key = indexName.isEmpty ? indexType : indexName + if byName[key] == nil { + byName[key] = (columns: [], unique: unique, primary: indexType == "P" || indexType == "Q") + order.append(key) + } + if !column.isEmpty { byName[key]?.columns.append(column) } + } + return order.compactMap { key in + guard let info = byName[key] else { return nil } + return PluginIndexInfo( + name: key, columns: info.columns, isUnique: info.unique, + isPrimary: info.primary, type: "TERADATA") + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let database = effectiveDatabase(schema) + let result = try await execute( + query: TeradataSchemaQueries.showTableDDL(database: database, table: table)) + return result.rows.compactMap { text($0.first) }.joined() + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + guard let database = effectiveDatabase(schema) else { return "" } + let result = try await execute( + query: TeradataSchemaQueries.viewDefinition(database: database, view: view)) + return text(result.rows.first?.first) ?? "" + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let rowCount = try await fetchApproximateRowCount(table: table, schema: schema) + return PluginTableMetadata(tableName: table, rowCount: rowCount.map { Int64($0) }) + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + let target = TeradataSchemaQueries.qualifiedName(database: effectiveDatabase(schema), table: table) + let result = try await execute(query: "SELECT COUNT(*) FROM \(target)") + return text(result.rows.first?.first).flatMap { Int($0) } + } + + func switchDatabase(to database: String) async throws { + _ = try await execute(query: TeradataSchemaQueries.setDatabase(database)) + currentDatabaseName = database + } + + func buildBrowseQuery( + table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], limit: Int, offset: Int + ) -> String? { + buildBrowseQuery( + table: table, schema: nil, sortColumns: sortColumns, + columns: columns, limit: limit, offset: offset) + } + + func buildBrowseQuery( + table: String, schema: String?, sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], limit: Int, offset: Int + ) -> String? { + let sorts: [(name: String, ascending: Bool)] = sortColumns.compactMap { sort in + guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } + return (columns[sort.columnIndex], sort.ascending) + } + return TeradataSchemaQueries.browse( + database: effectiveDatabase(schema), table: table, + columns: columns.isEmpty ? nil : columns, sortColumns: sorts, + limit: limit, offset: offset) + } + + private static func tableType(_ kind: String) -> String { + kind.trimmingCharacters(in: .whitespaces) == "V" ? "VIEW" : "TABLE" + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataSSLMapping.swift b/Plugins/TeradataDriverPlugin/TeradataSSLMapping.swift new file mode 100644 index 000000000..14646b2f5 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataSSLMapping.swift @@ -0,0 +1,26 @@ +import Foundation +import TableProPluginKit +import TableProTeradataCore + +enum TeradataSSLMapping { + static func tlsOptions(for ssl: SSLConfiguration) -> TeradataTLSOptions { + guard ssl.isEnabled else { return .disabled } + return TeradataTLSOptions( + enabled: true, + allowPlaintextFallback: ssl.mode == .preferred, + verifiesCertificate: ssl.verifiesCertificate, + verifiesHostname: ssl.verifiesHostname, + caCertificatePath: ssl.caCertificatePath, + modeLabel: modeLabel(for: ssl.mode)) + } + + private static func modeLabel(for mode: SSLMode) -> String { + switch mode { + case .disabled: return "DISABLE" + case .preferred: return "PREFER" + case .required: return "REQUIRE" + case .verifyCa: return "VERIFY-CA" + case .verifyIdentity: return "VERIFY-FULL" + } + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 8c5d395f6..7cb8f8ad4 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -95,6 +95,8 @@ 5E1A500100000000000000A7 /* ElasticsearchQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */; }; 5E1A500100000000000000A8 /* ElasticsearchStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */; }; 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 */; }; 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 */; }; @@ -104,6 +106,7 @@ 8F6387B85F8B4AEE8210A6F1 /* SnowflakeIdTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */; }; 952FCF406F3DE2575166802B /* SnowflakeBrowserAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */; }; B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */; }; + E035AC6C854B14A8D56EDD2F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; EE6F98F0581A8B96C6FE45E9 /* SnowflakeMFATokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */; }; FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */; }; /* End PBXBuildFile section */ @@ -422,6 +425,8 @@ 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = ""; }; 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; }; 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 = ""; }; @@ -666,6 +671,13 @@ ); target = 5A1091C62EF17EDC0055EA7C /* TablePro */; }; + A14460BE78075C507C6E5BDF /* Exceptions for "Plugins/TeradataDriverPlugin" folder in "TeradataDriver" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A95F5AB41510A4EFAAA0F38C /* TeradataDriver */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -889,9 +901,27 @@ path = TableProUITests; sourceTree = ""; }; + 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A14460BE78075C507C6E5BDF /* Exceptions for "Plugins/TeradataDriverPlugin" folder in "TeradataDriver" target */, + ); + path = Plugins/TeradataDriverPlugin; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 3600DDF1A8FAAF26C193B519 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 66671E37CDACCE1B9B0D9400 /* Cocoa.framework in Frameworks */, + E035AC6C854B14A8D56EDD2F /* TableProPluginKit.framework in Frameworks */, + 675C428FECC26FF3E0AF42DA /* TableProTeradataCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A1091C42EF17EDC0055EA7C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1163,6 +1193,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0AF412EF6616DCE513B6EAEC /* OS X */ = { + isa = PBXGroup; + children = ( + 97DB8F422F8F77B13A41B15F /* Cocoa.framework */, + ); + name = "OS X"; + sourceTree = ""; + }; 5A05FBC72F3EDF7500819CD7 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -1210,6 +1248,7 @@ 5A05FBC72F3EDF7500819CD7 /* Recovered References */, 5AEA8B482F6808E90040461A /* Frameworks */, 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, + 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */, ); sourceTree = ""; }; @@ -1250,6 +1289,7 @@ 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */, 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */, 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */, + A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */, ); name = Products; sourceTree = ""; @@ -1299,6 +1339,7 @@ 5AEA8B482F6808E90040461A /* Frameworks */ = { isa = PBXGroup; children = ( + 0AF412EF6616DCE513B6EAEC /* OS X */, ); name = Frameworks; sourceTree = ""; @@ -1426,8 +1467,6 @@ 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */, ); name = SurrealDBDriverPlugin; - packageProductDependencies = ( - ); productName = SurrealDBDriverPlugin; productReference = 5A1862000000000000000002 /* SurrealDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1448,8 +1487,6 @@ 5A32BC012F9D5F1300BAEB5F /* mcp-server */, ); name = "mcp-server"; - packageProductDependencies = ( - ); productName = "mcp-server"; productReference = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; productType = "com.apple.product-type.tool"; @@ -1470,8 +1507,6 @@ 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, ); name = LibSQLDriverPlugin; - packageProductDependencies = ( - ); productName = LibSQLDriverPlugin; productReference = 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1938,8 +1973,6 @@ dependencies = ( ); name = BigQueryDriverPlugin; - packageProductDependencies = ( - ); productName = BigQueryDriverPlugin; productReference = 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1957,8 +1990,6 @@ dependencies = ( ); name = DynamoDBDriverPlugin; - packageProductDependencies = ( - ); productName = DynamoDBDriverPlugin; productReference = 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1979,8 +2010,6 @@ 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, ); name = CloudflareD1DriverPlugin; - packageProductDependencies = ( - ); productName = CloudflareD1DriverPlugin; productReference = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1998,8 +2027,6 @@ dependencies = ( ); name = EtcdDriverPlugin; - packageProductDependencies = ( - ); productName = EtcdDriverPlugin; productReference = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -2038,12 +2065,33 @@ dependencies = ( ); name = ElasticsearchDriverPlugin; - packageProductDependencies = ( - ); productName = ElasticsearchDriverPlugin; productReference = 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; }; + A95F5AB41510A4EFAAA0F38C /* TeradataDriver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 21831B14D329AEDEF6E0FC05 /* Build configuration list for PBXNativeTarget "TeradataDriver" */; + buildPhases = ( + A71ED1E7D88AFCF2B8B4757E /* Sources */, + 3600DDF1A8FAAF26C193B519 /* Frameworks */, + CEA116D19B343A1E94648AFE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */, + ); + name = TeradataDriver; + packageProductDependencies = ( + F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */, + ); + productName = TeradataDriver; + productReference = A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */; + productType = "com.apple.product-type.bundle"; + }; FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */; @@ -2205,6 +2253,7 @@ 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */, 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */, FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */, + A95F5AB41510A4EFAAA0F38C /* TeradataDriver */, ); }; /* End PBXProject section */ @@ -2434,6 +2483,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CEA116D19B343A1E94648AFE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2714,6 +2770,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A71ED1E7D88AFCF2B8B4757E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -4858,9 +4921,66 @@ }; name = Release; }; + E7AC38103A156E07E8B6F0B9 /* 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/TeradataDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).TeradataPlugin"; + 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.TeradataDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + FDC1CCF032C4C3B329085455 /* 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/TeradataDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).TeradataPlugin"; + 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.TeradataDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 21831B14D329AEDEF6E0FC05 /* Build configuration list for PBXNativeTarget "TeradataDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7AC38103A156E07E8B6F0B9 /* Release */, + FDC1CCF032C4C3B329085455 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -5253,6 +5373,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; + F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProTeradataCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TablePro/Assets.xcassets/teradata-icon.imageset/Contents.json b/TablePro/Assets.xcassets/teradata-icon.imageset/Contents.json new file mode 100644 index 000000000..f692ba532 --- /dev/null +++ b/TablePro/Assets.xcassets/teradata-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "teradata.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/teradata-icon.imageset/teradata.svg b/TablePro/Assets.xcassets/teradata-icon.imageset/teradata.svg new file mode 100644 index 000000000..805d1b1d6 --- /dev/null +++ b/TablePro/Assets.xcassets/teradata-icon.imageset/teradata.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 3d6d0327b..35dc6b475 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -209,6 +209,87 @@ extension PluginMetadataRegistry { tagline: String(localized: "Microsoft's enterprise SQL database") ) )), + ("Teradata", PluginMetadataSnapshot( + displayName: "Teradata", iconName: "teradata-icon", defaultPort: 1_025, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "teradata", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["teradata"], + postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#F37440", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + 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: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["DBC", "Sys", "SysAdmin", "SystemFe", "SYSLIB", "SYSUDTLIB", "TDStats", "PUBLIC", "All", "Default"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [.name, .type, .nullable, .defaultValue] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "TOP", "QUALIFY", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "ON", "AND", "OR", "NOT", + "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", "VIEW", + "DATABASE", "USER", "MACRO", "VOLATILE", "MULTISET", "SET", "AS", "SAMPLE", + "HELP", "SHOW", "EXPLAIN", "COLLECT", "STATISTICS", "LOCKING", "FOR", "ACCESS", + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "CAST", "TRIM", "SUBSTRING", + "COALESCE", "NULLIFZERO", "ZEROIFNULL", "OREPLACE", "OTRANSLATE", + "CURRENT_DATE", "CURRENT_TIMESTAMP", "EXTRACT", "ADD_MONTHS", + ], + dataTypes: [ + "BYTEINT", "SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMBER", "FLOAT", + "CHAR", "VARCHAR", "CLOB", "BYTE", "VARBYTE", "BLOB", + "DATE", "TIME", "TIMESTAMP", "INTERVAL", "PERIOD", "JSON", "XML", + ], + autoLimitStyle: .top + ), + statementCompletions: [], + columnTypesByCategory: [ + "Integer": ["BYTEINT", "SMALLINT", "INTEGER", "BIGINT"], + "Float": ["FLOAT", "DECIMAL", "NUMBER"], + "String": ["CHAR", "VARCHAR", "CLOB"], + "Date": ["DATE", "TIME", "TIMESTAMP"], + "Binary": ["BYTE", "VARBYTE", "BLOB"], + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [ + ConnectionField( + id: "teradataLogMech", label: "Logon Mechanism", placeholder: "TD2", defaultValue: "TD2"), + ConnectionField( + id: "teradataTMode", label: "Transaction Mode", placeholder: "DEFAULT", defaultValue: "DEFAULT"), + ], + category: .relational, + tagline: String(localized: "Teradata Vantage data warehouse") + ) + )), ("Oracle", PluginMetadataSnapshot( displayName: "Oracle", iconName: "oracle-icon", defaultPort: 1_521, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9b206c745..1e7af3131 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -78032,6 +78032,9 @@ } } } + }, + "Teradata Vantage data warehouse" : { + }, "Terminal" : { "extractionState" : "stale", diff --git a/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/Contents.json new file mode 100644 index 000000000..f692ba532 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "teradata.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/teradata.svg b/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/teradata.svg new file mode 100644 index 000000000..805d1b1d6 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/teradata.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/Contents.json new file mode 100644 index 000000000..f692ba532 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "teradata.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/teradata.svg b/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/teradata.svg new file mode 100644 index 000000000..805d1b1d6 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/teradata.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/databases/teradata.mdx b/docs/databases/teradata.mdx new file mode 100644 index 000000000..e3ab168fe --- /dev/null +++ b/docs/databases/teradata.mdx @@ -0,0 +1,48 @@ +--- +title: Teradata +description: Connect to Teradata Vantage with TablePro's native Swift driver. +--- + +TablePro connects to Teradata Vantage with a driver written in native Swift. It speaks the Teradata wire protocol directly, so there is no ODBC driver or JDBC jar to install. Sessions use the TD2 logon mechanism with encrypted data. + +## Install the plugin + +Teradata is a downloadable driver. Open **Settings > Plugins**, find Teradata, and install it. The driver downloads and loads without restarting the app. + +## Connection settings + +| Field | Description | +| --- | --- | +| Host | The Teradata database hostname. | +| Port | The gateway port. Default `1025`. | +| Database | The default database to use after logon. Optional; leave blank to use your account's default. | +| Username | Your Teradata username. | +| Password | Your Teradata password. | +| Logon Mechanism | The logon method. `TD2` and `TDNEGO` are supported. Default `TD2`. | +| Transaction Mode | `DEFAULT`, `ANSI`, or `TERA`. Default `DEFAULT`. | +| SSL Mode | Off by default. `Preferred`, `Required`, `Verify CA`, or `Verify Identity` turn on TLS. | + +## Databases and objects + +In Teradata a database and a user are both namespaces that hold tables, views, and macros. TablePro lists them from the data dictionary (`DBC.DatabasesV`) as top-level databases, and their tables and columns come from `DBC.TablesV` and `DBC.ColumnsV`. + +Identifiers are quoted with double quotes. Teradata has no `LIMIT`, so TablePro pages results with `TOP` on the first page and `QUALIFY ROW_NUMBER()` for later pages. + +## TLS + +Teradata carries encrypted sessions over HTTPS on port `443`, separate from the plain gateway port `1025`. TablePro connects with TLS and tunnels the session over the gateway's WebSocket endpoint. Set the SSL Mode to turn it on: + +- `Preferred` tries TLS and falls back to a plain connection on `1025` if the HTTPS port is unreachable. +- `Required` uses TLS but does not check the server 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. + +To test TLS, use `Required` so a failed handshake surfaces instead of falling back. + +## Transaction mode + +`ANSI` and `TERA` differ in case sensitivity and commit behavior. `ANSI` compares strings case-sensitively and keeps a transaction open until you commit; `TERA` compares case-insensitively and commits each request unless you wrap it in `BEGIN TRANSACTION`. `DEFAULT` uses whatever the database is configured for. + +## Troubleshooting + +- **Cannot reach the server.** Confirm the host and that port `1025` is open from your network. +- **Logon fails.** Check the username, password, and that the account is not locked. LDAP, Kerberos, and JWT logon are not yet supported; use `TD2` or `TDNEGO`. Picking an unsupported mechanism reports "unsupported logon mechanism" before connecting. diff --git a/docs/docs.json b/docs/docs.json index 3d91f68be..0501f792d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -88,7 +88,7 @@ }, { "group": "Analytics", - "pages": ["databases/clickhouse"] + "pages": ["databases/clickhouse", "databases/teradata"] } ] }, diff --git a/docs/index.mdx b/docs/index.mdx index ab2f43a36..49fbc1ba2 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -50,6 +50,7 @@ Native macOS client for 22 databases. Built with SwiftUI and AppKit, no Electron | DynamoDB | N/A (API-based) | Plugin | | BigQuery | N/A (API-based) | Plugin | | Snowflake | N/A (API-based) | Plugin | +| Teradata | 1025 | Plugin | | libSQL / Turso | N/A (API-based) | Plugin | | Elasticsearch | 9200 | Plugin | | SurrealDB | 8000 | Plugin | diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index 55cb93322..fb5965f5d 100755 --- a/scripts/release-all-plugins.sh +++ b/scripts/release-all-plugins.sh @@ -41,6 +41,7 @@ PLUGINS=( libsql elasticsearch surrealdb + teradata ) BUNDLED_PLUGINS=(