Skip to content

Commit 62b1474

Browse files
authored
test(regression): cover MariaDB type name resolution, RowDisplayCache eviction, and PluginCellValue.asText contract (#1218)
1 parent b173394 commit 62b1474

4 files changed

Lines changed: 293 additions & 6 deletions

File tree

Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import OSLog
1212
import TableProPluginKit
1313

1414
// MySQL/MariaDB field flag and charset constants
15-
private let mysqlBinaryFlag: UInt = 0x0080
16-
private let mysqlEnumFlag: UInt = 0x0100
17-
private let mysqlSetFlag: UInt = 0x0800
18-
private let mysqlBinaryCharset: UInt32 = 63
15+
internal let mysqlBinaryFlag: UInt = 0x0080
16+
internal let mysqlEnumFlag: UInt = 0x0100
17+
internal let mysqlSetFlag: UInt = 0x0800
18+
internal let mysqlBinaryCharset: UInt32 = 63
1919

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

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

96+
return mariaDBTypeName(
97+
typeRaw: field.type.rawValue,
98+
flags: flags,
99+
charsetnr: field.charsetnr,
100+
length: field.length
101+
)
102+
}
103+
104+
/// Pure mapping from raw MySQL/MariaDB field type code + flags to TablePro's
105+
/// column-type-name string. Separated from `mysqlTypeToString` so it can be
106+
/// unit-tested without an actual `MYSQL_FIELD` struct.
107+
internal func mariaDBTypeName(
108+
typeRaw: UInt32,
109+
flags: UInt,
110+
charsetnr: UInt32,
111+
length: UInt
112+
) -> String {
96113
// Binary flag alone is insufficient — MariaDB sets it on text columns with
97114
// binary collation (e.g. utf8mb4_bin for JSON). Only charset 63 is truly binary.
98-
let isBinary = (flags & mysqlBinaryFlag) != 0 && field.charsetnr == mysqlBinaryCharset
115+
let isBinary = (flags & mysqlBinaryFlag) != 0 && charsetnr == mysqlBinaryCharset
99116

100-
switch field.type.rawValue {
117+
switch typeRaw {
101118
case 0: return "DECIMAL"
102119
case 1: return "TINYINT"
103120
case 2: return "SMALLINT"
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//
2+
// RowDisplayCacheTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
@testable import TablePro
8+
import Testing
9+
10+
@Suite("RowDisplayCache")
11+
@MainActor
12+
struct RowDisplayCacheTests {
13+
private func makeBox(_ values: [String?]) -> RowDisplayBox {
14+
RowDisplayBox(ContiguousArray(values))
15+
}
16+
17+
private func cost(of values: [String?]) -> Int {
18+
var total = 0
19+
for v in values {
20+
if let s = v { total &+= s.utf8.count }
21+
}
22+
return total
23+
}
24+
25+
@Test("Empty cache returns nil for any lookup")
26+
func emptyLookup() {
27+
let cache = RowDisplayCache()
28+
#expect(cache.box(forID: .existing(0)) == nil)
29+
#expect(cache.box(forID: .existing(100)) == nil)
30+
}
31+
32+
@Test("Inserted box is retrievable")
33+
func basicSetGet() {
34+
let cache = RowDisplayCache()
35+
let id = RowID.existing(42)
36+
let values = ["a", "b", "c"]
37+
let box = makeBox(values)
38+
cache.setBox(box, forID: id, cost: cost(of: values))
39+
40+
#expect(cache.box(forID: id) === box)
41+
}
42+
43+
@Test("Count limit evicts oldest entries first (FIFO)")
44+
func countLimitEvictsFIFO() {
45+
let cache = RowDisplayCache(countLimit: 3, costLimit: 1_000_000)
46+
for index in 1...3 {
47+
cache.setBox(makeBox(["row\(index)"]), forID: .existing(index), cost: 4)
48+
}
49+
#expect(cache.box(forID: .existing(1)) != nil)
50+
51+
// Fourth insertion should evict the first.
52+
cache.setBox(makeBox(["row4"]), forID: .existing(4), cost: 4)
53+
#expect(cache.box(forID: .existing(1)) == nil)
54+
#expect(cache.box(forID: .existing(2)) != nil)
55+
#expect(cache.box(forID: .existing(3)) != nil)
56+
#expect(cache.box(forID: .existing(4)) != nil)
57+
}
58+
59+
@Test("Cost limit evicts even when count is under limit")
60+
func costLimitEvicts() {
61+
let cache = RowDisplayCache(countLimit: 1_000, costLimit: 10)
62+
// First insert costs 6; under cap.
63+
cache.setBox(makeBox(["abcdef"]), forID: .existing(1), cost: 6)
64+
// Second insert costs 6 more; total 12 > 10, evicts first.
65+
cache.setBox(makeBox(["123456"]), forID: .existing(2), cost: 6)
66+
67+
#expect(cache.box(forID: .existing(1)) == nil)
68+
#expect(cache.box(forID: .existing(2)) != nil)
69+
}
70+
71+
@Test("Replacing an existing key does not consume queue slot")
72+
func replaceExistingKey() {
73+
let cache = RowDisplayCache(countLimit: 2, costLimit: 1_000_000)
74+
cache.setBox(makeBox(["v1"]), forID: .existing(1), cost: 2)
75+
cache.setBox(makeBox(["v2"]), forID: .existing(2), cost: 2)
76+
77+
// Replace id=1 without expanding the cache.
78+
cache.setBox(makeBox(["v1-updated"]), forID: .existing(1), cost: 10)
79+
#expect(cache.box(forID: .existing(1))?.values.first == "v1-updated")
80+
#expect(cache.box(forID: .existing(2))?.values.first == "v2")
81+
82+
// Adding a new entry now evicts the oldest in insertion order (still id=1
83+
// because replacing did not re-add it to the order).
84+
cache.setBox(makeBox(["v3"]), forID: .existing(3), cost: 2)
85+
#expect(cache.box(forID: .existing(1)) == nil)
86+
#expect(cache.box(forID: .existing(2)) != nil)
87+
#expect(cache.box(forID: .existing(3)) != nil)
88+
}
89+
90+
@Test("removeAll empties the cache and resets state")
91+
func removeAllResetsState() {
92+
let cache = RowDisplayCache()
93+
for index in 1...10 {
94+
cache.setBox(makeBox(["x"]), forID: .existing(index), cost: 1)
95+
}
96+
cache.removeAll()
97+
for index in 1...10 {
98+
#expect(cache.box(forID: .existing(index)) == nil)
99+
}
100+
101+
// Cache continues to work after removeAll.
102+
cache.setBox(makeBox(["fresh"]), forID: .existing(100), cost: 5)
103+
#expect(cache.box(forID: .existing(100))?.values.first == "fresh")
104+
}
105+
106+
@Test("Inserted row IDs of both kinds round-trip")
107+
func mixedRowIDKinds() {
108+
let cache = RowDisplayCache()
109+
let existingID = RowID.existing(5)
110+
let insertedID = RowID.inserted(UUID())
111+
cache.setBox(makeBox(["existing"]), forID: existingID, cost: 8)
112+
cache.setBox(makeBox(["inserted"]), forID: insertedID, cost: 8)
113+
#expect(cache.box(forID: existingID)?.values.first == "existing")
114+
#expect(cache.box(forID: insertedID)?.values.first == "inserted")
115+
}
116+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//
2+
// MariaDBTypeNameTests.swift
3+
// TableProTests
4+
//
5+
6+
#if canImport(MySQLDriverPlugin)
7+
import Testing
8+
9+
@testable import MySQLDriverPlugin
10+
11+
@Suite("MariaDB type name resolution")
12+
struct MariaDBTypeNameTests {
13+
private func resolve(typeRaw: UInt32, charsetnr: UInt32 = 33, flags: UInt = 0, length: UInt = 0) -> String {
14+
mariaDBTypeName(typeRaw: typeRaw, flags: flags, charsetnr: charsetnr, length: length)
15+
}
16+
17+
private let binaryFlagAndCharset: (flags: UInt, charsetnr: UInt32) = (mysqlBinaryFlag, mysqlBinaryCharset)
18+
19+
// MARK: - Numeric types (regression for #1209: numeric routed as bytes)
20+
21+
@Test("INT family resolves to numeric type names")
22+
func numericTypes() {
23+
#expect(resolve(typeRaw: 1) == "TINYINT")
24+
#expect(resolve(typeRaw: 2) == "SMALLINT")
25+
#expect(resolve(typeRaw: 3) == "INT")
26+
#expect(resolve(typeRaw: 8) == "BIGINT")
27+
#expect(resolve(typeRaw: 9) == "MEDIUMINT")
28+
}
29+
30+
@Test("DECIMAL and floating point resolve to their type names")
31+
func decimalAndFloat() {
32+
#expect(resolve(typeRaw: 0) == "DECIMAL")
33+
#expect(resolve(typeRaw: 4) == "FLOAT")
34+
#expect(resolve(typeRaw: 5) == "DOUBLE")
35+
#expect(resolve(typeRaw: 246) == "NEWDECIMAL")
36+
}
37+
38+
// MARK: - Temporal types
39+
40+
@Test("Temporal types resolve to their type names")
41+
func temporalTypes() {
42+
#expect(resolve(typeRaw: 7) == "TIMESTAMP")
43+
#expect(resolve(typeRaw: 10) == "DATE")
44+
#expect(resolve(typeRaw: 11) == "TIME")
45+
#expect(resolve(typeRaw: 12) == "DATETIME")
46+
#expect(resolve(typeRaw: 13) == "YEAR")
47+
#expect(resolve(typeRaw: 14) == "NEWDATE")
48+
}
49+
50+
// MARK: - Misc
51+
52+
@Test("JSON, BIT, GEOMETRY resolve to their type names")
53+
func miscTypes() {
54+
#expect(resolve(typeRaw: 16) == "BIT")
55+
#expect(resolve(typeRaw: 245) == "JSON")
56+
#expect(resolve(typeRaw: 255) == "GEOMETRY")
57+
}
58+
59+
@Test("Unknown type code returns UNKNOWN")
60+
func unknownType() {
61+
#expect(resolve(typeRaw: 999) == "UNKNOWN")
62+
}
63+
64+
// MARK: - BINARY / VARBINARY (regression for #1217: data wipe on edit)
65+
66+
@Test("BINARY(N) resolves to BINARY only when binary flag set")
67+
func binaryWithFlagAndCharset() {
68+
let (flags, charsetnr) = binaryFlagAndCharset
69+
#expect(resolve(typeRaw: 254, charsetnr: charsetnr, flags: flags) == "BINARY")
70+
}
71+
72+
@Test("CHAR(N) without binary flag stays CHAR")
73+
func charWithoutBinaryFlag() {
74+
#expect(resolve(typeRaw: 254, charsetnr: 33, flags: 0) == "CHAR")
75+
}
76+
77+
@Test("CHAR with charset 63 but no binary flag stays CHAR")
78+
func charBinaryCharsetWithoutFlag() {
79+
#expect(resolve(typeRaw: 254, charsetnr: mysqlBinaryCharset, flags: 0) == "CHAR")
80+
}
81+
82+
@Test("CHAR with binary flag but non-binary charset stays CHAR")
83+
func charFlagWithoutBinaryCharset() {
84+
#expect(resolve(typeRaw: 254, charsetnr: 33, flags: mysqlBinaryFlag) == "CHAR")
85+
}
86+
87+
@Test("VARBINARY(N) resolves to VARBINARY only when binary flag set with charset 63")
88+
func varbinaryWithFlagAndCharset() {
89+
let (flags, charsetnr) = binaryFlagAndCharset
90+
#expect(resolve(typeRaw: 253, charsetnr: charsetnr, flags: flags) == "VARBINARY")
91+
}
92+
93+
@Test("VARCHAR(N) without binary flag stays VARCHAR")
94+
func varcharWithoutBinaryFlag() {
95+
#expect(resolve(typeRaw: 253, charsetnr: 33, flags: 0) == "VARCHAR")
96+
}
97+
98+
// MARK: - BLOB family
99+
100+
@Test("BLOB family resolves binary vs text by isBinary flag")
101+
func blobFamilyBinaryVsText() {
102+
let (flags, charsetnr) = binaryFlagAndCharset
103+
#expect(resolve(typeRaw: 249, charsetnr: charsetnr, flags: flags) == "TINYBLOB")
104+
#expect(resolve(typeRaw: 249, charsetnr: 33, flags: 0) == "TINYTEXT")
105+
#expect(resolve(typeRaw: 250, charsetnr: charsetnr, flags: flags) == "MEDIUMBLOB")
106+
#expect(resolve(typeRaw: 250, charsetnr: 33, flags: 0) == "MEDIUMTEXT")
107+
#expect(resolve(typeRaw: 251, charsetnr: charsetnr, flags: flags) == "LONGBLOB")
108+
#expect(resolve(typeRaw: 251, charsetnr: 33, flags: 0) == "LONGTEXT")
109+
}
110+
111+
@Test("Generic BLOB (252) routes by length and binary flag")
112+
func blobLengthRouting() {
113+
let (flags, charsetnr) = binaryFlagAndCharset
114+
#expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100) == "BLOB")
115+
#expect(resolve(typeRaw: 252, charsetnr: charsetnr, flags: flags, length: 100_000) == "LONGBLOB")
116+
#expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100) == "TEXT")
117+
#expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100_000) == "LONGTEXT")
118+
}
119+
}
120+
#endif

TableProTests/Plugins/PluginCellValueSortKeyTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,38 @@ struct PluginCellValueSortKeyTests {
3636
#expect(b < c)
3737
#expect(a != b)
3838
}
39+
40+
// MARK: - asText contract
41+
//
42+
// `asText` MUST return nil for `.bytes` so callers cannot accidentally treat
43+
// binary cells as editable text. Returning empty string instead would cause
44+
// the inline cell editor to display the empty field on click and commit ""
45+
// on focus-out, silently wiping the original bytes (regression for #1217).
46+
47+
@Test(".text.asText returns the text verbatim")
48+
func textAsText() {
49+
#expect(PluginCellValue.text("hello").asText == "hello")
50+
#expect(PluginCellValue.text("").asText == "")
51+
}
52+
53+
@Test(".bytes.asText returns nil so inline edit is gated")
54+
func bytesAsTextIsNil() {
55+
#expect(PluginCellValue.bytes(Data([0xDE, 0xAD])).asText == nil)
56+
#expect(PluginCellValue.bytes(Data()).asText == nil)
57+
}
58+
59+
@Test(".null.asText returns nil")
60+
func nullAsText() {
61+
#expect(PluginCellValue.null.asText == nil)
62+
}
63+
64+
// MARK: - asBytes contract
65+
66+
@Test(".bytes.asBytes returns the data; other cases return nil")
67+
func asBytes() {
68+
let data = Data([0x01, 0x02, 0x03])
69+
#expect(PluginCellValue.bytes(data).asBytes == data)
70+
#expect(PluginCellValue.text("hello").asBytes == nil)
71+
#expect(PluginCellValue.null.asBytes == nil)
72+
}
3973
}

0 commit comments

Comments
 (0)