From 97b85d28c98179621853319fa0ef979247652f13 Mon Sep 17 00:00:00 2001 From: shouwang0527 Date: Fri, 15 May 2026 14:31:26 +0800 Subject: [PATCH 1/2] fix(mysql): display BIT field values as decimal instead of hex bytes MySQL bit(1) fields were incorrectly treated as binary data, causing values like 0x01 to display as "^A" (control character). This change: - Removes MYSQL_TYPE_BIT (16) from the binary classification logic in MariaDBFieldClassifier.isBinary(), since BIT is not truly binary - Adds MariaDBFieldClassifier.isBit() to detect BIT type fields - Adds bitFieldToString() to convert the packed binary representation (MSB-first) into a human-readable decimal string - Propagates columnIsBit through all three query execution paths (sync, prepared statements, streaming) Fixes #1272 --- .../MariaDBFieldClassifier.swift | 7 ++-- .../MariaDBPluginConnection.swift | 40 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift index 52a6f9fc6..39759a609 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift @@ -11,12 +11,13 @@ 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 { + return typeRaw == bitType + } } diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index c97e5d6aa..977c26c4d 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -44,6 +44,7 @@ struct MariaDBPluginQueryResult { let affectedRows: UInt64 let insertId: UInt64 let isTruncated: Bool + let columnIsBit: [Bool] } // MARK: - SSL Configuration @@ -132,6 +133,19 @@ internal func mariaDBTypeName( } } +/// Converts a MySQL/MariaDB BIT field's raw byte buffer to its decimal string representation. +/// +/// MySQL stores BIT(N) as a binary value with N bits packed into ceil(N/8) bytes, +/// most-significant-bit first. For bit(1), the buffer contains a single byte like 0x00 or 0x01. +private 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) +} + // MARK: - Connection Class final class MariaDBPluginConnection: @unchecked Sendable { @@ -441,10 +455,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] + var columnIsBit: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) columnIsBinary.reserveCapacity(numFields) + columnIsBit.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. (rows: [[PluginCellValue]], isTruncated: Bool) { let numFields = columns.count var resultBinds: [MYSQL_BIND] = Array(repeating: MYSQL_BIND(), count: numFields) @@ -710,7 +731,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 columnIsBit[i] { + row.append(.text(bitFieldToString(data))) + } else if columnIsBinary[i] { row.append(.bytes(data)) } else if let str = String(data: data, encoding: .utf8) { row.append(.text(str)) @@ -795,6 +818,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] + var columnIsBit: [Bool] = [] let numFields = Int(mysql_num_fields(metadata)) if let fields = mysql_fetch_fields(metadata) { @@ -817,19 +841,20 @@ final class MariaDBPluginConnection: @unchecked Sendable { charset: field.charsetnr ) ) + columnIsBit.append(MariaDBFieldClassifier.isBit(typeRaw: field.type.rawValue)) } } let fetchResult = try fetchResultSet( from: stmt, metadata: metadata, columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, - columnIsBinary: columnIsBinary + columnIsBinary: columnIsBinary, columnIsBit: columnIsBit ) return MariaDBPluginQueryResult( columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, rows: fetchResult.rows, affectedRows: UInt64(fetchResult.rows.count), - insertId: 0, isTruncated: fetchResult.isTruncated + insertId: 0, isTruncated: fetchResult.isTruncated, columnIsBit: columnIsBit ) } @@ -896,10 +921,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] + var columnIsBit: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) columnIsBinary.reserveCapacity(numFields) + columnIsBit.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. Date: Fri, 15 May 2026 15:39:22 +0700 Subject: [PATCH 2/2] refactor(plugin-mysql): clean up BIT decoding (#1272) - Move bitFieldToString into MariaDBFieldClassifier so the BIT decode helper lives with the BIT detector. - Drop the columnIsBit: [Bool] parallel array threaded through MariaDBPluginQueryResult, the three query paths, and fetchResultSet. The type code in columnTypes[i] is sufficient and MariaDBFieldClassifier.isBit(typeRaw:) is O(1). - Fix MariaDBFieldClassifierTests.bitIsBinary which asserted the old (broken) behavior. Replace with bitIsNotBinary and add coverage for isBit and bitFieldToString across 1-byte, multi-byte, BIT(64) max, empty buffer, and the Data overload. - Add CHANGELOG entry under [Unreleased] > Fixed. --- CHANGELOG.md | 1 + .../MariaDBFieldClassifier.swift | 15 ++++- .../MariaDBPluginConnection.swift | 44 ++++---------- .../Plugins/MariaDBFieldClassifierTests.swift | 58 +++++++++++++++++-- 4 files changed, 79 insertions(+), 39 deletions(-) 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 39759a609..f49a05151 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift @@ -18,6 +18,19 @@ internal enum MariaDBFieldClassifier { } static func isBit(typeRaw: UInt32) -> Bool { - return typeRaw == bitType + 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 977c26c4d..04f36644e 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -44,7 +44,6 @@ struct MariaDBPluginQueryResult { let affectedRows: UInt64 let insertId: UInt64 let isTruncated: Bool - let columnIsBit: [Bool] } // MARK: - SSL Configuration @@ -133,19 +132,6 @@ internal func mariaDBTypeName( } } -/// Converts a MySQL/MariaDB BIT field's raw byte buffer to its decimal string representation. -/// -/// MySQL stores BIT(N) as a binary value with N bits packed into ceil(N/8) bytes, -/// most-significant-bit first. For bit(1), the buffer contains a single byte like 0x00 or 0x01. -private 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) -} - // MARK: - Connection Class final class MariaDBPluginConnection: @unchecked Sendable { @@ -455,12 +441,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] - var columnIsBit: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) columnIsBinary.reserveCapacity(numFields) - columnIsBit.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. (rows: [[PluginCellValue]], isTruncated: Bool) { let numFields = columns.count var resultBinds: [MYSQL_BIND] = Array(repeating: MYSQL_BIND(), count: numFields) @@ -731,8 +712,8 @@ 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 columnIsBit[i] { - row.append(.text(bitFieldToString(data))) + 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) { @@ -818,7 +799,6 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] - var columnIsBit: [Bool] = [] let numFields = Int(mysql_num_fields(metadata)) if let fields = mysql_fetch_fields(metadata) { @@ -841,20 +821,19 @@ final class MariaDBPluginConnection: @unchecked Sendable { charset: field.charsetnr ) ) - columnIsBit.append(MariaDBFieldClassifier.isBit(typeRaw: field.type.rawValue)) } } let fetchResult = try fetchResultSet( from: stmt, metadata: metadata, columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, - columnIsBinary: columnIsBinary, columnIsBit: columnIsBit + columnIsBinary: columnIsBinary ) return MariaDBPluginQueryResult( columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, rows: fetchResult.rows, affectedRows: UInt64(fetchResult.rows.count), - insertId: 0, isTruncated: fetchResult.isTruncated, columnIsBit: columnIsBit + insertId: 0, isTruncated: fetchResult.isTruncated ) } @@ -921,12 +900,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] - var columnIsBit: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) columnIsBinary.reserveCapacity(numFields) - columnIsBit.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. 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] {