Skip to content

Commit 58a9020

Browse files
authored
Fix column type classification across all databases (#462)
* feat: fix column type classification across all databases Extract ColumnTypeClassifier from PluginDriverAdapter with dictionary-driven classification, wrapper stripping (Nullable/LowCardinality), and database-specific conventions (MySQL TINYINT(1), MSSQL BIT). Add ClickHouse Enum8/Enum16 value parsing and NTEXT long text support. * fix: remove duplicate INT8 key and add multi-value nullable enum test
1 parent f91be79 commit 58a9020

7 files changed

Lines changed: 1188 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Enum/set picker support for PostgreSQL custom enums, ClickHouse Enum8/Enum16, and DuckDB ENUM types
13+
- Boolean picker for MSSQL BIT columns and MySQL TINYINT(1) convention
14+
- Correct type classification for ClickHouse Nullable()/LowCardinality() wrappers, MSSQL MONEY/IMAGE/DATETIME2, DuckDB unsigned integers, and parameterized MySQL integer types
15+
1016
## [0.24.1] - 2026-03-26
1117

1218
### Fixed

TablePro/Core/Plugins/PluginDriverAdapter.swift

Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
1212
private(set) var status: ConnectionStatus = .disconnected
1313
private let pluginDriver: any PluginDatabaseDriver
1414
private var columnTypeCache: [String: ColumnType] = [:]
15+
private let classifier = ColumnTypeClassifier()
1516

1617
var serverVersion: String? { pluginDriver.serverVersion }
1718
var parameterStyle: ParameterStyle { pluginDriver.parameterStyle }
@@ -423,66 +424,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
423424

424425
private func mapColumnType(rawTypeName: String) -> ColumnType {
425426
if let cached = columnTypeCache[rawTypeName] { return cached }
426-
let result = classifyColumnType(rawTypeName: rawTypeName)
427+
let result = classifier.classify(rawTypeName: rawTypeName)
427428
columnTypeCache[rawTypeName] = result
428429
return result
429430
}
430-
431-
private func classifyColumnType(rawTypeName: String) -> ColumnType {
432-
let upper = rawTypeName.uppercased()
433-
434-
if upper.contains("BOOL") {
435-
return .boolean(rawType: rawTypeName)
436-
}
437-
438-
if upper == "INT" || upper == "INTEGER" || upper == "BIGINT" || upper == "SMALLINT"
439-
|| upper == "TINYINT" || upper == "MEDIUMINT" || upper.hasSuffix("SERIAL") {
440-
return .integer(rawType: rawTypeName)
441-
}
442-
443-
if upper == "FLOAT" || upper == "DOUBLE" || upper == "DECIMAL" || upper == "NUMERIC"
444-
|| upper == "REAL" || upper == "NUMBER" || upper.hasPrefix("DECIMAL(")
445-
|| upper.hasPrefix("NUMERIC(") || upper.hasPrefix("NUMBER(") {
446-
return .decimal(rawType: rawTypeName)
447-
}
448-
449-
if upper == "DATE" {
450-
return .date(rawType: rawTypeName)
451-
}
452-
453-
if upper.contains("TIMESTAMP") {
454-
return .timestamp(rawType: rawTypeName)
455-
}
456-
457-
if upper == "DATETIME" {
458-
return .datetime(rawType: rawTypeName)
459-
}
460-
461-
if upper == "TIME" {
462-
return .timestamp(rawType: rawTypeName)
463-
}
464-
465-
if upper == "JSON" || upper == "JSONB" {
466-
return .json(rawType: rawTypeName)
467-
}
468-
469-
if upper == "BLOB" || upper == "BYTEA" || upper == "BINARY" || upper == "VARBINARY"
470-
|| upper.hasPrefix("BINARY(") || upper.hasPrefix("VARBINARY(") || upper == "RAW" {
471-
return .blob(rawType: rawTypeName)
472-
}
473-
474-
if upper.hasPrefix("ENUM") {
475-
return .enumType(rawType: rawTypeName, values: nil)
476-
}
477-
478-
if upper.hasPrefix("SET(") {
479-
return .set(rawType: rawTypeName, values: nil)
480-
}
481-
482-
if upper == "GEOMETRY" || upper == "POINT" || upper == "LINESTRING" || upper == "POLYGON" {
483-
return .spatial(rawType: rawTypeName)
484-
}
485-
486-
return .text(rawType: rawTypeName)
487-
}
488431
}

TablePro/Core/Services/ColumnType.swift

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ enum ColumnType: Equatable {
8888
return true
8989
}
9090

91-
// PostgreSQL/SQLite CLOB type
92-
if raw == "CLOB" {
91+
// PostgreSQL/SQLite CLOB type, MSSQL NTEXT type
92+
if raw == "CLOB" || raw == "NTEXT" {
9393
return true
9494
}
9595

@@ -213,4 +213,46 @@ enum ColumnType: Equatable {
213213

214214
return values.isEmpty ? nil : values
215215
}
216+
217+
/// Parse enum values from ClickHouse Enum8/Enum16 syntax: "Enum8('a' = 1, 'b' = 2)"
218+
static func parseClickHouseEnumValues(from typeString: String) -> [String]? {
219+
let upper = typeString.uppercased()
220+
guard upper.hasPrefix("ENUM8(") || upper.hasPrefix("ENUM16(") else {
221+
return nil
222+
}
223+
224+
guard let openParen = typeString.firstIndex(of: "("),
225+
let closeParen = typeString.lastIndex(of: ")") else {
226+
return nil
227+
}
228+
229+
let inner = String(typeString[typeString.index(after: openParen)..<closeParen])
230+
231+
// Parse quoted values, ignoring the " = N" assignment suffixes
232+
var values: [String] = []
233+
var current = ""
234+
var inQuote = false
235+
var escaped = false
236+
237+
for char in inner {
238+
if escaped {
239+
current.append(char)
240+
escaped = false
241+
} else if char == "\\" {
242+
escaped = true
243+
} else if char == "'" {
244+
if inQuote {
245+
values.append(current)
246+
current = ""
247+
inQuote = false
248+
} else {
249+
inQuote = true
250+
}
251+
} else if inQuote {
252+
current.append(char)
253+
}
254+
}
255+
256+
return values.isEmpty ? nil : values
257+
}
216258
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//
2+
// ColumnTypeClassifier.swift
3+
// TablePro
4+
//
5+
// Maps raw database type strings to semantic ColumnType values.
6+
// Handles type wrappers (Nullable, LowCardinality), parameterized types,
7+
// and database-specific conventions across all supported databases.
8+
//
9+
10+
import Foundation
11+
12+
struct ColumnTypeClassifier {
13+
14+
func classify(rawTypeName: String) -> ColumnType {
15+
let stripped = stripWrappers(rawTypeName)
16+
let (base, params) = extractBaseAndParams(stripped)
17+
let upper = base.uppercased()
18+
19+
// MySQL convention: TINYINT(1) means boolean
20+
if upper == "TINYINT", params == "1" {
21+
return .boolean(rawType: rawTypeName)
22+
}
23+
24+
if let factory = Self.typeLookup[upper] {
25+
return factory(rawTypeName)
26+
}
27+
28+
return classifyByPattern(upper: upper, rawTypeName: rawTypeName)
29+
}
30+
31+
// MARK: - Wrapper Stripping
32+
33+
private func stripWrappers(_ value: String) -> String {
34+
for prefix in ["Nullable(", "LowCardinality("] {
35+
if value.hasPrefix(prefix), value.hasSuffix(")") {
36+
let startIndex = value.index(value.startIndex, offsetBy: prefix.count)
37+
let endIndex = value.index(before: value.endIndex)
38+
let inner = String(value[startIndex..<endIndex])
39+
return stripWrappers(inner)
40+
}
41+
}
42+
return value
43+
}
44+
45+
// MARK: - Base / Params Extraction
46+
47+
private func extractBaseAndParams(_ value: String) -> (base: String, params: String?) {
48+
guard let parenIndex = value.firstIndex(of: "(") else {
49+
return (value, nil)
50+
}
51+
let base = String(value[value.startIndex..<parenIndex])
52+
guard let lastParen = value.lastIndex(of: ")") else {
53+
return (value, nil)
54+
}
55+
let paramsStart = value.index(after: parenIndex)
56+
let params = String(value[paramsStart..<lastParen]).trimmingCharacters(in: .whitespaces)
57+
return (base, params)
58+
}
59+
60+
// MARK: - Pattern Fallback
61+
62+
private func classifyByPattern(upper: String, rawTypeName: String) -> ColumnType {
63+
if upper.contains("BOOL") {
64+
return .boolean(rawType: rawTypeName)
65+
}
66+
if upper.hasSuffix("SERIAL") {
67+
return .integer(rawType: rawTypeName)
68+
}
69+
if upper.hasSuffix("INT") {
70+
return .integer(rawType: rawTypeName)
71+
}
72+
if upper.hasPrefix("TIMESTAMP") {
73+
return .timestamp(rawType: rawTypeName)
74+
}
75+
if upper.hasSuffix("TEXT") || upper.hasSuffix("CHAR") {
76+
return .text(rawType: rawTypeName)
77+
}
78+
if upper.contains("BLOB") {
79+
return .blob(rawType: rawTypeName)
80+
}
81+
if upper.hasPrefix("ENUM") {
82+
return .enumType(rawType: rawTypeName, values: nil)
83+
}
84+
if upper.hasPrefix("SET(") {
85+
return .set(rawType: rawTypeName, values: nil)
86+
}
87+
return .text(rawType: rawTypeName)
88+
}
89+
90+
// MARK: - Type Lookup Table
91+
92+
// swiftlint:disable:next function_body_length
93+
private static let typeLookup: [String: (String) -> ColumnType] = {
94+
var map: [String: (String) -> ColumnType] = [:]
95+
96+
// Boolean
97+
for key in ["BOOL", "BOOLEAN", "BIT"] {
98+
map[key] = { .boolean(rawType: $0) }
99+
}
100+
101+
// Integer
102+
for key in [
103+
"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT",
104+
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
105+
"INT2", "INT4",
106+
"INT8", "INT16", "INT32", "INT64", "INT128", "INT256",
107+
"UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256",
108+
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT"
109+
] {
110+
map[key] = { .integer(rawType: $0) }
111+
}
112+
113+
// Decimal
114+
for key in [
115+
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", "NUMBER",
116+
"MONEY", "SMALLMONEY",
117+
"FLOAT32", "FLOAT64",
118+
"DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256",
119+
"BINARY_FLOAT", "BINARY_DOUBLE",
120+
"DOUBLE PRECISION"
121+
] {
122+
map[key] = { .decimal(rawType: $0) }
123+
}
124+
125+
// Date
126+
for key in ["DATE", "DATE32"] {
127+
map[key] = { .date(rawType: $0) }
128+
}
129+
130+
// Timestamp
131+
for key in [
132+
"TIMESTAMP", "TIMESTAMPTZ", "TIMESTAMP_TZ", "TIMESTAMP_NTZ",
133+
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS",
134+
"TIME", "TIMETZ"
135+
] {
136+
map[key] = { .timestamp(rawType: $0) }
137+
}
138+
139+
// Datetime
140+
for key in [
141+
"DATETIME", "DATETIME2", "DATETIME64",
142+
"DATETIMEOFFSET", "SMALLDATETIME"
143+
] {
144+
map[key] = { .datetime(rawType: $0) }
145+
}
146+
147+
// JSON
148+
for key in ["JSON", "JSONB"] {
149+
map[key] = { .json(rawType: $0) }
150+
}
151+
152+
// Blob
153+
for key in [
154+
"BLOB", "BYTEA", "BINARY", "VARBINARY", "RAW", "IMAGE",
155+
"TINYBLOB", "MEDIUMBLOB", "LONGBLOB"
156+
] {
157+
map[key] = { .blob(rawType: $0) }
158+
}
159+
160+
// Enum
161+
for key in ["ENUM", "ENUM8", "ENUM16"] {
162+
map[key] = { .enumType(rawType: $0, values: nil) }
163+
}
164+
165+
// Set
166+
map["SET"] = { .set(rawType: $0, values: nil) }
167+
168+
// Spatial
169+
for key in [
170+
"GEOMETRY", "POINT", "LINESTRING", "POLYGON",
171+
"MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON",
172+
"GEOGRAPHY", "GEOMETRYCOLLECTION"
173+
] {
174+
map[key] = { .spatial(rawType: $0) }
175+
}
176+
177+
// Text (explicit entries for common types not caught by fallback)
178+
for key in [
179+
"TEXT", "VARCHAR", "CHAR", "NVARCHAR", "NCHAR", "NTEXT",
180+
"VARCHAR2", "CLOB", "NCLOB",
181+
"STRING", "FIXEDSTRING",
182+
"UUID", "UNIQUEIDENTIFIER", "SQL_VARIANT",
183+
"TINYTEXT", "MEDIUMTEXT", "LONGTEXT"
184+
] {
185+
map[key] = { .text(rawType: $0) }
186+
}
187+
188+
return map
189+
}()
190+
}

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -994,10 +994,12 @@ final class MainContentCoordinator {
994994
) async -> [String: [String]] {
995995
var result: [String: [String]] = [:]
996996

997-
// Build enum/set value lookup map from column types (MySQL/MariaDB)
997+
// Build enum/set value lookup map from column types (MySQL/MariaDB + ClickHouse Enum8/Enum16)
998998
for col in columnInfo {
999999
if let values = ColumnType.parseEnumValues(from: col.dataType) {
10001000
result[col.name] = values
1001+
} else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) {
1002+
result[col.name] = values
10011003
}
10021004
}
10031005

0 commit comments

Comments
 (0)