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
29 changes: 23 additions & 6 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import OSLog
import TableProPluginKit

// 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
internal let mysqlBinaryFlag: UInt = 0x0080
internal let mysqlEnumFlag: UInt = 0x0100
internal let mysqlSetFlag: UInt = 0x0800
internal let mysqlBinaryCharset: UInt32 = 63

private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection")

Expand Down Expand Up @@ -93,11 +93,28 @@ func mysqlTypeToString(_ fieldPtr: UnsafePointer<MYSQL_FIELD>) -> String {
if (flags & mysqlEnumFlag) != 0 { return "ENUM" }
if (flags & mysqlSetFlag) != 0 { return "SET" }

return mariaDBTypeName(
typeRaw: field.type.rawValue,
flags: flags,
charsetnr: field.charsetnr,
length: field.length
)
}

/// Pure mapping from raw MySQL/MariaDB field type code + flags to TablePro's
/// column-type-name string. Separated from `mysqlTypeToString` so it can be
/// unit-tested without an actual `MYSQL_FIELD` struct.
internal func mariaDBTypeName(
typeRaw: UInt32,
flags: UInt,
charsetnr: UInt32,
length: UInt
) -> String {
// 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
let isBinary = (flags & mysqlBinaryFlag) != 0 && charsetnr == mysqlBinaryCharset

switch field.type.rawValue {
switch typeRaw {
case 0: return "DECIMAL"
case 1: return "TINYINT"
case 2: return "SMALLINT"
Expand Down
116 changes: 116 additions & 0 deletions TableProTests/Core/DataGrid/RowDisplayCacheTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// RowDisplayCacheTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("RowDisplayCache")
@MainActor
struct RowDisplayCacheTests {
private func makeBox(_ values: [String?]) -> RowDisplayBox {
RowDisplayBox(ContiguousArray(values))
}

private func cost(of values: [String?]) -> Int {
var total = 0
for v in values {
if let s = v { total &+= s.utf8.count }
}
return total
}

@Test("Empty cache returns nil for any lookup")
func emptyLookup() {
let cache = RowDisplayCache()
#expect(cache.box(forID: .existing(0)) == nil)
#expect(cache.box(forID: .existing(100)) == nil)
}

@Test("Inserted box is retrievable")
func basicSetGet() {
let cache = RowDisplayCache()
let id = RowID.existing(42)
let values = ["a", "b", "c"]
let box = makeBox(values)
cache.setBox(box, forID: id, cost: cost(of: values))

#expect(cache.box(forID: id) === box)
}

@Test("Count limit evicts oldest entries first (FIFO)")
func countLimitEvictsFIFO() {
let cache = RowDisplayCache(countLimit: 3, costLimit: 1_000_000)
for index in 1...3 {
cache.setBox(makeBox(["row\(index)"]), forID: .existing(index), cost: 4)
}
#expect(cache.box(forID: .existing(1)) != nil)

// Fourth insertion should evict the first.
cache.setBox(makeBox(["row4"]), forID: .existing(4), cost: 4)
#expect(cache.box(forID: .existing(1)) == nil)
#expect(cache.box(forID: .existing(2)) != nil)
#expect(cache.box(forID: .existing(3)) != nil)
#expect(cache.box(forID: .existing(4)) != nil)
}

@Test("Cost limit evicts even when count is under limit")
func costLimitEvicts() {
let cache = RowDisplayCache(countLimit: 1_000, costLimit: 10)
// First insert costs 6; under cap.
cache.setBox(makeBox(["abcdef"]), forID: .existing(1), cost: 6)
// Second insert costs 6 more; total 12 > 10, evicts first.
cache.setBox(makeBox(["123456"]), forID: .existing(2), cost: 6)

#expect(cache.box(forID: .existing(1)) == nil)
#expect(cache.box(forID: .existing(2)) != nil)
}

@Test("Replacing an existing key does not consume queue slot")
func replaceExistingKey() {
let cache = RowDisplayCache(countLimit: 2, costLimit: 1_000_000)
cache.setBox(makeBox(["v1"]), forID: .existing(1), cost: 2)
cache.setBox(makeBox(["v2"]), forID: .existing(2), cost: 2)

// Replace id=1 without expanding the cache.
cache.setBox(makeBox(["v1-updated"]), forID: .existing(1), cost: 10)
#expect(cache.box(forID: .existing(1))?.values.first == "v1-updated")
#expect(cache.box(forID: .existing(2))?.values.first == "v2")

// Adding a new entry now evicts the oldest in insertion order (still id=1
// because replacing did not re-add it to the order).
cache.setBox(makeBox(["v3"]), forID: .existing(3), cost: 2)
#expect(cache.box(forID: .existing(1)) == nil)
#expect(cache.box(forID: .existing(2)) != nil)
#expect(cache.box(forID: .existing(3)) != nil)
}

@Test("removeAll empties the cache and resets state")
func removeAllResetsState() {
let cache = RowDisplayCache()
for index in 1...10 {
cache.setBox(makeBox(["x"]), forID: .existing(index), cost: 1)
}
cache.removeAll()
for index in 1...10 {
#expect(cache.box(forID: .existing(index)) == nil)
}

// Cache continues to work after removeAll.
cache.setBox(makeBox(["fresh"]), forID: .existing(100), cost: 5)
#expect(cache.box(forID: .existing(100))?.values.first == "fresh")
}

@Test("Inserted row IDs of both kinds round-trip")
func mixedRowIDKinds() {
let cache = RowDisplayCache()
let existingID = RowID.existing(5)
let insertedID = RowID.inserted(UUID())
cache.setBox(makeBox(["existing"]), forID: existingID, cost: 8)
cache.setBox(makeBox(["inserted"]), forID: insertedID, cost: 8)
#expect(cache.box(forID: existingID)?.values.first == "existing")
#expect(cache.box(forID: insertedID)?.values.first == "inserted")
}
}
120 changes: 120 additions & 0 deletions TableProTests/Plugins/MariaDBTypeNameTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// MariaDBTypeNameTests.swift
// TableProTests
//

#if canImport(MySQLDriverPlugin)
import Testing

@testable import MySQLDriverPlugin

@Suite("MariaDB type name resolution")
struct MariaDBTypeNameTests {
private func resolve(typeRaw: UInt32, charsetnr: UInt32 = 33, flags: UInt = 0, length: UInt = 0) -> String {
mariaDBTypeName(typeRaw: typeRaw, flags: flags, charsetnr: charsetnr, length: length)
}

private let binaryFlagAndCharset: (flags: UInt, charsetnr: UInt32) = (mysqlBinaryFlag, mysqlBinaryCharset)

// MARK: - Numeric types (regression for #1209: numeric routed as bytes)

@Test("INT family resolves to numeric type names")
func numericTypes() {
#expect(resolve(typeRaw: 1) == "TINYINT")
#expect(resolve(typeRaw: 2) == "SMALLINT")
#expect(resolve(typeRaw: 3) == "INT")
#expect(resolve(typeRaw: 8) == "BIGINT")
#expect(resolve(typeRaw: 9) == "MEDIUMINT")
}

@Test("DECIMAL and floating point resolve to their type names")
func decimalAndFloat() {
#expect(resolve(typeRaw: 0) == "DECIMAL")
#expect(resolve(typeRaw: 4) == "FLOAT")
#expect(resolve(typeRaw: 5) == "DOUBLE")
#expect(resolve(typeRaw: 246) == "NEWDECIMAL")
}

// MARK: - Temporal types

@Test("Temporal types resolve to their type names")
func temporalTypes() {
#expect(resolve(typeRaw: 7) == "TIMESTAMP")
#expect(resolve(typeRaw: 10) == "DATE")
#expect(resolve(typeRaw: 11) == "TIME")
#expect(resolve(typeRaw: 12) == "DATETIME")
#expect(resolve(typeRaw: 13) == "YEAR")
#expect(resolve(typeRaw: 14) == "NEWDATE")
}

// MARK: - Misc

@Test("JSON, BIT, GEOMETRY resolve to their type names")
func miscTypes() {
#expect(resolve(typeRaw: 16) == "BIT")
#expect(resolve(typeRaw: 245) == "JSON")
#expect(resolve(typeRaw: 255) == "GEOMETRY")
}

@Test("Unknown type code returns UNKNOWN")
func unknownType() {
#expect(resolve(typeRaw: 999) == "UNKNOWN")
}

// MARK: - BINARY / VARBINARY (regression for #1217: data wipe on edit)

@Test("BINARY(N) resolves to BINARY only when binary flag set")
func binaryWithFlagAndCharset() {
let (flags, charsetnr) = binaryFlagAndCharset
#expect(resolve(typeRaw: 254, charsetnr: charsetnr, flags: flags) == "BINARY")
}

@Test("CHAR(N) without binary flag stays CHAR")
func charWithoutBinaryFlag() {
#expect(resolve(typeRaw: 254, charsetnr: 33, flags: 0) == "CHAR")
}

@Test("CHAR with charset 63 but no binary flag stays CHAR")
func charBinaryCharsetWithoutFlag() {
#expect(resolve(typeRaw: 254, charsetnr: mysqlBinaryCharset, flags: 0) == "CHAR")
}

@Test("CHAR with binary flag but non-binary charset stays CHAR")
func charFlagWithoutBinaryCharset() {
#expect(resolve(typeRaw: 254, charsetnr: 33, flags: mysqlBinaryFlag) == "CHAR")
}

@Test("VARBINARY(N) resolves to VARBINARY only when binary flag set with charset 63")
func varbinaryWithFlagAndCharset() {
let (flags, charsetnr) = binaryFlagAndCharset
#expect(resolve(typeRaw: 253, charsetnr: charsetnr, flags: flags) == "VARBINARY")
}

@Test("VARCHAR(N) without binary flag stays VARCHAR")
func varcharWithoutBinaryFlag() {
#expect(resolve(typeRaw: 253, charsetnr: 33, flags: 0) == "VARCHAR")
}

// MARK: - BLOB family

@Test("BLOB family resolves binary vs text by isBinary flag")
func blobFamilyBinaryVsText() {
let (flags, charsetnr) = binaryFlagAndCharset
#expect(resolve(typeRaw: 249, charsetnr: charsetnr, flags: flags) == "TINYBLOB")
#expect(resolve(typeRaw: 249, charsetnr: 33, flags: 0) == "TINYTEXT")
#expect(resolve(typeRaw: 250, charsetnr: charsetnr, flags: flags) == "MEDIUMBLOB")
#expect(resolve(typeRaw: 250, charsetnr: 33, flags: 0) == "MEDIUMTEXT")
#expect(resolve(typeRaw: 251, charsetnr: charsetnr, flags: flags) == "LONGBLOB")
#expect(resolve(typeRaw: 251, charsetnr: 33, flags: 0) == "LONGTEXT")
}

@Test("Generic BLOB (252) routes by length and binary flag")
func blobLengthRouting() {
let (flags, charsetnr) = binaryFlagAndCharset
#expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100) == "BLOB")
#expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100_000) == "LONGBLOB")
#expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100) == "TEXT")
#expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100_000) == "LONGTEXT")
}
}
#endif
34 changes: 34 additions & 0 deletions TableProTests/Plugins/PluginCellValueSortKeyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,38 @@ struct PluginCellValueSortKeyTests {
#expect(b < c)
#expect(a != b)
}

// MARK: - asText contract
//
// `asText` MUST return nil for `.bytes` so callers cannot accidentally treat
// binary cells as editable text. Returning empty string instead would cause
// the inline cell editor to display the empty field on click and commit ""
// on focus-out, silently wiping the original bytes (regression for #1217).

@Test(".text.asText returns the text verbatim")
func textAsText() {
#expect(PluginCellValue.text("hello").asText == "hello")
#expect(PluginCellValue.text("").asText == "")
}

@Test(".bytes.asText returns nil so inline edit is gated")
func bytesAsTextIsNil() {
#expect(PluginCellValue.bytes(Data([0xDE, 0xAD])).asText == nil)
#expect(PluginCellValue.bytes(Data()).asText == nil)
}

@Test(".null.asText returns nil")
func nullAsText() {
#expect(PluginCellValue.null.asText == nil)
}

// MARK: - asBytes contract

@Test(".bytes.asBytes returns the data; other cases return nil")
func asBytes() {
let data = Data([0x01, 0x02, 0x03])
#expect(PluginCellValue.bytes(data).asBytes == data)
#expect(PluginCellValue.text("hello").asBytes == nil)
#expect(PluginCellValue.null.asBytes == nil)
}
}
Loading