From 5eef43dfe44553212d87bbdd49f7df348d39d9d2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 16:07:06 +0700 Subject: [PATCH 1/2] fix: resolve MariaDB JSON columns showing as binary hex data --- CHANGELOG.md | 5 + .../MariaDBPluginConnection.swift | 33 ++- .../RightSidebar/EditableFieldView.swift | 9 + .../Services/MariaDBJsonDetectionTests.swift | 198 ++++++++++++++++++ 4 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 TableProTests/Core/Services/MariaDBJsonDetectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8f1211..8bc0a9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts +- JSON fields in Row Details sidebar now display as pretty-printed scrollable text + +### Fixed + +- MariaDB JSON columns misdetected as BLOB, showing hex dumps instead of JSON text ## [0.25.0] - 2026-03-27 diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index fb07c291..4f6cbbe8 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -11,10 +11,11 @@ import Foundation import OSLog import TableProPluginKit -// MySQL/MariaDB field flag constants -private let mysqlBinaryFlag: UInt = 0x0080 // 128 -private let mysqlEnumFlag: UInt = 0x0100 // 256 -private let mysqlSetFlag: UInt = 0x0800 // 2048 +// MySQL/MariaDB field flag and charset constants +private let mysqlBinaryFlag: UInt = 0x0080 +private let mysqlEnumFlag: UInt = 0x0100 +private let mysqlSetFlag: UInt = 0x0800 +private let mysqlBinaryCharset: UInt32 = 63 private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection") @@ -72,13 +73,27 @@ struct MySQLSSLConfig { // MARK: - Type Mapping -func mysqlTypeToString(_ type: UInt32, length: UInt, flags: UInt) -> String { +func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> String { + let field = fieldPtr.pointee + let flags = UInt(field.flags) + let length = field.length + + // MariaDB extended metadata: detect JSON stored as LONGTEXT (best-effort) + var attr = MARIADB_CONST_STRING() + if mariadb_field_attr(&attr, fieldPtr, MARIADB_FIELD_ATTR_FORMAT_NAME) == 0, + let str = attr.str, attr.length > 0, + String(cString: str) == "json" { + return "JSON" + } + if (flags & mysqlEnumFlag) != 0 { return "ENUM" } if (flags & mysqlSetFlag) != 0 { return "SET" } - let isBinary = (flags & mysqlBinaryFlag) != 0 + // Binary flag alone is insufficient — MariaDB sets it on text columns with + // binary collation (e.g. utf8mb4_bin for JSON). Only charset 63 is truly binary. + let isBinary = (flags & mysqlBinaryFlag) != 0 && field.charsetnr == mysqlBinaryCharset - switch type { + switch field.type.rawValue { case 0: return "DECIMAL" case 1: return "TINYINT" case 2: return "SMALLINT" @@ -444,7 +459,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlEnumFlag) != 0 { fieldType = 247 } if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) - columnTypeNames.append(mysqlTypeToString(fieldType, length: field.length, flags: fieldFlags)) + columnTypeNames.append(mysqlTypeToString(fields + i)) } } @@ -749,7 +764,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlEnumFlag) != 0 { fieldType = 247 } if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) - columnTypeNames.append(mysqlTypeToString(fieldType, length: field.length, flags: fieldFlags)) + columnTypeNames.append(mysqlTypeToString(fields + i)) } } diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index e66d5a0e..29eb9d52 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -375,6 +375,14 @@ struct ReadOnlyFieldView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .frame(maxHeight: 120) + } else if columnTypeEnum.isJsonType { + ScrollView { + Text(value) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxHeight: 200) } else if isLongText { Text(value) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) @@ -409,4 +417,5 @@ struct ReadOnlyFieldView: View { } } } + } diff --git a/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift new file mode 100644 index 00000000..44c2c23e --- /dev/null +++ b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift @@ -0,0 +1,198 @@ +// +// MariaDBJsonDetectionTests.swift +// TableProTests +// +// Tests the app-side classification and formatting pipeline for MariaDB JSON scenarios. +// MariaDB stores JSON as LONGTEXT with utf8mb4_bin collation. The driver uses +// mariadb_field_attr to detect JSON; when that succeeds, it returns "JSON". +// When it fails (intermittent), the charset fallback causes it to return "LONGTEXT" +// instead of "BLOB". These tests verify the app handles both paths correctly. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MariaDB JSON Detection") +struct MariaDBJsonDetectionTests { + private let classifier = ColumnTypeClassifier() + + // MARK: - Classifier: Driver returns "JSON" (mariadb_field_attr succeeded) + + @Test("JSON type name classifies as json") + func jsonTypeNameClassifiesAsJson() { + let result = classifier.classify(rawTypeName: "JSON") + #expect(result.isJsonType) + } + + @Test("JSON classified type is not blob") + func jsonIsNotBlob() { + let result = classifier.classify(rawTypeName: "JSON") + #expect(!result.isBlobType) + } + + // MARK: - Classifier: Driver returns "LONGTEXT" (mariadb_field_attr failed, charset fallback) + + @Test("LONGTEXT classifies as text, not blob") + func longtextClassifiesAsText() { + let result = classifier.classify(rawTypeName: "LONGTEXT") + if case .text = result { + // expected + } else { + Issue.record("LONGTEXT should classify as .text, got \(result)") + } + } + + @Test("LONGTEXT is not json type") + func longtextIsNotJsonType() { + let result = classifier.classify(rawTypeName: "LONGTEXT") + #expect(!result.isJsonType) + } + + @Test("LONGTEXT is not blob type") + func longtextIsNotBlobType() { + let result = classifier.classify(rawTypeName: "LONGTEXT") + #expect(!result.isBlobType) + } + + // MARK: - Classifier: True binary types still work + + @Test("BLOB classifies as blob") + func blobClassifiesAsBlob() { + let result = classifier.classify(rawTypeName: "BLOB") + #expect(result.isBlobType) + } + + @Test("LONGBLOB classifies as blob") + func longblobClassifiesAsBlob() { + let result = classifier.classify(rawTypeName: "LONGBLOB") + #expect(result.isBlobType) + } + + @Test("MEDIUMBLOB classifies as blob") + func mediumblobClassifiesAsBlob() { + let result = classifier.classify(rawTypeName: "MEDIUMBLOB") + #expect(result.isBlobType) + } + + @Test("TINYBLOB classifies as blob") + func tinyblobClassifiesAsBlob() { + let result = classifier.classify(rawTypeName: "TINYBLOB") + #expect(result.isBlobType) + } + + // MARK: - BlobFormattingService: formatting requirements + + @Suite("Blob Formatting Requirements") + @MainActor + struct BlobFormattingTests { + @Test("JSON type does not require blob formatting") + func jsonDoesNotRequireBlobFormatting() { + let columnType = ColumnType.json(rawType: "JSON") + #expect(!BlobFormattingService.shared.requiresFormatting(columnType: columnType)) + } + + @Test("LONGTEXT type does not require blob formatting") + func longtextDoesNotRequireBlobFormatting() { + let columnType = ColumnType.text(rawType: "LONGTEXT") + #expect(!BlobFormattingService.shared.requiresFormatting(columnType: columnType)) + } + + @Test("BLOB type requires blob formatting") + func blobRequiresBlobFormatting() { + let columnType = ColumnType.blob(rawType: "BLOB") + #expect(BlobFormattingService.shared.requiresFormatting(columnType: columnType)) + } + + @Test("LONGBLOB type requires blob formatting") + func longblobRequiresBlobFormatting() { + let columnType = ColumnType.blob(rawType: "LONGBLOB") + #expect(BlobFormattingService.shared.requiresFormatting(columnType: columnType)) + } + } + + // MARK: - CellDisplayFormatter: JSON vs BLOB display + + @Suite("Cell Display Formatting") + @MainActor + struct CellDisplayTests { + @Test("JSON type value not hex-formatted in grid") + func jsonValueNotHexFormatted() { + let jsonValue = "{\"name\":\"test\"}" + let columnType = ColumnType.json(rawType: "JSON") + let display = CellDisplayFormatter.format(jsonValue, columnType: columnType) + #expect(display == jsonValue) + } + + @Test("Text type value not hex-formatted in grid") + func textValueNotHexFormatted() { + let textValue = "{\"name\":\"test\"}" + let columnType = ColumnType.text(rawType: "LONGTEXT") + let display = CellDisplayFormatter.format(textValue, columnType: columnType) + #expect(display == textValue) + } + + @Test("Blob type value is hex-formatted in grid") + func blobValueIsHexFormatted() { + let blobValue = "hello" + let columnType = ColumnType.blob(rawType: "BLOB") + let display = CellDisplayFormatter.format(blobValue, columnType: columnType) + #expect(display != blobValue) + } + + @Test("JSON value with newlines is sanitized but not hex-formatted") + func jsonWithNewlinesSanitized() { + let jsonValue = "{\n \"name\": \"test\"\n}" + let columnType = ColumnType.json(rawType: "JSON") + let display = CellDisplayFormatter.format(jsonValue, columnType: columnType) + // Newlines replaced by sanitizedForCellDisplay, but no hex encoding + #expect(display?.contains("0x") != true) + #expect(display?.contains("name") == true) + } + } + + // MARK: - ColumnType properties for MariaDB scenarios + + @Test("JSON type has correct display name") + func jsonDisplayName() { + let columnType = ColumnType.json(rawType: "JSON") + #expect(columnType.displayName == "JSON") + } + + @Test("LONGTEXT is recognized as long text") + func longtextIsLongText() { + let columnType = ColumnType.text(rawType: "LONGTEXT") + #expect(columnType.isLongText) + } + + @Test("LONGTEXT is recognized as very long text") + func longtextIsVeryLongText() { + let columnType = ColumnType.text(rawType: "LONGTEXT") + #expect(columnType.isVeryLongText) + } + + @Test("JSON type is not long text") + func jsonIsNotLongText() { + let columnType = ColumnType.json(rawType: "JSON") + #expect(!columnType.isLongText) + } + + @Test("JSON badge label is json") + func jsonBadgeLabel() { + let columnType = ColumnType.json(rawType: "JSON") + #expect(columnType.badgeLabel == "json") + } + + @Test("LONGTEXT badge label is string") + func longtextBadgeLabel() { + let columnType = ColumnType.text(rawType: "LONGTEXT") + #expect(columnType.badgeLabel == "string") + } + + @Test("BLOB badge label is binary") + func blobBadgeLabel() { + let columnType = ColumnType.blob(rawType: "BLOB") + #expect(columnType.badgeLabel == "binary") + } +} From 02fc57d82401e9e11ebbc5228bcce3888f0c17f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 17:16:02 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?changelog=20wording,=20import=20order,=20trailing=20blank=20lin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- TablePro/Views/RightSidebar/EditableFieldView.swift | 1 - TableProTests/Core/Services/MariaDBJsonDetectionTests.swift | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0a9b2..9f89395b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts -- JSON fields in Row Details sidebar now display as pretty-printed scrollable text +- JSON fields in Row Details sidebar now display in a scrollable monospaced text area ### Fixed diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 29eb9d52..cfa7fe9b 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -417,5 +417,4 @@ struct ReadOnlyFieldView: View { } } } - } diff --git a/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift index 44c2c23e..6deed8f0 100644 --- a/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift +++ b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift @@ -10,9 +10,8 @@ // import Foundation -import Testing - @testable import TablePro +import Testing @Suite("MariaDB JSON Detection") struct MariaDBJsonDetectionTests {