diff --git a/CHANGELOG.md b/CHANGELOG.md index 912a90c35..eb7b52cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- MySQL/MariaDB: `BIT(N)` columns now display as decimal numbers (`0`, `1`, `255`) instead of raw bytes that showed up as control characters like `^A` in the data grid. (#1272) - ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267) - AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash. - MongoDB: the connection form now shows a Username field. It was hidden for databases where authentication is optional, so connections to auth-enabled servers saved with no credentials and every query failed with "requires authentication" even though the connection looked healthy. diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift index 52a6f9fc6..f49a05151 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift @@ -11,12 +11,26 @@ internal enum MariaDBFieldClassifier { private static let blobOrStringTypes: Set = [249, 250, 251, 252, 253, 254] static func isBinary(typeRaw: UInt32, charset: UInt32) -> Bool { - if typeRaw == bitType { - return true - } guard charset == binaryCharset else { return false } return blobOrStringTypes.contains(typeRaw) } + + static func isBit(typeRaw: UInt32) -> Bool { + typeRaw == bitType + } + + static func bitFieldToString(_ buffer: UnsafeRawBufferPointer) -> String { + guard !buffer.isEmpty else { return "0" } + var value: UInt64 = 0 + for byte in buffer { + value = (value << 8) | UInt64(byte) + } + return String(value) + } + + static func bitFieldToString(_ data: Data) -> String { + data.withUnsafeBytes { bitFieldToString($0) } + } } diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index c97e5d6aa..04f36644e 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -511,6 +511,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { if columnTypes[i] == 255 { row.append(.text(GeometryWKBParser.parse(bufferPtr))) + } else if MariaDBFieldClassifier.isBit(typeRaw: columnTypes[i]) { + row.append(.text(MariaDBFieldClassifier.bitFieldToString(bufferPtr))) } else if columnIsBinary[i] { row.append(.bytes(Data(bufferPtr))) } else if let str = String(bytes: bufferPtr, encoding: .utf8) { @@ -710,7 +712,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { let length = Int(resultBinds[i].length?.pointee ?? 0) let buffer = resultBuffers[i].assumingMemoryBound(to: UInt8.self) let data = Data(bytes: buffer, count: length) - if columnIsBinary[i] { + if MariaDBFieldClassifier.isBit(typeRaw: columnTypes[i]) { + row.append(.text(MariaDBFieldClassifier.bitFieldToString(data))) + } else if columnIsBinary[i] { row.append(.bytes(data)) } else if let str = String(data: data, encoding: .utf8) { row.append(.text(str)) @@ -956,6 +960,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { if columnTypes[i] == 255 { row.append(.text(GeometryWKBParser.parse(bufferPtr))) + } else if MariaDBFieldClassifier.isBit(typeRaw: columnTypes[i]) { + row.append(.text(MariaDBFieldClassifier.bitFieldToString(bufferPtr))) } else if columnIsBinary[i] { row.append(.bytes(Data(bufferPtr))) } else if let str = String(bytes: bufferPtr, encoding: .utf8) { diff --git a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift index 4a045bb68..f81df717d 100644 --- a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift +++ b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift @@ -4,18 +4,68 @@ // #if canImport(MySQLDriverPlugin) +import Foundation import Testing @testable import MySQLDriverPlugin @Suite("MariaDBFieldClassifier") struct MariaDBFieldClassifierTests { - @Test("BIT routes to binary regardless of charset") - func bitIsBinary() { - #expect(MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 63)) - #expect(MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 33)) + @Test("BIT no longer routes to binary (it was rendering as raw control characters in the data grid)") + func bitIsNotBinary() { + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 63)) + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 33)) } + @Test("isBit identifies type code 16 and only type code 16") + func isBitMatchesOnlyBitType() { + #expect(MariaDBFieldClassifier.isBit(typeRaw: 16)) + for typeRaw: UInt32 in [0, 1, 2, 3, 7, 12, 15, 17, 245, 252, 255] { + #expect(!MariaDBFieldClassifier.isBit(typeRaw: typeRaw)) + } + } + + @Test("bitFieldToString decodes a single byte big-endian") + func bitFieldSingleByte() { + #expect(decodeBitField([0x00]) == "0") + #expect(decodeBitField([0x01]) == "1") + #expect(decodeBitField([0x7f]) == "127") + #expect(decodeBitField([0xff]) == "255") + } + + @Test("bitFieldToString decodes multi-byte values MSB-first") + func bitFieldMultiByte() { + #expect(decodeBitField([0x01, 0x00]) == "256") + #expect(decodeBitField([0xff, 0xff]) == "65535") + #expect(decodeBitField([0x12, 0x34]) == "4660") + } + + @Test("bitFieldToString handles BIT(64) up to UInt64.max") + func bitField64Bits() { + let allOnes: [UInt8] = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + #expect(decodeBitField(allOnes) == "18446744073709551615") + let halfPlusOne: [UInt8] = [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + #expect(decodeBitField(halfPlusOne) == "9223372036854775808") + } + + @Test("bitFieldToString on an empty buffer returns 0") + func bitFieldEmptyBuffer() { + #expect(decodeBitField([]) == "0") + } + + @Test("Data overload matches the raw-pointer overload") + func bitFieldDataOverload() { + let bytes: [UInt8] = [0x01, 0xff] + let dataResult = MariaDBFieldClassifier.bitFieldToString(Data(bytes)) + #expect(dataResult == "511") + #expect(dataResult == decodeBitField(bytes)) + } + + private func decodeBitField(_ bytes: [UInt8]) -> String { + bytes.withUnsafeBytes { MariaDBFieldClassifier.bitFieldToString($0) } + } + + @Test("BLOB family with binary charset routes to binary") func blobFamilyBinary() { for typeRaw: UInt32 in [249, 250, 251, 252] {