From e805e11ae896c6042266904278c8207122c2b042 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 17:53:54 +0700 Subject: [PATCH 01/24] feat(plugin-teradata): add pure-Swift TD2 protocol core --- Packages/TableProCore/Package.swift | 13 +- .../TableProTeradataCore/BigUInt.swift | 236 ++++++++++++++++++ .../Sources/TableProTeradataCore/ByteIO.swift | 105 ++++++++ .../Sources/TableProTeradataCore/Der.swift | 71 ++++++ .../TableProTeradataCore/DiffieHellman.swift | 51 ++++ .../TableProTeradataCore/GcmCipher.swift | 36 +++ .../TableProTeradataCore/LanMessage.swift | 124 +++++++++ .../Sources/TableProTeradataCore/Parcel.swift | 101 ++++++++ .../TableProTeradataCore/RecordDecoder.swift | 70 ++++++ .../TableProTeradataCore/Td2Token.swift | 80 ++++++ .../TableProTeradataCore/Td2Wrap.swift | 115 +++++++++ .../TeradataSchemaQueries.swift | 106 ++++++++ .../TableProTeradataCore/TeradataType.swift | 40 +++ .../BigUIntTests.swift | 68 +++++ .../TableProTeradataCoreTests/DHVectors.swift | 23 ++ .../ProtocolTests.swift | 179 +++++++++++++ .../TeradataSchemaQueriesTests.swift | 59 +++++ 17 files changed, 1476 insertions(+), 1 deletion(-) create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/BigUInt.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/Der.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/DiffieHellman.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/LanMessage.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/ProtocolTests.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift 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/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..dfff0efa6 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift @@ -0,0 +1,105 @@ +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 + } +} + +enum WireError: Error, CustomStringConvertible { + case truncated(String) + case malformed(String) + case server(code: Int, message: String) + case unsupported(String) + + var description: String { + switch self { + case .truncated(let what): return "truncated: \(what)" + case .malformed(let what): return "malformed: \(what)" + case .server(let code, let message): return "server error \(code): \(message)" + case .unsupported(let what): return "unsupported: \(what)" + } + } +} + +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 WireError.truncated("u8") } + defer { offset += 1 } + return bytes[offset] + } + + mutating func u16() throws -> UInt16 { + guard remaining >= 2 else { throw WireError.truncated("u16") } + defer { offset += 2 } + return UInt16(bytes[offset]) << 8 | UInt16(bytes[offset + 1]) + } + + mutating func u32() throws -> UInt32 { + guard remaining >= 4 else { throw WireError.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 WireError.truncated("take(\(count))") } + defer { offset += count } + return Array(bytes[offset..= count else { throw WireError.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..642353fab --- /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 WireError.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 WireError.malformed("expected OID tag") } + let length = try decodeLength(&reader) + let content = try reader.take(length) + guard let first = content.first else { throw WireError.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..05188dc72 --- /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 WireError.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 WireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") + } + guard tag.count == GcmCipher.tagLength else { + throw WireError.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..2adf39c77 --- /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 WireError.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 WireError.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..1d49bf0f5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift @@ -0,0 +1,70 @@ +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) + default: + throw WireError.unsupported("record field type \(column.baseCode)") + } + } + + 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.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 WireError.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..28ac8b1ff --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift @@ -0,0 +1,80 @@ +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 WireError.truncated("server TD2 token") } + let version = token[0] + let msgType = token[1] + guard version == 1 || version == 3 else { throw WireError.malformed("TD2 version \(version)") } + guard msgType == 1 || msgType == 2 else { throw WireError.malformed("TD2 msgType \(msgType)") } + let peerCapabilities = be32(token, 8) + guard token[16] >= 6 else { throw WireError.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 WireError.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 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..2d62d5edc --- /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 WireError.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 WireError.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 WireError.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/TeradataSchemaQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift new file mode 100644 index 000000000..b2cf94508 --- /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', 'V', 'M', 'P', 'E', 'F', 'R', 'Q') \ + 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/TeradataType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift new file mode 100644 index 000000000..e533579cc --- /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 } +} + +enum TeradataValue: Equatable { + 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/Tests/TableProTeradataCoreTests/BigUIntTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift new file mode 100644 index 000000000..a5a6c65ad --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import TableProTeradataCore + +final class BigUIntTests: XCTestCase { + func testRoundTripBytes() { + XCTAssertEqual(BigUInt(hex: "")!.description, "0") + XCTAssertEqual(BigUInt(hex: "00")!.description, "0") + XCTAssertEqual(BigUInt(hex: "01")!.description, "01") + XCTAssertEqual(BigUInt(hex: "ff")!.description, "ff") + XCTAssertEqual(BigUInt(hex: "0100")!.description, "0100") + XCTAssertEqual(BigUInt(hex: "deadbeef")!.description, "deadbeef") + let prime = BigUInt(hex: DHVectors.primeHex)! + XCTAssertEqual(prime.byteCount, 256) + XCTAssertEqual(prime.description, DHVectors.primeHex.lowercased()) + XCTAssertEqual(BigUInt(bytesBE: prime.bytesBE()), prime) + } + + func testComparisonAndSubtraction() { + let a = BigUInt(hex: "1000000000000000")! + let b = BigUInt(hex: "ffffffff")! + XCTAssertEqual(BigUInt.compare(a.limbs, b.limbs), 1) + XCTAssertEqual(BigUInt.compare(b.limbs, a.limbs), -1) + XCTAssertEqual(BigUInt.compare(a.limbs, a.limbs), 0) + XCTAssertEqual(a.subtracting(b).description, "0fffffff00000001") + } + + func testModSmall() { + XCTAssertEqual(BigUInt(hex: "64")!.mod(BigUInt(hex: "0a")!).description, "0") + XCTAssertEqual(BigUInt(hex: "65")!.mod(BigUInt(hex: "0a")!).description, "01") + XCTAssertEqual(BigUInt(hex: "deadbeefcafe")!.mod(BigUInt(hex: "010000")!).description, "cafe") + XCTAssertEqual(BigUInt(hex: "123456789abcdef0")!.mod(BigUInt(hex: "0100000000")!).description, + "9abcdef0") + } + + func testModPowSmallVectors() { + for entry in DHVectors.small { + let base = BigUInt(hex: entry.base)! + let exp = BigUInt(hex: entry.exp)! + let mod = BigUInt(hex: entry.mod)! + let expected = BigUInt(hex: entry.result)! + XCTAssertEqual(base.modPow(exp, modulus: mod), expected, + "\(entry.base)^\(entry.exp) mod \(entry.mod)") + } + } + + func testModPowDHVectors() { + let prime = BigUInt(hex: DHVectors.primeHex)! + let generator = BigUInt(hex: DHVectors.generatorHex)! + for pair in DHVectors.pairs { + let exp = BigUInt(hex: pair.x)! + let expected = BigUInt(hex: pair.y)! + XCTAssertEqual(generator.modPow(exp, modulus: prime), expected, "g^\(pair.x)") + } + } + + func testDiffieHellmanSharedSecretConsistency() { + let prime = BigUInt(hex: DHVectors.primeHex)! + let generator = BigUInt(hex: DHVectors.generatorHex)! + let privateA = BigUInt(hex: "7f3c91aa20d5e6b8c4d1f0937755dd11abcdef0123456789fedcba9876543210")! + let privateB = BigUInt(hex: "95e42b7d1c0a8f6e5d4c3b2a19087f6e5d4c3b2a1908f7e6d5c4b3a2918070605")! + let publicA = generator.modPow(privateA, modulus: prime) + let publicB = generator.modPow(privateB, modulus: prime) + let sharedFromA = publicB.modPow(privateA, modulus: prime) + let sharedFromB = publicA.modPow(privateB, modulus: prime) + XCTAssertEqual(sharedFromA, sharedFromB) + XCTAssertFalse(sharedFromA.isZero) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift new file mode 100644 index 000000000..53ce53267 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift @@ -0,0 +1,23 @@ +// Auto-generated DH test vectors (RFC 3526 Group 14 prime, g=2). Do not edit. +enum DHVectors { + static let primeHex = "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff" + static let generatorHex = "02" + static let pairs: [(x: String, y: String)] = [ + ("01", "02"), + ("02", "04"), + ("05", "20"), + ("1234", "841ad311a237c5b4417619073dc277d3324a355324082383e5a4b75dfceb099532e57984cb67bcbd0ae924e2f20df4ef35049c4b8d3173b6fdfb2594410072a9e63ad88797cff1393abcb9a5505513fd76b6525508b5256cabc88e0f8adf7c49bf98ca32e1d5dd40b8a9685b84f5fc7f26277837f8bcad0de6483c3988efd54523fcd6d1c568e800b63ef592c3d9e3972dc060129ba73a669bb5d5ff3063e76def06113da4395e3ae0b4080647f17fe9adfddc42d5d241c7507478753109e15719e318ff0b5b00f5d1e6931c7e138d4043515122197d2a4962216b948630b2e5bb8589dd35a1d1b2e78967adba9ec8ca68e761ca29c6b881ad783b6ed7507346"), + ("deadbeef", "8cf8f34206ef1c591b3fe2c5ddec332fd79b3ade33f2bf2fd291993006cd0225023a12eb285d28930ae32e0deb8b3156d9fef7bf20f52f144356b0d6951a864fc1b548a52964ad09729cf5dc683b4a4acc6dbeb8855e4f3d8ec9e2efa6d6b2d312db86b01381a31ce47b0e3c0e90877cda66ff85465963f867ee020bd647cbb4e6023726ecfcf67da8e93116e0c1aded2c60d7a802c55fa133be6dee49d70e33d739acb7860884fb25e02a67e980197021315098addb221c9256015071dafd7a1d9f53353b86999f3830a16f9d2304eb2b7005698d590b5daa5030cd5b46119b47d1150b64dfb39edc50e5048a486c882885cbb2c4c21ec3e7744f54481afab3"), + ("01000000", "53649e1f1959dae3b9d253e56134dcd2fab6ba41cd89f43a32c2bfe2d8442e957ed83073a4d0893a757bbf9fce2e93772525f51256b3162bf1942173e426e680d8e649ae118e4a10321013390e742e27e23a5c4cc0a485b2959fdb7dec5297a0cc4b513477bcabcda08270198c10d75e5f62a26adafa40bf7b024928cdfb53703fcc651698c5c0f7571d12f5976f525883faf6fe6353badb980a076621ec27701935d2e35d2d02a73a78bdb193c0e894342f7478df47baa1f32c37420b1afd9c6e1edb8a57aeaf2dc0d42fff01f67f53ac56b55aa62796e0622a23a40b81330df927525dc8cc4bedf182fff210615b245dfd0059dfb9f26fe6f969a596503b17"), + ("010000000000000000000000000000000000000000000000000000000000000007", "906f66166f41a25762ef3cf4de3e8b7108529e83a6994a7a3d7287719c72145dd899301a2471fe1035ad595f7cf701b6188d7093dcdc806577255b33b837d41281e3d27418fd478b83ba8828db9ee171f2abc805a877252b87e48c45d3b99541ecc52ee4956fdc71b3e920e75a53733dad00b91d9c70a306faa2e6b28314867f84235ed6428a0a6746b0faeffdd66481ffe32cbee48fb15fdccc582d37510936c2218620f320dcc93c14591c697e6d45fdce883c4ba5689710541328f5af663b83eb55741cde466fd022dfc1bdbe4006f75d0a2d61dbc4a5018e89cd25d7e6ae90f739813e3581aad83c97188a0a4795a73c7b57ece5a8c621648104f4204477"), + ("8f7ea53c0563fb20b2597bc5f6b67fb13cdce41001871be1f77c2bc3021c50ce8cfbc7c83cb2b4ce025ed03069aef471ca13d5d7b09fdd473e5a137a973318fb", "27aff6769a2705b5a162fcce8443b30d78315a1b82d59a2a59523e9c09d1fcde349af7807dcfe24138df3582d32fc7c38328af642261e458d568b6cf8470fca079f0ee8b9ee0a184a160c19e69d21bc269bb0e0f542b95d8d018d0de98352bc847964d2ec67a2c060163fef35aa2326e499433504621cd776d4e4b2a055870408f44dd400c11b53ad8a2eca69e36e8cdae4f7c6bf1d14f721c183d5488f4e6bb585dea0bb6648e1e45ba553974d9d782293c2616705dfeffaaab881d91bba6614a94f9ebc9608b32123d2eb4d27783938e9a278c2c82aa2806439c2ed754faf6ac7158da01bafe6a95ddeb3d3cc871b841f9943820f5a5f59a2bc9b012c1c7d0"), + ("445384194288abce1c17ac5ee458c8a3d73727c861ba079c54b138fbc0150f610f645f30bc4e7072a67751bb37a32311038b743c82e584f098e2967f42a896bd", "f7cbf6e39f08ae044424374ac5b2e42bc6d0ffe2aec5ac40b35fef75dac0b6e18fc3a9d54d83f49f5023bb28f8029964f389bd2909c958549fb2de01c4c320f8bb2744fe36b487293032c06f1fb85436cf9360f3b349984010453a85eff7b49e3dc8f19a28819afd2f00d8e5ba485abb72d4b82470e08a7310bac89c70cdfabe55dc0786a49a3460fe2d110a27e341cb4c7d93981d06f4e35858c036a38a97c8c036292d4ae26878283cd8dbadc5594c1db877fdaea93666f47b3fbf2ec936b6cc3254b1984c01fab38d4864f895c30ec08c00badf13b05a90abf0e06421c15af985051b0df1646226c7858a8600854c64bfa07dab10ae287e18868552fe4edf"), + ("07484e34cf328f0a82649aaf6873c40ecb6c03e09c48cf9708c7bd4afb6963601bdb409fbb33bd8cd53ab94af4e0ec96d783b78bc43f4e2f988ec825693072f3d1c438dd766172659ba2e747cf1b4bcdc2490ca21d325269bf54899daafa99dbc2aee220cd50bd5616f4d4bacd79b731de8daf91ff964dc9f82854529d8b6c36cedcaca521e4e0bb62141ccde825bb5fe31fbf26a1b43c2b3b8348b58690123f7a62122974f46e1bbc4f9465edebf9ba6de1c14779f8f0eeb34d644b0fe983abad8a708007e83000d60909c9bb6349590cb040104d7aec2ed5e8b1be389a3d8eb3338ba86188522ed360edeeeb6c747628ee6257abe36622a0aca5483ba77de1", "5a2f0d41f941033743b985df48371103fad67269a7daf2390e8c928b02d24546344eea38aeac2005c6bdd3ece343fb96068f93dede0933ceee3f720bd98ad594deb9b11e9a6d952f303bf1d5186967fa59ed478effa33a0b0a7edadacc0bc91a87a3604a016792020ab8f8f9f46367b3a70c8e7fc2062b22b9e503b433a5778eefa9b8ea324715731ba223bd10861a4619a44208f59bb0546424cb36367452019c7520ab887b801e6931de2e311300cde74d3207407c9b1cc48870d679f7962fefc713949bfe851f27711da41ed2c6d1d43c7c89cf171fe82cff8b9a8311b2f46254cb1985ea22377a3bbe3f1f41a064734d9f99bf964e986cdeddd89f23679f"), + ] + static let small: [(base: String, exp: String, mod: String, result: String)] = [ + ("05", "75", "f1", "1b"), + ("07", "00", "f1", "01"), + ("03", "03e8", "0f4243", "011e00"), + ("02", "0fff", "fffffffb", "73cdcb4d"), + ] +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/ProtocolTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/ProtocolTests.swift new file mode 100644 index 000000000..fdc97f277 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/ProtocolTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import TableProTeradataCore + +final class LanMessageTests: XCTestCase { + func testHeaderLayout() { + let message = LanMessage(kind: .start, body: [0xAA, 0xBB], sessionNumber: 0x01020304, + requestNumber: 0x11223344, hostCharSet: 0x7F) + let encoded = message.encoded() + XCTAssertEqual(encoded.count, LanMessage.headerLength + 2) + XCTAssertEqual(encoded[0], 3) + XCTAssertEqual(encoded[1], LanMessage.requestClass) + XCTAssertEqual(encoded[2], MessageKind.start.rawValue) + XCTAssertEqual(Array(encoded[20..<24]), [0x01, 0x02, 0x03, 0x04]) + XCTAssertEqual(Array(encoded[32..<36]), [0x11, 0x22, 0x33, 0x44]) + XCTAssertEqual(encoded[37], 0x7F) + XCTAssertEqual(Array(encoded[52..<54]), [0xAA, 0xBB]) + } + + func testSplitBodyLength() { + let body = [UInt8](repeating: 0x5A, count: 70000) + let encoded = LanMessage(kind: .cont, body: body).encoded() + let header = Array(encoded[0.. [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/TeradataSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift new file mode 100644 index 000000000..8bdb8ab6d --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift @@ -0,0 +1,59 @@ +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 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'")) + } +} From d2b2be52229b26f576c23fcfa70f626723f2450e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:03:09 +0700 Subject: [PATCH 02/24] feat(plugin-teradata): add Teradata connection and session layer --- .../Sources/TableProTeradataCore/ByteIO.swift | 26 +- .../Sources/TableProTeradataCore/Der.swift | 6 +- .../TableProTeradataCore/GcmCipher.swift | 6 +- .../Sources/TableProTeradataCore/Parcel.swift | 4 +- .../TableProTeradataCore/RecordDecoder.swift | 4 +- .../TableProTeradataCore/Td2Token.swift | 22 +- .../TableProTeradataCore/Td2Wrap.swift | 6 +- .../TeradataConnection.swift | 231 ++++++++++++++++++ .../TeradataConnectionConfig.swift | 78 ++++++ .../TeradataMessages.swift | 138 +++++++++++ .../TeradataResultParser.swift | 83 +++++++ .../TableProTeradataCore/TeradataSocket.swift | 123 ++++++++++ .../TableProTeradataCore/TeradataType.swift | 2 +- 13 files changed, 689 insertions(+), 40 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift b/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift index dfff0efa6..8d7b3e65a 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/ByteIO.swift @@ -16,22 +16,6 @@ enum HexBytes { } } -enum WireError: Error, CustomStringConvertible { - case truncated(String) - case malformed(String) - case server(code: Int, message: String) - case unsupported(String) - - var description: String { - switch self { - case .truncated(let what): return "truncated: \(what)" - case .malformed(let what): return "malformed: \(what)" - case .server(let code, let message): return "server error \(code): \(message)" - case .unsupported(let what): return "unsupported: \(what)" - } - } -} - struct ByteWriter { private(set) var bytes: [UInt8] = [] @@ -74,32 +58,32 @@ struct ByteReader { var remaining: Int { bytes.count - offset } mutating func u8() throws -> UInt8 { - guard remaining >= 1 else { throw WireError.truncated("u8") } + guard remaining >= 1 else { throw TeradataWireError.truncated("u8") } defer { offset += 1 } return bytes[offset] } mutating func u16() throws -> UInt16 { - guard remaining >= 2 else { throw WireError.truncated("u16") } + 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 WireError.truncated("u32") } + 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 WireError.truncated("take(\(count))") } + guard remaining >= count else { throw TeradataWireError.truncated("take(\(count))") } defer { offset += count } return Array(bytes[offset..= count else { throw WireError.truncated("skip(\(count))") } + guard remaining >= 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 index 642353fab..f321f96cf 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/Der.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Der.swift @@ -21,7 +21,7 @@ enum Der { 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 WireError.malformed("invalid OID \(dotted)") + throw TeradataWireError.malformed("invalid OID \(dotted)") } var content: [UInt8] = [UInt8(parts[0] * 40 + parts[1])] for component in parts.dropFirst(2) { @@ -32,10 +32,10 @@ enum Der { static func decodeObjectIdentifier(_ bytes: [UInt8]) throws -> String { var reader = ByteReader(bytes) - guard try reader.u8() == objectIdentifierTag else { throw WireError.malformed("expected OID tag") } + 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 WireError.malformed("empty OID") } + 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() { diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift b/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift index 05188dc72..26b599e62 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/GcmCipher.swift @@ -13,7 +13,7 @@ struct GcmCipher { func seal(_ plaintext: [UInt8], nonce nonceBytes: [UInt8], aad: [UInt8] = []) throws -> (ciphertext: [UInt8], tag: [UInt8]) { guard nonceBytes.count == GcmCipher.nonceLength else { - throw WireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") + 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)) @@ -22,10 +22,10 @@ struct GcmCipher { func open(ciphertext: [UInt8], nonce nonceBytes: [UInt8], tag: [UInt8], aad: [UInt8] = []) throws -> [UInt8] { guard nonceBytes.count == GcmCipher.nonceLength else { - throw WireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") + throw TeradataWireError.malformed("GCM nonce must be \(GcmCipher.nonceLength) bytes") } guard tag.count == GcmCipher.tagLength else { - throw WireError.malformed("GCM tag must be \(GcmCipher.tagLength) bytes") + throw TeradataWireError.malformed("GCM tag must be \(GcmCipher.tagLength) bytes") } let box = try AES.GCM.SealedBox( nonce: try AES.GCM.Nonce(data: Data(nonceBytes)), diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift index 2adf39c77..e6cef5e0e 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Parcel.swift @@ -87,12 +87,12 @@ struct Parcel { flavor = rawFlavor ^ alternateFlag _ = try reader.u16() total = Int(try reader.u32()) - guard total >= alternateHeaderLength else { throw WireError.malformed("APH length \(total)") } + 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 WireError.malformed("parcel length \(total)") } + guard total >= standardHeaderLength else { throw TeradataWireError.malformed("parcel length \(total)") } parcels.append(Parcel(flavor: flavor, body: try reader.take(total - standardHeaderLength))) } } diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift index 1d49bf0f5..0415ef6e9 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift @@ -38,7 +38,7 @@ enum RecordDecoder { let length = Int(try reader.u16()) return try reader.take(length) default: - throw WireError.unsupported("record field type \(column.baseCode)") + throw TeradataWireError.unsupported("record field type \(column.baseCode)") } } @@ -64,7 +64,7 @@ enum RecordDecoder { let day = Int(packed % 100) return .text(String(format: "%04d-%02d-%02d", year, month, day)) default: - throw WireError.unsupported("record field type \(column.baseCode)") + throw TeradataWireError.unsupported("record field type \(column.baseCode)") } } } diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift index 28ac8b1ff..08c68bdca 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Token.swift @@ -34,13 +34,13 @@ enum Td2Token { } static func parseServerParams(_ token: [UInt8]) throws -> ServerParams { - guard token.count >= keyDataOffset else { throw WireError.truncated("server TD2 token") } + 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 WireError.malformed("TD2 version \(version)") } - guard msgType == 1 || msgType == 2 else { throw WireError.malformed("TD2 msgType \(msgType)") } + 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 WireError.malformed("TD2 lib version \(token[16])") } + 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)) @@ -49,7 +49,7 @@ enum Td2Token { var cursor = keyDataOffset func slice(_ length: Int, _ what: String) throws -> [UInt8] { - guard cursor + length <= token.count else { throw WireError.truncated("TD2 \(what)") } + guard cursor + length <= token.count else { throw TeradataWireError.truncated("TD2 \(what)") } defer { cursor += length } return Array(token[cursor.. 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) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift index 2d62d5edc..85108c9f9 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/Td2Wrap.swift @@ -49,7 +49,7 @@ enum Td2Wrap { static func unwrap(der: [UInt8], key: [UInt8]) throws -> [UInt8] { var reader = ByteReader(der) - guard try reader.u8() == 0xE0 else { throw WireError.malformed("wrap: expected E0") } + guard try reader.u8() == 0xE0 else { throw TeradataWireError.malformed("wrap: expected E0") } _ = try Der.decodeLength(&reader) var ciphertext: [UInt8] = [] @@ -78,7 +78,7 @@ enum Td2Wrap { } guard !ciphertext.isEmpty, authTag.count == tagLen else { - throw WireError.malformed("wrap: missing ciphertext/tag") + throw TeradataWireError.malformed("wrap: missing ciphertext/tag") } var nonce = [UInt8]() nonce.append(UInt8((msgLength >> 24) & 0xFF)) @@ -89,7 +89,7 @@ enum Td2Wrap { 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 WireError.malformed("wrap: short plaintext") } + guard buffer.count >= tagLen else { throw TeradataWireError.malformed("wrap: short plaintext") } return Array(buffer[0.. 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 sawEndRequest = false + while !sawEndRequest { + let batch = try readReply() + parcels.append(contentsOf: batch) + if batch.contains(where: { $0.flavor == ParcelFlavor.endRequest.rawValue }) { + sawEndRequest = true + } else if batch.contains(where: { $0.flavor == ParcelFlavor.endStatement.rawValue }) { + sawEndRequest = 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() + } + + 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) + 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..ac4286cb9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift @@ -0,0 +1,78 @@ +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 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 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, + 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.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..bcc75502f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift @@ -0,0 +1,138 @@ +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, 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=Y;ES=\(session);" + + "LM=\(logMech);LOB=Y;PART=DBC/SQL;SCS=UTF8;SIP=Y;SSLM=DISABLE;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..cba38ed70 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift @@ -0,0 +1,83 @@ +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 { activityCount(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/TeradataSocket.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift new file mode 100644 index 000000000..84f3503c9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift @@ -0,0 +1,123 @@ +import Darwin +import Foundation + +public enum TeradataWireError: Error, CustomStringConvertible { + 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 "server error \(code): \(message)" + case .unsupported(let what): return "unsupported: \(what)" + case .cancelled: return "cancelled" + } + } +} + +final class TeradataSocket { + 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/TeradataType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift index e533579cc..44c90928d 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataType.swift @@ -9,7 +9,7 @@ struct ColumnMeta { var baseCode: UInt16 { typeCode & 0xFFFE } } -enum TeradataValue: Equatable { +public enum TeradataValue: Equatable, Sendable { case null case integer(Int64) case double(Double) From dda9096d540cf271f6148ff705c9796747a03664 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:05:25 +0700 Subject: [PATCH 03/24] feat(plugin-teradata): map Teradata column types for structure and grid --- .../TeradataColumnType.swift | 79 +++++++++++++++++++ .../TeradataColumnTypeTests.swift | 32 ++++++++ 2 files changed, 111 insertions(+) create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataColumnTypeTests.swift diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift new file mode 100644 index 000000000..c23e7658a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift @@ -0,0 +1,79 @@ +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 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/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) + } +} From 1252c8465181d03e9f5a7aa8ae2ba038b4d5d5e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:14:27 +0700 Subject: [PATCH 04/24] feat(plugin-teradata): add the Teradata driver plugin bundle --- .../TableProCoreTypes/DatabaseType.swift | 4 +- .../TeradataColumnType.swift | 26 +++ Plugins/TeradataDriverPlugin/Info.plist | 8 + .../TeradataAsyncConnection.swift | 43 +++++ .../TeradataDriverPlugin/TeradataPlugin.swift | 177 ++++++++++++++++++ .../TeradataPluginDriver+Schema.swift | 150 +++++++++++++++ 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 Plugins/TeradataDriverPlugin/Info.plist create mode 100644 Plugins/TeradataDriverPlugin/TeradataAsyncConnection.swift create mode 100644 Plugins/TeradataDriverPlugin/TeradataPlugin.swift create mode 100644 Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift 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/TeradataColumnType.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift index c23e7658a..155fe1406 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataColumnType.swift @@ -57,6 +57,32 @@ public enum TeradataColumnType { } } + 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, 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..5be0c3ae9 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -0,0 +1,177 @@ +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 = 1025 + 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 supportsSSL = false + 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", + ] + ) + + 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] + } + + 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 { + let coreConfig = TeradataConnectionConfig( + host: config.host, + port: UInt16(config.port), + username: config.username, + password: config.password, + database: config.database.isEmpty ? nil : config.database, + logMech: logMech, + transactionMode: transactionMode) + 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 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+Schema.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift new file mode 100644 index 000000000..74ec46176 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift @@ -0,0 +1,150 @@ +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 { + switch kind.trimmingCharacters(in: .whitespaces) { + case "V": return "VIEW" + case "M": return "MACRO" + case "P", "E": return "PROCEDURE" + case "F", "R": return "FUNCTION" + case "G": return "TRIGGER" + default: return "TABLE" + } + } +} From a67829421d20af2a994eb95316b8858d43a3c2d7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:16:06 +0700 Subject: [PATCH 05/24] docs(plugin-teradata): document Teradata driver and wire up plugin CI --- .github/workflows/build-plugin.yml | 5 ++++ CHANGELOG.md | 4 ++++ docs/databases/teradata.mdx | 37 ++++++++++++++++++++++++++++++ docs/docs.json | 2 +- docs/index.mdx | 1 + scripts/release-all-plugins.sh | 1 + 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs/databases/teradata.mdx 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..d8e2ded54 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 to Teradata over TD2 logon with encrypted sessions, browse databases, tables, and columns, and run SQL. (#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/docs/databases/teradata.mdx b/docs/databases/teradata.mdx new file mode 100644 index 000000000..d88830cc8 --- /dev/null +++ b/docs/databases/teradata.mdx @@ -0,0 +1,37 @@ +--- +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. Default `TD2`. | +| Transaction Mode | `DEFAULT`, `ANSI`, or `TERA`. Default `DEFAULT`. | + +## 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. + +## 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 and Kerberos logon are not yet supported; use `TD2`. 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=( From 2ad84d7f3f83365a6b691ddb692dda598d72fc0b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:33:24 +0700 Subject: [PATCH 06/24] build(plugin-teradata): add TeradataDriver Xcode target and fix plugin build --- .../TeradataSchemaQueries.swift | 2 +- .../TeradataDriverPlugin/TeradataPlugin.swift | 11 +- TablePro.xcodeproj/project.pbxproj | 195 +++++++++++++++--- 3 files changed, 174 insertions(+), 34 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift index b2cf94508..7afdac6fe 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift @@ -46,7 +46,7 @@ public enum TeradataSchemaQueries { """ } - public static func showTableDDL(database: String, table: String) -> String { + public static func showTableDDL(database: String?, table: String) -> String { "SHOW TABLE \(qualifiedName(database: database, table: table))" } diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 5be0c3ae9..e5a6455bd 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -89,7 +89,16 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { "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") diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 8c5d395f6..45fc5cbe8 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; path = TeradataDriver.bundle; 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 */ @@ -688,6 +700,8 @@ }; 5A32BC012F9D5F1300BAEB5F /* mcp-server */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = "mcp-server"; sourceTree = ""; }; @@ -873,6 +887,8 @@ }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = TableProTests; sourceTree = ""; }; @@ -886,12 +902,32 @@ }; 5AF00A122FB9000000000001 /* TableProUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); 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 +1199,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 = ( @@ -1174,10 +1218,10 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( - 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */, - 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */, - 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */, - 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, + 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */, + 5E1A500500000000000000B0 /* ElasticsearchDriverPlugin */, + 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */, + 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */, 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, @@ -1210,6 +1254,7 @@ 5A05FBC72F3EDF7500819CD7 /* Recovered References */, 5AEA8B482F6808E90040461A /* Frameworks */, 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, + 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */, ); sourceTree = ""; }; @@ -1250,11 +1295,12 @@ 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */, 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */, 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */, + A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */, ); name = Products; sourceTree = ""; }; - 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */ = { + 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */ = { isa = PBXGroup; children = ( 5ABQR00200000000000000A1 /* BigQueryAuth.swift */, @@ -1269,7 +1315,7 @@ path = Plugins/BigQueryDriverPlugin; sourceTree = ""; }; - 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */ = { + 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */ = { isa = PBXGroup; children = ( 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */, @@ -1283,7 +1329,7 @@ path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; }; - 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { + 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */ = { isa = PBXGroup; children = ( 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */, @@ -1299,11 +1345,12 @@ 5AEA8B482F6808E90040461A /* Frameworks */ = { isa = PBXGroup; children = ( + 0AF412EF6616DCE513B6EAEC /* OS X */, ); name = Frameworks; sourceTree = ""; }; - 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */ = { + 5E1A500500000000000000B0 /* ElasticsearchDriverPlugin */ = { isa = PBXGroup; children = ( 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */, @@ -1426,8 +1473,6 @@ 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */, ); name = SurrealDBDriverPlugin; - packageProductDependencies = ( - ); productName = SurrealDBDriverPlugin; productReference = 5A1862000000000000000002 /* SurrealDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1448,8 +1493,6 @@ 5A32BC012F9D5F1300BAEB5F /* mcp-server */, ); name = "mcp-server"; - packageProductDependencies = ( - ); productName = "mcp-server"; productReference = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; productType = "com.apple.product-type.tool"; @@ -1470,8 +1513,6 @@ 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, ); name = LibSQLDriverPlugin; - packageProductDependencies = ( - ); productName = LibSQLDriverPlugin; productReference = 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1938,8 +1979,6 @@ dependencies = ( ); name = BigQueryDriverPlugin; - packageProductDependencies = ( - ); productName = BigQueryDriverPlugin; productReference = 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1957,8 +1996,6 @@ dependencies = ( ); name = DynamoDBDriverPlugin; - packageProductDependencies = ( - ); productName = DynamoDBDriverPlugin; productReference = 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1979,8 +2016,6 @@ 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, ); name = CloudflareD1DriverPlugin; - packageProductDependencies = ( - ); productName = CloudflareD1DriverPlugin; productReference = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1998,8 +2033,6 @@ dependencies = ( ); name = EtcdDriverPlugin; - packageProductDependencies = ( - ); productName = EtcdDriverPlugin; productReference = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -2038,12 +2071,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" */; @@ -2160,11 +2214,11 @@ mainGroup = 5A1091BE2EF17EDC0055EA7C; minimizedProjectReferenceProxies = 1; packageReferences = ( - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */, + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */, 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, ); preferredProjectObjectVersion = 77; @@ -2205,6 +2259,7 @@ 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */, 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */, FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */, + A95F5AB41510A4EFAAA0F38C /* TeradataDriver */, ); }; /* End PBXProject section */ @@ -2434,6 +2489,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CEA116D19B343A1E94648AFE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2714,6 +2776,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A71ED1E7D88AFCF2B8B4757E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -4858,9 +4927,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 = ( @@ -5170,15 +5296,15 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditLanguages; }; - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */ = { + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditSourceEditor; }; - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = { + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/TableProCore; }; @@ -5214,7 +5340,7 @@ /* Begin XCSwiftPackageProductDependency section */ 5A1MPORT000000000000A010 /* TableProImport */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; productName = TableProImport; }; 5A32BBFA2F9D5EAB00BAEB5F /* X509 */ = { @@ -5250,9 +5376,14 @@ }; 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; productName = TableProMSSQLCore; }; + F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; + productName = TableProTeradataCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; From 1ec1bf36897709da25f6eb51347de51b03298c06 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:36:10 +0700 Subject: [PATCH 07/24] feat(plugin-teradata): show Teradata in the database chooser before install --- ...ginMetadataRegistry+RegistryDefaults.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 3d6d0327b..9b86de9da 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: false, + 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: false, + 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, From ff8f386b82c35b2b7e5068ae21165bc26fe33ea0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:41:07 +0700 Subject: [PATCH 08/24] feat(plugin-teradata): generate INSERT/UPDATE/DELETE for grid editing --- .../TeradataDriverPlugin/TeradataPlugin.swift | 15 ++++ .../TeradataPluginDriver+Editing.swift | 89 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index e5a6455bd..915329713 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -176,6 +176,21 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { 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() } diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift new file mode 100644 index 000000000..0a39c2a6c --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift @@ -0,0 +1,89 @@ +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + func sqlLiteral(_ value: PluginCellValue) -> String { + switch value { + case .null: + return "NULL" + case .text(let string): + if PluginNumericLiteral.isValid(string) { return 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 + } +} From 5a1cf5188d590d67b67079e0a7369f952d7b0a05 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:45:10 +0700 Subject: [PATCH 09/24] feat(plugin-teradata): generate CREATE/ALTER TABLE and index DDL for schema editing --- .../TeradataDriverPlugin/TeradataPlugin.swift | 5 +- .../TeradataPluginDriver+DDL.swift | 68 +++++++++++++++++++ TablePro.xcodeproj/project.pbxproj | 42 +++++------- ...ginMetadataRegistry+RegistryDefaults.swift | 2 +- 4 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 Plugins/TeradataDriverPlugin/TeradataPluginDriver+DDL.swift diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 915329713..d4ce16430 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -37,7 +37,7 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Teradata" static let databaseDisplayName = "Teradata" static let iconName = "teradata-icon" - static let defaultPort = 1025 + static let defaultPort = 1_025 static let additionalConnectionFields: [ConnectionField] = [ ConnectionField( id: "teradataLogMech", label: "Logon Mechanism", placeholder: "TD2", defaultValue: "TD2"), @@ -53,6 +53,7 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseGroupingStrategy: GroupingStrategy = .byDatabase static let containerEntityName = "Database" static let supportsForeignKeys = true + static let supportsSchemaEditing = true static let supportsSSL = false static let systemDatabaseNames: [String] = [ "DBC", "Sys", "SysAdmin", "SystemFe", "SYSLIB", "SYSUDTLIB", "SYSSPATIAL", @@ -121,7 +122,7 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var capabilities: PluginCapabilities { - [.cancelQuery, .transactions] + [.cancelQuery, .transactions, .alterTableDDL] } init(config: DriverConnectionConfig) { 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/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 45fc5cbe8..7cb8f8ad4 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -426,7 +426,7 @@ 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; path = TeradataDriver.bundle; sourceTree = BUILT_PRODUCTS_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 = ""; }; @@ -700,8 +700,6 @@ }; 5A32BC012F9D5F1300BAEB5F /* mcp-server */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = "mcp-server"; sourceTree = ""; }; @@ -887,8 +885,6 @@ }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TableProTests; sourceTree = ""; }; @@ -902,8 +898,6 @@ }; 5AF00A122FB9000000000001 /* TableProUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TableProUITests; sourceTree = ""; }; @@ -1218,10 +1212,10 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( - 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */, - 5E1A500500000000000000B0 /* ElasticsearchDriverPlugin */, - 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */, - 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */, + 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */, + 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */, + 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */, + 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */, 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, @@ -1300,7 +1294,7 @@ name = Products; sourceTree = ""; }; - 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */ = { + 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */ = { isa = PBXGroup; children = ( 5ABQR00200000000000000A1 /* BigQueryAuth.swift */, @@ -1315,7 +1309,7 @@ path = Plugins/BigQueryDriverPlugin; sourceTree = ""; }; - 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */ = { + 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */ = { isa = PBXGroup; children = ( 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */, @@ -1329,7 +1323,7 @@ path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; }; - 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */ = { + 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { isa = PBXGroup; children = ( 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */, @@ -1350,7 +1344,7 @@ name = Frameworks; sourceTree = ""; }; - 5E1A500500000000000000B0 /* ElasticsearchDriverPlugin */ = { + 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */ = { isa = PBXGroup; children = ( 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */, @@ -2214,11 +2208,11 @@ mainGroup = 5A1091BE2EF17EDC0055EA7C; minimizedProjectReferenceProxies = 1; packageReferences = ( - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */, + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */, 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */, + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */, + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, ); preferredProjectObjectVersion = 77; @@ -5296,15 +5290,15 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */ = { + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditLanguages; }; - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */ = { + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditSourceEditor; }; - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */ = { + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/TableProCore; }; @@ -5340,7 +5334,7 @@ /* Begin XCSwiftPackageProductDependency section */ 5A1MPORT000000000000A010 /* TableProImport */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProImport; }; 5A32BBFA2F9D5EAB00BAEB5F /* X509 */ = { @@ -5376,12 +5370,12 @@ }; 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProTeradataCore; }; /* End XCSwiftPackageProductDependency section */ diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 9b86de9da..8abeaa3fc 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -211,7 +211,7 @@ extension PluginMetadataRegistry { )), ("Teradata", PluginMetadataSnapshot( displayName: "Teradata", iconName: "teradata-icon", defaultPort: 1_025, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "teradata", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .database, supportsHealthMonitor: false, urlSchemes: ["teradata"], From 9fec9b284f61ac424679829f8e52356e8c3660be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:47:01 +0700 Subject: [PATCH 10/24] feat(plugin-teradata): add Teradata brand icon asset --- .../teradata-icon.imageset/Contents.json | 16 ++++++++++++++++ .../teradata-icon.imageset/teradata.svg | 1 + .../teradata-icon.imageset/Contents.json | 16 ++++++++++++++++ .../teradata-icon.imageset/teradata.svg | 1 + .../teradata-icon.imageset/Contents.json | 16 ++++++++++++++++ .../teradata-icon.imageset/teradata.svg | 1 + 6 files changed, 51 insertions(+) create mode 100644 TablePro/Assets.xcassets/teradata-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/teradata-icon.imageset/teradata.svg create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/teradata-icon.imageset/teradata.svg create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/teradata-icon.imageset/teradata.svg 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/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 From 4500ce947bc21a07f2c6b499ec08dc0d35ca0266 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 18:53:06 +0700 Subject: [PATCH 11/24] feat(plugin-teradata): reject unsupported logon mechanisms before connecting --- .../TeradataConnection.swift | 11 +++++++ .../TeradataLogMechTests.swift | 29 +++++++++++++++++++ docs/databases/teradata.mdx | 4 +-- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataLogMechTests.swift diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift index 81862542b..ae38e6de1 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift @@ -19,6 +19,7 @@ public final class TeradataConnection { public var isConnected: Bool { socket != nil } public func connect() throws { + try validateLogMech() let socket = try TeradataSocket( host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) self.socket = socket @@ -76,6 +77,16 @@ public final class TeradataConnection { socket?.cancel() } + 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(), 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/docs/databases/teradata.mdx b/docs/databases/teradata.mdx index d88830cc8..501728ee2 100644 --- a/docs/databases/teradata.mdx +++ b/docs/databases/teradata.mdx @@ -18,7 +18,7 @@ Teradata is a downloadable driver. Open **Settings > Plugins**, find Teradata, a | 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. Default `TD2`. | +| Logon Mechanism | The logon method. `TD2` and `TDNEGO` are supported. Default `TD2`. | | Transaction Mode | `DEFAULT`, `ANSI`, or `TERA`. Default `DEFAULT`. | ## Databases and objects @@ -34,4 +34,4 @@ Identifiers are quoted with double quotes. Teradata has no `LIMIT`, so TablePro ## 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 and Kerberos logon are not yet supported; use `TD2`. +- **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. From 779369d963dee83538eafe3b772f32b1f841f7a1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 19:50:52 +0700 Subject: [PATCH 12/24] feat(plugin-teradata): add TLS transport over the gateway port --- CHANGELOG.md | 2 +- .../TeradataConnection.swift | 24 ++- .../TeradataConnectionConfig.swift | 30 ++++ .../TeradataMessages.swift | 4 +- .../TableProTeradataCore/TeradataSocket.swift | 9 +- .../TeradataTLSTransport.swift | 157 ++++++++++++++++++ .../TeradataTLSTests.swift | 32 ++++ .../TeradataDriverPlugin/TeradataPlugin.swift | 5 +- .../TeradataSSLMapping.swift | 26 +++ ...ginMetadataRegistry+RegistryDefaults.swift | 2 +- docs/databases/teradata.mdx | 11 ++ 11 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift create mode 100644 Plugins/TeradataDriverPlugin/TeradataSSLMapping.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e2ded54..1f0294f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Teradata support through a downloadable driver written in native Swift. Connect to Teradata over TD2 logon with encrypted sessions, browse databases, tables, and columns, and run SQL. (#1867) +- 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 diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift index ae38e6de1..312541285 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift @@ -3,7 +3,7 @@ import Foundation public final class TeradataConnection { private let config: TeradataConnectionConfig - private var socket: TeradataSocket? + private var socket: (any TeradataTransport)? private var sessionNumber: UInt32 = 0 private var aesKey: [UInt8] = [] private var charsetCode: UInt8 = 0xBF @@ -20,8 +20,7 @@ public final class TeradataConnection { public func connect() throws { try validateLogMech() - let socket = try TeradataSocket( - host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) + let socket = try makeTransport() self.socket = socket serverIP = Self.resolveAddress(config.host) ?? config.host @@ -77,6 +76,22 @@ public final class TeradataConnection { socket?.cancel() } + private func makeTransport() throws -> any TeradataTransport { + guard config.tls.enabled else { + return try TeradataSocket( + host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) + } + do { + return try TeradataTLSTransport( + host: config.host, port: config.port, options: config.tls, + timeoutSeconds: config.connectTimeoutSeconds) + } 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: @@ -147,7 +162,8 @@ public final class TeradataConnection { let attributes = TeradataMessages.clientAttributesParcel( username: config.username, session: sessionNumber, charset: charsetCode, serverIP: serverIP, logMech: config.logMech.rawValue, - transactionMode: config.transactionMode.rawValue, database: config.database) + transactionMode: config.transactionMode.rawValue, sslMode: config.tls.modeLabel, + database: config.database) let body = logon.encoded() + sessionOptions.encoded() + connect.encoded() + logonData.encoded() + attributes.encoded() try sendEncrypted(kind: .connect, body: body, requestNumber: 0) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift index ac4286cb9..7e2adb11a 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift @@ -22,6 +22,33 @@ public enum TeradataTransactionMode: String, Sendable { } } +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 init( + enabled: Bool = false, + allowPlaintextFallback: Bool = false, + verifiesCertificate: Bool = false, + verifiesHostname: Bool = false, + caCertificatePath: String = "", + modeLabel: String = "DISABLE" + ) { + self.enabled = enabled + self.allowPlaintextFallback = allowPlaintextFallback + self.verifiesCertificate = verifiesCertificate + self.verifiesHostname = verifiesHostname + self.caCertificatePath = caCertificatePath + self.modeLabel = modeLabel + } + + public static let disabled = TeradataTLSOptions() +} + public struct TeradataConnectionConfig: Sendable { public var host: String public var port: UInt16 @@ -31,6 +58,7 @@ public struct TeradataConnectionConfig: Sendable { public var account: String? public var logMech: TeradataLogMech public var transactionMode: TeradataTransactionMode + public var tls: TeradataTLSOptions public var connectTimeoutSeconds: Int public init( @@ -42,6 +70,7 @@ public struct TeradataConnectionConfig: Sendable { account: String? = nil, logMech: TeradataLogMech = .td2, transactionMode: TeradataTransactionMode = .default, + tls: TeradataTLSOptions = .disabled, connectTimeoutSeconds: Int = 20 ) { self.host = host @@ -52,6 +81,7 @@ public struct TeradataConnectionConfig: Sendable { self.account = account self.logMech = logMech self.transactionMode = transactionMode + self.tls = tls self.connectTimeoutSeconds = connectTimeoutSeconds } } diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift index bcc75502f..789052fc1 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift @@ -65,7 +65,7 @@ enum TeradataMessages { static func clientAttributesParcel( username: String, session: UInt32, charset: UInt8, - serverIP: String, logMech: String, transactionMode: String, database: String? + serverIP: String, logMech: String, transactionMode: String, sslMode: String, database: String? ) -> Parcel { var writer = ByteWriter() func stringAttribute(_ code: UInt16, _ value: String) { @@ -89,7 +89,7 @@ enum TeradataMessages { stringAttribute(28, "P") stringAttribute(29, "20.0.0.63") let ess = "BA=N;CCS=UTF8;CERT=U;CF=0;DP=1025;ENC=Y;ES=\(session);" - + "LM=\(logMech);LOB=Y;PART=DBC/SQL;SCS=UTF8;SIP=Y;SSLM=DISABLE;TM=\(transactionMode);TVD=plain;" + + "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) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift index 84f3503c9..78d2c67c0 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSocket.swift @@ -21,7 +21,14 @@ public enum TeradataWireError: Error, CustomStringConvertible { } } -final class TeradataSocket { +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 diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift new file mode 100644 index 000000000..aad56bc0f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift @@ -0,0 +1,157 @@ +import Foundation +import Network +import Security + +final class TeradataTLSTransport: TeradataTransport { + private let connection: NWConnection + private let condition = NSCondition() + private let timeoutSeconds: Int + private var buffer: [UInt8] = [] + private var receiveError: Error? + private var peerClosed = false + private var cancelled = false + + init(host: String, port: UInt16, options: TeradataTLSOptions, timeoutSeconds: Int) throws { + self.timeoutSeconds = timeoutSeconds + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + throw TeradataWireError.connectionFailed("invalid port \(port)") + } + let queue = DispatchQueue(label: "com.TablePro.teradata.tls") + + 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, false) + } + complete(SecTrustEvaluateWithError(trust, nil)) + }, + queue) + + 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):\(port) timed out") + } + if let failure { + connection.cancel() + throw TeradataWireError.connectionFailed("TLS handshake failed: \(failure)") + } + startReceiveLoop() + } + + func send(_ bytes: [UInt8]) throws { + condition.lock() + let stopped = cancelled + condition.unlock() + if stopped { throw TeradataWireError.cancelled } + + let semaphore = DispatchSemaphore(value: 0) + var sendError: Error? + connection.send(content: Data(bytes), completion: .contentProcessed { error in + sendError = error + semaphore.signal() + }) + if semaphore.wait(timeout: .now() + .seconds(timeoutSeconds)) == .timedOut { + throw TeradataWireError.truncated("TLS send timed out") + } + if let sendError { throw TeradataWireError.truncated("TLS send: \(sendError)") } + } + + func receive(_ count: Int) throws -> [UInt8] { + guard count > 0 else { return [] } + condition.lock() + defer { condition.unlock() } + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while buffer.count < count { + if cancelled { throw TeradataWireError.cancelled } + if let receiveError { throw TeradataWireError.truncated("TLS recv: \(receiveError)") } + if peerClosed { throw TeradataWireError.truncated("TLS peer closed after \(buffer.count)/\(count)") } + if !condition.wait(until: deadline) { throw TeradataWireError.truncated("TLS recv timed out") } + } + let result = Array(buffer.prefix(count)) + buffer.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 startReceiveLoop() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.condition.lock() + if let data, !data.isEmpty { self.buffer.append(contentsOf: data) } + if let error { self.receiveError = error } + if isComplete { self.peerClosed = true } + let shouldContinue = error == nil && !isComplete && !self.cancelled + self.condition.signal() + self.condition.unlock() + if shouldContinue { self.startReceiveLoop() } + } + } + + private static func loadAnchors(_ path: String) -> [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/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/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index d4ce16430..15f1506ec 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -54,7 +54,7 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let containerEntityName = "Database" static let supportsForeignKeys = true static let supportsSchemaEditing = true - static let supportsSSL = false + static let supportsSSL = true static let systemDatabaseNames: [String] = [ "DBC", "Sys", "SysAdmin", "SystemFe", "SYSLIB", "SYSUDTLIB", "SYSSPATIAL", "TD_SYSFNLIB", "TD_SERVER_DB", "TDStats", "TDMaps", "TDQCD", "SQLJ", @@ -146,7 +146,8 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { password: config.password, database: config.database.isEmpty ? nil : config.database, logMech: logMech, - transactionMode: transactionMode) + transactionMode: transactionMode, + tls: TeradataSSLMapping.tlsOptions(for: config.ssl)) let connection = TeradataAsyncConnection(config: coreConfig) try await connection.connect() self.connection = connection 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/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 8abeaa3fc..35dc6b475 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -225,7 +225,7 @@ extension PluginMetadataRegistry { supportsImport: true, supportsExport: true, supportsSSH: true, - supportsSSL: false, + supportsSSL: true, supportsCascadeDrop: false, supportsForeignKeyDisable: false, supportsReadOnlyMode: true, diff --git a/docs/databases/teradata.mdx b/docs/databases/teradata.mdx index 501728ee2..289345c1a 100644 --- a/docs/databases/teradata.mdx +++ b/docs/databases/teradata.mdx @@ -20,6 +20,7 @@ Teradata is a downloadable driver. Open **Settings > Plugins**, find Teradata, a | 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 @@ -27,6 +28,16 @@ In Teradata a database and a user are both namespaces that hold tables, views, a 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 negotiates TLS on the same gateway port (`1025`), so there is no separate HTTPS port. Set the SSL Mode to turn it on: + +- `Preferred` tries TLS and falls back to a plain connection if the gateway does not offer it. +- `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 against a server that offers it, 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. From bdecf5e38a9f3acbc67d2294e49a750c38f57bcb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 20:13:59 +0700 Subject: [PATCH 13/24] fix(plugin-teradata): carry TLS over the gateway WebSocket transport on 443 --- .../TeradataConnection.swift | 13 +- .../TeradataConnectionConfig.swift | 8 +- .../TeradataMessages.swift | 5 +- .../TeradataTLSTransport.swift | 153 +++++++++++++----- .../TableProTeradataCore/WebSocketFrame.swift | 87 ++++++++++ .../WebSocketFrameTests.swift | 47 ++++++ docs/databases/teradata.mdx | 6 +- 7 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProTeradataCore/WebSocketFrame.swift create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift index 312541285..47a1be40c 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift @@ -11,6 +11,7 @@ public final class TeradataConnection { 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 @@ -82,9 +83,11 @@ public final class TeradataConnection { host: config.host, port: config.port, timeoutSeconds: config.connectTimeoutSeconds) } do { - return try TeradataTLSTransport( - host: config.host, port: config.port, options: config.tls, + 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( @@ -163,7 +166,7 @@ public final class TeradataConnection { username: config.username, session: sessionNumber, charset: charsetCode, serverIP: serverIP, logMech: config.logMech.rawValue, transactionMode: config.transactionMode.rawValue, sslMode: config.tls.modeLabel, - database: config.database) + encryptData: !confidentialityBypassed, database: config.database) let body = logon.encoded() + sessionOptions.encoded() + connect.encoded() + logonData.encoded() + attributes.encoded() try sendEncrypted(kind: .connect, body: body, requestNumber: 0) @@ -179,6 +182,10 @@ public final class TeradataConnection { kind: kind, body: body, sessionNumber: sessionNumber, requestNumber: requestNumber, byteVar: 0, authentication: TeradataMessages.authBytes(nextMessage()), hostCharSet: charsetCode) + if confidentialityBypassed { + try socket?.send(message.encoded()) + return + } let wrapped = try Td2Wrap.encryptMessage( message.encoded(), key: aesKey, sequenceNumber: nextGssSequence()) try socket?.send(wrapped) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift index 7e2adb11a..08c5e1e10 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnectionConfig.swift @@ -29,6 +29,8 @@ public struct TeradataTLSOptions: Sendable { 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, @@ -36,7 +38,9 @@ public struct TeradataTLSOptions: Sendable { verifiesCertificate: Bool = false, verifiesHostname: Bool = false, caCertificatePath: String = "", - modeLabel: String = "DISABLE" + modeLabel: String = "DISABLE", + httpsPort: UInt16 = 443, + webSocketPath: String = "/gateway" ) { self.enabled = enabled self.allowPlaintextFallback = allowPlaintextFallback @@ -44,6 +48,8 @@ public struct TeradataTLSOptions: Sendable { self.verifiesHostname = verifiesHostname self.caCertificatePath = caCertificatePath self.modeLabel = modeLabel + self.httpsPort = httpsPort + self.webSocketPath = webSocketPath } public static let disabled = TeradataTLSOptions() diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift index 789052fc1..9a21a815c 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataMessages.swift @@ -65,7 +65,8 @@ enum TeradataMessages { static func clientAttributesParcel( username: String, session: UInt32, charset: UInt8, - serverIP: String, logMech: String, transactionMode: String, sslMode: String, database: String? + serverIP: String, logMech: String, transactionMode: String, sslMode: String, + encryptData: Bool = true, database: String? ) -> Parcel { var writer = ByteWriter() func stringAttribute(_ code: UInt16, _ value: String) { @@ -88,7 +89,7 @@ enum TeradataMessages { 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=Y;ES=\(session);" + 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") diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift index aad56bc0f..220c65f52 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift @@ -6,17 +6,20 @@ final class TeradataTLSTransport: TeradataTransport { private let connection: NWConnection private let condition = NSCondition() private let timeoutSeconds: Int - private var buffer: [UInt8] = [] + 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, port: UInt16, options: TeradataTLSOptions, timeoutSeconds: Int) throws { + init(host: String, options: TeradataTLSOptions, timeoutSeconds: Int) throws { self.timeoutSeconds = timeoutSeconds - guard let endpointPort = NWEndpoint.Port(rawValue: port) else { - throw TeradataWireError.connectionFailed("invalid port \(port)") + 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) @@ -35,7 +38,7 @@ final class TeradataTLSTransport: TeradataTransport { } complete(SecTrustEvaluateWithError(trust, nil)) }, - queue) + verifyQueue) let tcpOptions = NWProtocolTCP.Options() tcpOptions.noDelay = true @@ -46,45 +49,28 @@ final class TeradataTLSTransport: TeradataTransport { 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 + 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):\(port) timed out") + 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 { - condition.lock() - let stopped = cancelled - condition.unlock() - if stopped { throw TeradataWireError.cancelled } - - let semaphore = DispatchSemaphore(value: 0) - var sendError: Error? - connection.send(content: Data(bytes), completion: .contentProcessed { error in - sendError = error - semaphore.signal() - }) - if semaphore.wait(timeout: .now() + .seconds(timeoutSeconds)) == .timedOut { - throw TeradataWireError.truncated("TLS send timed out") - } - if let sendError { throw TeradataWireError.truncated("TLS send: \(sendError)") } + try sendRaw(WebSocketFrame.encodeBinary(bytes)) } func receive(_ count: Int) throws -> [UInt8] { @@ -92,24 +78,21 @@ final class TeradataTLSTransport: TeradataTransport { condition.lock() defer { condition.unlock() } let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) - while buffer.count < count { + 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("TLS peer closed after \(buffer.count)/\(count)") } + 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(buffer.prefix(count)) - buffer.removeFirst(count) + let result = Array(messageBuffer.prefix(count)) + messageBuffer.removeFirst(count) return result } - func cancel() { - stop() - } - - func close() { - stop() - } + func cancel() { stop() } + func close() { stop() } private func stop() { condition.lock() @@ -119,11 +102,89 @@ final class TeradataTLSTransport: TeradataTransport { 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] } 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.. Date: Thu, 16 Jul 2026 20:50:52 +0700 Subject: [PATCH 15/24] chore(plugin-teradata): extract Teradata tagline string --- TablePro/Resources/Localizable.xcstrings | 3 +++ 1 file changed, 3 insertions(+) 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", From 8b22a6f8c954b934238d62a12aa2c7b068fba558 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 20:55:14 +0700 Subject: [PATCH 16/24] fix(plugin-teradata): show the server error message instead of an error code --- .../Sources/TableProTeradataCore/TeradataResultParser.swift | 1 + .../Sources/TableProTeradataCore/TeradataSocket.swift | 6 ++++-- .../TeradataExecuteLoopTests.swift | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift index cba38ed70..0176a6683 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift @@ -40,6 +40,7 @@ enum TeradataResultParser { let messageLength = Int(body[10]) << 8 | Int(body[11]) let end = min(12 + messageLength, body.count) let message = String(decoding: body[12.. Date: Thu, 16 Jul 2026 21:18:19 +0700 Subject: [PATCH 17/24] fix(plugin-teradata): list only tables and views, not procedures or macros --- .../TableProTeradataCore/TeradataSchemaQueries.swift | 2 +- .../TeradataSchemaQueriesTests.swift | 8 ++++++++ .../TeradataPluginDriver+Schema.swift | 9 +-------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift index 7afdac6fe..7a1c4cb7b 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift @@ -22,7 +22,7 @@ public enum TeradataSchemaQueries { """ SELECT TableName, TableKind FROM DBC.TablesV \ WHERE DatabaseName = \(quoteLiteral(database)) \ - AND TableKind IN ('T', 'O', 'V', 'M', 'P', 'E', 'F', 'R', 'Q') \ + AND TableKind IN ('T', 'O', 'Q', 'V') \ ORDER BY TableName """ } diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift index 8bdb8ab6d..ef8ddc6fe 100644 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift @@ -20,6 +20,14 @@ final class TeradataSchemaQueriesTests: XCTestCase { 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, diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift index 74ec46176..dd021a6e3 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift @@ -138,13 +138,6 @@ extension TeradataPluginDriver { } private static func tableType(_ kind: String) -> String { - switch kind.trimmingCharacters(in: .whitespaces) { - case "V": return "VIEW" - case "M": return "MACRO" - case "P", "E": return "PROCEDURE" - case "F", "R": return "FUNCTION" - case "G": return "TRIGGER" - default: return "TABLE" - } + kind.trimmingCharacters(in: .whitespaces) == "V" ? "VIEW" : "TABLE" } } From 367a45d981ed955004557ae8c45d306b820d3620 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:46:49 +0700 Subject: [PATCH 18/24] fix(plugin-teradata): decode DECIMAL result columns --- .../TableProTeradataCore/RecordDecoder.swift | 49 +++++++++++++++++++ .../RecordDecoderDecimalTests.swift | 38 ++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderDecimalTests.swift diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift index 0415ef6e9..145cc9b48 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/RecordDecoder.swift @@ -37,11 +37,58 @@ enum RecordDecoder { 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: @@ -55,6 +102,8 @@ enum RecordDecoder { 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: 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")]) + } +} From 9bc0849e1b2838552fb20e4e6369cdf0637a6952 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:46:50 +0700 Subject: [PATCH 19/24] fix(plugin-teradata): pin Verify CA to the configured CA and stop a ping frame from deadlocking --- .../Sources/TableProTeradataCore/TeradataTLSTransport.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift index 220c65f52..138b4c55a 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataTLSTransport.swift @@ -34,7 +34,7 @@ final class TeradataTLSTransport: TeradataTransport { SecTrustSetPolicies(trust, policy) if let anchors, !anchors.isEmpty { SecTrustSetAnchorCertificates(trust, anchors as CFArray) - SecTrustSetAnchorCertificatesOnly(trust, false) + SecTrustSetAnchorCertificatesOnly(trust, true) } complete(SecTrustEvaluateWithError(trust, nil)) }, @@ -159,7 +159,9 @@ final class TeradataTLSTransport: TeradataTransport { } private func sendPong(_ payload: [UInt8]) { - try? sendRaw(WebSocketFrame.encode(opcode: .pong, payload: payload)) + connection.send( + content: Data(WebSocketFrame.encode(opcode: .pong, payload: payload)), + completion: .idempotent) } private func sendRaw(_ bytes: [UInt8]) throws { From 66a2f23c779ea86977dbc415479711dee307dd11 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:46:50 +0700 Subject: [PATCH 20/24] fix(plugin-teradata): quote text cell values so numeric strings keep their formatting --- Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift index 0a39c2a6c..0215ec781 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift @@ -8,7 +8,6 @@ extension TeradataPluginDriver { case .null: return "NULL" case .text(let string): - if PluginNumericLiteral.isValid(string) { return string } return "'" + string.replacingOccurrences(of: "'", with: "''") + "'" case .bytes(let data): return "'" + data.map { String(format: "%02X", $0) }.joined() + "'XB" From 770e82abd003115b11dbe1c95d79779e1188f7ca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:46:50 +0700 Subject: [PATCH 21/24] fix(plugin-teradata): reject an out-of-range port instead of crashing on connect --- Plugins/TeradataDriverPlugin/TeradataPlugin.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 15f1506ec..e7f13472c 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -139,9 +139,12 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } 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: UInt16(config.port), + port: port, username: config.username, password: config.password, database: config.database.isEmpty ? nil : config.database, From dec820bc0d9247d9f7ec863813f9c6b8e6655431 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:46:50 +0700 Subject: [PATCH 22/24] fix(plugin-teradata): read to end-of-request and guard short encrypted replies --- .../Sources/TableProTeradataCore/TeradataConnection.swift | 4 +++- .../TableProTeradataCoreTests/TeradataExecuteLoopTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift index b504348fd..a31cbacdd 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataConnection.swift @@ -78,7 +78,6 @@ public final class TeradataConnection { static func responseIsComplete(_ parcels: [Parcel]) -> Bool { let terminators: Set = [ ParcelFlavor.endRequest.rawValue, - ParcelFlavor.endStatement.rawValue, ParcelFlavor.failure.rawValue, ParcelFlavor.error.rawValue, ParcelFlavor.statementError.rawValue, @@ -213,6 +212,9 @@ public final class TeradataConnection { 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) diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift index d420f282c..ec0b832a5 100644 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift @@ -7,9 +7,9 @@ final class TeradataExecuteLoopTests: XCTestCase { XCTAssertFalse(TeradataConnection.responseIsComplete(batch)) } - func testEndMarkersTerminate() { + func testEndRequestTerminatesButEndStatementDoesNot() { XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.endRequest)])) - XCTAssertTrue(TeradataConnection.responseIsComplete([Parcel(.endStatement)])) + XCTAssertFalse(TeradataConnection.responseIsComplete([Parcel(.record), Parcel(.endStatement)])) } func testServerFailuresTerminateInsteadOfLooping() { From 014b90051ac863fa825418e208f1a2f2e40c7a56 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 21:55:56 +0700 Subject: [PATCH 23/24] test(plugin-teradata): count Teradata in the known database types --- .../Tests/TableProModelsTests/DatabaseTypeTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index b4fc01761..3ef2fab6e 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -53,13 +53,14 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 20) + #expect(DatabaseType.allKnownTypes.count == 21) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) #expect(DatabaseType.allKnownTypes.contains(.libsql)) #expect(DatabaseType.allKnownTypes.contains(.beancount)) #expect(DatabaseType.allKnownTypes.contains(.surrealdb)) + #expect(DatabaseType.allKnownTypes.contains(.teradata)) } @Test("Hashable conformance") From 379071dcb92f8a3954d5b75131b7d4ec416849b9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Jul 2026 22:03:08 +0700 Subject: [PATCH 24/24] fix(plugin-teradata): rename the activity-count helper so it stops shadowing the local --- .../Sources/TableProTeradataCore/TeradataResultParser.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift index 0176a6683..18ea3d0d9 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataResultParser.swift @@ -11,7 +11,7 @@ enum TeradataResultParser { let activityCount = parcels .first { $0.flavor == ParcelFlavor.success.rawValue } - .map { activityCount(fromSuccess: $0.body) } ?? 0 + .map { readActivityCount(fromSuccess: $0.body) } ?? 0 var columns: [ColumnMeta] = [] if let prepInfoX = parcels.first(where: { $0.flavor == ParcelFlavor.prepInfoX.rawValue }) { @@ -44,7 +44,7 @@ enum TeradataResultParser { return (code, message.isEmpty ? String(decoding: body, as: UTF8.self) : message) } - private static func activityCount(fromSuccess body: [UInt8]) -> Int { + private static func readActivityCount(fromSuccess body: [UInt8]) -> Int { guard body.count >= 6 else { return 0 } return Int(body[2]) << 24 | Int(body[3]) << 16 | Int(body[4]) << 8 | Int(body[5]) }