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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Enum/set picker support for PostgreSQL custom enums, ClickHouse Enum8/Enum16, and DuckDB ENUM types
- Boolean picker for MSSQL BIT columns and MySQL TINYINT(1) convention
- Correct type classification for ClickHouse Nullable()/LowCardinality() wrappers, MSSQL MONEY/IMAGE/DATETIME2, DuckDB unsigned integers, and parameterized MySQL integer types

## [0.24.1] - 2026-03-26

### Fixed
Expand Down
61 changes: 2 additions & 59 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
private(set) var status: ConnectionStatus = .disconnected
private let pluginDriver: any PluginDatabaseDriver
private var columnTypeCache: [String: ColumnType] = [:]
private let classifier = ColumnTypeClassifier()

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

private func mapColumnType(rawTypeName: String) -> ColumnType {
if let cached = columnTypeCache[rawTypeName] { return cached }
let result = classifyColumnType(rawTypeName: rawTypeName)
let result = classifier.classify(rawTypeName: rawTypeName)
columnTypeCache[rawTypeName] = result
return result
}

private func classifyColumnType(rawTypeName: String) -> ColumnType {
let upper = rawTypeName.uppercased()

if upper.contains("BOOL") {
return .boolean(rawType: rawTypeName)
}

if upper == "INT" || upper == "INTEGER" || upper == "BIGINT" || upper == "SMALLINT"
|| upper == "TINYINT" || upper == "MEDIUMINT" || upper.hasSuffix("SERIAL") {
return .integer(rawType: rawTypeName)
}

if upper == "FLOAT" || upper == "DOUBLE" || upper == "DECIMAL" || upper == "NUMERIC"
|| upper == "REAL" || upper == "NUMBER" || upper.hasPrefix("DECIMAL(")
|| upper.hasPrefix("NUMERIC(") || upper.hasPrefix("NUMBER(") {
return .decimal(rawType: rawTypeName)
}

if upper == "DATE" {
return .date(rawType: rawTypeName)
}

if upper.contains("TIMESTAMP") {
return .timestamp(rawType: rawTypeName)
}

if upper == "DATETIME" {
return .datetime(rawType: rawTypeName)
}

if upper == "TIME" {
return .timestamp(rawType: rawTypeName)
}

if upper == "JSON" || upper == "JSONB" {
return .json(rawType: rawTypeName)
}

if upper == "BLOB" || upper == "BYTEA" || upper == "BINARY" || upper == "VARBINARY"
|| upper.hasPrefix("BINARY(") || upper.hasPrefix("VARBINARY(") || upper == "RAW" {
return .blob(rawType: rawTypeName)
}

if upper.hasPrefix("ENUM") {
return .enumType(rawType: rawTypeName, values: nil)
}

if upper.hasPrefix("SET(") {
return .set(rawType: rawTypeName, values: nil)
}

if upper == "GEOMETRY" || upper == "POINT" || upper == "LINESTRING" || upper == "POLYGON" {
return .spatial(rawType: rawTypeName)
}

return .text(rawType: rawTypeName)
}
}
46 changes: 44 additions & 2 deletions TablePro/Core/Services/ColumnType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ enum ColumnType: Equatable {
return true
}

// PostgreSQL/SQLite CLOB type
if raw == "CLOB" {
// PostgreSQL/SQLite CLOB type, MSSQL NTEXT type
if raw == "CLOB" || raw == "NTEXT" {
return true
}

Expand Down Expand Up @@ -213,4 +213,46 @@ enum ColumnType: Equatable {

return values.isEmpty ? nil : values
}

/// Parse enum values from ClickHouse Enum8/Enum16 syntax: "Enum8('a' = 1, 'b' = 2)"
static func parseClickHouseEnumValues(from typeString: String) -> [String]? {
let upper = typeString.uppercased()
guard upper.hasPrefix("ENUM8(") || upper.hasPrefix("ENUM16(") else {
return nil
}

guard let openParen = typeString.firstIndex(of: "("),
let closeParen = typeString.lastIndex(of: ")") else {
return nil
}

let inner = String(typeString[typeString.index(after: openParen)..<closeParen])

// Parse quoted values, ignoring the " = N" assignment suffixes
var values: [String] = []
var current = ""
var inQuote = false
var escaped = false

for char in inner {
if escaped {
current.append(char)
escaped = false
} else if char == "\\" {
escaped = true
} else if char == "'" {
if inQuote {
values.append(current)
current = ""
inQuote = false
} else {
inQuote = true
}
} else if inQuote {
current.append(char)
}
}

return values.isEmpty ? nil : values
}
}
190 changes: 190 additions & 0 deletions TablePro/Core/Services/ColumnTypeClassifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//
// ColumnTypeClassifier.swift
// TablePro
//
// Maps raw database type strings to semantic ColumnType values.
// Handles type wrappers (Nullable, LowCardinality), parameterized types,
// and database-specific conventions across all supported databases.
//

import Foundation

struct ColumnTypeClassifier {

func classify(rawTypeName: String) -> ColumnType {
let stripped = stripWrappers(rawTypeName)
let (base, params) = extractBaseAndParams(stripped)
let upper = base.uppercased()

// MySQL convention: TINYINT(1) means boolean
if upper == "TINYINT", params == "1" {
return .boolean(rawType: rawTypeName)
}

if let factory = Self.typeLookup[upper] {
return factory(rawTypeName)
}

return classifyByPattern(upper: upper, rawTypeName: rawTypeName)
}

// MARK: - Wrapper Stripping

private func stripWrappers(_ value: String) -> String {
for prefix in ["Nullable(", "LowCardinality("] {
if value.hasPrefix(prefix), value.hasSuffix(")") {
let startIndex = value.index(value.startIndex, offsetBy: prefix.count)
let endIndex = value.index(before: value.endIndex)
let inner = String(value[startIndex..<endIndex])
return stripWrappers(inner)
}
}
return value
}

// MARK: - Base / Params Extraction

private func extractBaseAndParams(_ value: String) -> (base: String, params: String?) {
guard let parenIndex = value.firstIndex(of: "(") else {
return (value, nil)
}
let base = String(value[value.startIndex..<parenIndex])
guard let lastParen = value.lastIndex(of: ")") else {
return (value, nil)
}
let paramsStart = value.index(after: parenIndex)
let params = String(value[paramsStart..<lastParen]).trimmingCharacters(in: .whitespaces)
return (base, params)
}

// MARK: - Pattern Fallback

private func classifyByPattern(upper: String, rawTypeName: String) -> ColumnType {
if upper.contains("BOOL") {
return .boolean(rawType: rawTypeName)
}
if upper.hasSuffix("SERIAL") {
return .integer(rawType: rawTypeName)
}
if upper.hasSuffix("INT") {
return .integer(rawType: rawTypeName)
}
if upper.hasPrefix("TIMESTAMP") {
return .timestamp(rawType: rawTypeName)
}
if upper.hasSuffix("TEXT") || upper.hasSuffix("CHAR") {
return .text(rawType: rawTypeName)
}
if upper.contains("BLOB") {
return .blob(rawType: rawTypeName)
}
if upper.hasPrefix("ENUM") {
return .enumType(rawType: rawTypeName, values: nil)
}
if upper.hasPrefix("SET(") {
return .set(rawType: rawTypeName, values: nil)
}
return .text(rawType: rawTypeName)
}

// MARK: - Type Lookup Table

// swiftlint:disable:next function_body_length
private static let typeLookup: [String: (String) -> ColumnType] = {
var map: [String: (String) -> ColumnType] = [:]

// Boolean
for key in ["BOOL", "BOOLEAN", "BIT"] {
map[key] = { .boolean(rawType: $0) }
}

// Integer
for key in [
"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT",
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
"INT2", "INT4",
"INT8", "INT16", "INT32", "INT64", "INT128", "INT256",
"UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256",
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT"
] {
map[key] = { .integer(rawType: $0) }
}

// Decimal
for key in [
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", "NUMBER",
"MONEY", "SMALLMONEY",
"FLOAT32", "FLOAT64",
"DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256",
"BINARY_FLOAT", "BINARY_DOUBLE",
"DOUBLE PRECISION"
] {
map[key] = { .decimal(rawType: $0) }
}

// Date
for key in ["DATE", "DATE32"] {
map[key] = { .date(rawType: $0) }
}

// Timestamp
for key in [
"TIMESTAMP", "TIMESTAMPTZ", "TIMESTAMP_TZ", "TIMESTAMP_NTZ",
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS",
"TIME", "TIMETZ"
] {
map[key] = { .timestamp(rawType: $0) }
}

// Datetime
for key in [
"DATETIME", "DATETIME2", "DATETIME64",
"DATETIMEOFFSET", "SMALLDATETIME"
] {
map[key] = { .datetime(rawType: $0) }
}

// JSON
for key in ["JSON", "JSONB"] {
map[key] = { .json(rawType: $0) }
}

// Blob
for key in [
"BLOB", "BYTEA", "BINARY", "VARBINARY", "RAW", "IMAGE",
"TINYBLOB", "MEDIUMBLOB", "LONGBLOB"
] {
map[key] = { .blob(rawType: $0) }
}

// Enum
for key in ["ENUM", "ENUM8", "ENUM16"] {
map[key] = { .enumType(rawType: $0, values: nil) }
}

// Set
map["SET"] = { .set(rawType: $0, values: nil) }

// Spatial
for key in [
"GEOMETRY", "POINT", "LINESTRING", "POLYGON",
"MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON",
"GEOGRAPHY", "GEOMETRYCOLLECTION"
] {
map[key] = { .spatial(rawType: $0) }
}

// Text (explicit entries for common types not caught by fallback)
for key in [
"TEXT", "VARCHAR", "CHAR", "NVARCHAR", "NCHAR", "NTEXT",
"VARCHAR2", "CLOB", "NCLOB",
"STRING", "FIXEDSTRING",
"UUID", "UNIQUEIDENTIFIER", "SQL_VARIANT",
"TINYTEXT", "MEDIUMTEXT", "LONGTEXT"
] {
map[key] = { .text(rawType: $0) }
}

return map
}()
}
4 changes: 3 additions & 1 deletion TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -994,10 +994,12 @@ final class MainContentCoordinator {
) async -> [String: [String]] {
var result: [String: [String]] = [:]

// Build enum/set value lookup map from column types (MySQL/MariaDB)
// Build enum/set value lookup map from column types (MySQL/MariaDB + ClickHouse Enum8/Enum16)
for col in columnInfo {
if let values = ColumnType.parseEnumValues(from: col.dataType) {
result[col.name] = values
} else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) {
result[col.name] = values
}
}

Expand Down
Loading
Loading