Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 in a scrollable monospaced text area

### Fixed

- MariaDB JSON columns misdetected as BLOB, showing hex dumps instead of JSON text

## [0.25.0] - 2026-03-27

Expand Down
33 changes: 24 additions & 9 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -72,13 +73,27 @@ struct MySQLSSLConfig {

// MARK: - Type Mapping

func mysqlTypeToString(_ type: UInt32, length: UInt, flags: UInt) -> String {
func mysqlTypeToString(_ fieldPtr: UnsafePointer<MYSQL_FIELD>) -> 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"
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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))
}
}

Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/RightSidebar/EditableFieldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
197 changes: 197 additions & 0 deletions TableProTests/Core/Services/MariaDBJsonDetectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//
// 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
@testable import TablePro
import Testing

@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")
}
}
Loading