From ac2503d2b3608d2dee0dc5d1815a519cc32ad6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 26 Mar 2026 11:07:26 +0700 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 6 + .../Core/Plugins/PluginDriverAdapter.swift | 61 +- TablePro/Core/Services/ColumnType.swift | 46 +- .../Core/Services/ColumnTypeClassifier.swift | 190 ++++ .../Views/Main/MainContentCoordinator.swift | 4 +- .../Services/ColumnTypeClassifierTests.swift | 873 ++++++++++++++++++ .../Core/Services/ColumnTypeTests.swift | 64 ++ 7 files changed, 1182 insertions(+), 62 deletions(-) create mode 100644 TablePro/Core/Services/ColumnTypeClassifier.swift create mode 100644 TableProTests/Core/Services/ColumnTypeClassifierTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6f304b..2efb5c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index c435898a..77a700fa 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -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 } @@ -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) - } } diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index a48cf100..7df28c7b 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -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 } @@ -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).. 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.. (base: String, params: String?) { + guard let parenIndex = value.firstIndex(of: "(") else { + return (value, nil) + } + let base = String(value[value.startIndex.. 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", + "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 + }() +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6ceb445e..9d2428b3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -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 } } diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift new file mode 100644 index 00000000..887788f8 --- /dev/null +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -0,0 +1,873 @@ +// +// ColumnTypeClassifierTests.swift +// TableProTests +// +// Tests for ColumnTypeClassifier raw type name to ColumnType mapping. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Column Type Classifier") +struct ColumnTypeClassifierTests { + private let classifier = ColumnTypeClassifier() + + // MARK: - Helpers + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isDate(_ type: ColumnType) -> Bool { + if case .date = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + private func isDatetime(_ type: ColumnType) -> Bool { + if case .datetime = type { return true } + return false + } + + private func isSpatial(_ type: ColumnType) -> Bool { + if case .spatial = type { return true } + return false + } + + // MARK: - Generic / Wrapper Stripping + + @Suite("Generic / Wrapper Stripping") + struct WrapperTests { + private let classifier = ColumnTypeClassifier() + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDatetime(_ type: ColumnType) -> Bool { + if case .datetime = type { return true } + return false + } + + @Test("Nullable(String) classifies as text") + func nullableString() { + let result = classifier.classify(rawTypeName: "Nullable(String)") + #expect(isText(result)) + } + + @Test("LowCardinality(String) classifies as text") + func lowCardinalityString() { + let result = classifier.classify(rawTypeName: "LowCardinality(String)") + #expect(isText(result)) + } + + @Test("LowCardinality(Nullable(UInt32)) classifies as integer") + func nestedWrappers() { + let result = classifier.classify(rawTypeName: "LowCardinality(Nullable(UInt32))") + #expect(isInteger(result)) + } + + @Test("Nullable(DateTime64(3)) classifies as datetime") + func nullableDatetime64() { + let result = classifier.classify(rawTypeName: "Nullable(DateTime64(3))") + #expect(isDatetime(result)) + } + + @Test("Nullable(Enum8('a' = 1)) classifies as enum") + func nullableEnum() { + let result = classifier.classify(rawTypeName: "Nullable(Enum8('a' = 1))") + #expect(result.isEnumType) + } + + @Test("Empty string classifies as text") + func emptyString() { + let result = classifier.classify(rawTypeName: "") + #expect(isText(result)) + } + } + + // MARK: - MySQL Types + + @Suite("MySQL Types") + struct MySQLTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isDate(_ type: ColumnType) -> Bool { + if case .date = type { return true } + return false + } + + private func isDatetime(_ type: ColumnType) -> Bool { + if case .datetime = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + private func isSpatial(_ type: ColumnType) -> Bool { + if case .spatial = type { return true } + return false + } + + @Test("TINYINT(1) classifies as boolean (MySQL convention)") + func tinyint1IsBoolean() { + #expect(classifier.classify(rawTypeName: "TINYINT(1)").isBooleanType) + } + + @Test("TINYINT classifies as integer") + func tinyintIsInteger() { + #expect(isInteger(classifier.classify(rawTypeName: "TINYINT"))) + } + + @Test("TINYINT(4) classifies as integer, not boolean") + func tinyint4IsInteger() { + let result = classifier.classify(rawTypeName: "TINYINT(4)") + #expect(isInteger(result)) + #expect(!result.isBooleanType) + } + + @Test("INT(11) classifies as integer") + func int11() { + #expect(isInteger(classifier.classify(rawTypeName: "INT(11)"))) + } + + @Test("BIGINT(20) classifies as integer") + func bigint20() { + #expect(isInteger(classifier.classify(rawTypeName: "BIGINT(20)"))) + } + + @Test("MEDIUMINT(8) classifies as integer") + func mediumint8() { + #expect(isInteger(classifier.classify(rawTypeName: "MEDIUMINT(8)"))) + } + + @Test("SMALLINT classifies as integer") + func smallint() { + #expect(isInteger(classifier.classify(rawTypeName: "SMALLINT"))) + } + + @Test("ENUM('a','b','c') classifies as enum") + func enumType() { + #expect(classifier.classify(rawTypeName: "ENUM('a','b','c')").isEnumType) + } + + @Test("SET('x','y') classifies as set") + func setType() { + #expect(classifier.classify(rawTypeName: "SET('x','y')").isSetType) + } + + @Test("FLOAT classifies as decimal") + func floatType() { + #expect(isDecimal(classifier.classify(rawTypeName: "FLOAT"))) + } + + @Test("DOUBLE classifies as decimal") + func doubleType() { + #expect(isDecimal(classifier.classify(rawTypeName: "DOUBLE"))) + } + + @Test("DECIMAL(10,2) classifies as decimal") + func decimalType() { + #expect(isDecimal(classifier.classify(rawTypeName: "DECIMAL(10,2)"))) + } + + @Test("NUMERIC(5) classifies as decimal") + func numericType() { + #expect(isDecimal(classifier.classify(rawTypeName: "NUMERIC(5)"))) + } + + @Test("JSON classifies as json") + func jsonType() { + #expect(classifier.classify(rawTypeName: "JSON").isJsonType) + } + + @Test("BLOB classifies as blob") + func blobType() { + #expect(classifier.classify(rawTypeName: "BLOB").isBlobType) + } + + @Test("TINYBLOB classifies as blob") + func tinyblobType() { + #expect(classifier.classify(rawTypeName: "TINYBLOB").isBlobType) + } + + @Test("MEDIUMBLOB classifies as blob") + func mediumblobType() { + #expect(classifier.classify(rawTypeName: "MEDIUMBLOB").isBlobType) + } + + @Test("LONGBLOB classifies as blob") + func longblobType() { + #expect(classifier.classify(rawTypeName: "LONGBLOB").isBlobType) + } + + @Test("BINARY classifies as blob") + func binaryType() { + #expect(classifier.classify(rawTypeName: "BINARY").isBlobType) + } + + @Test("VARBINARY classifies as blob") + func varbinaryType() { + #expect(classifier.classify(rawTypeName: "VARBINARY").isBlobType) + } + + @Test("BOOLEAN classifies as boolean") + func booleanType() { + #expect(classifier.classify(rawTypeName: "BOOLEAN").isBooleanType) + } + + @Test("BOOL classifies as boolean") + func boolType() { + #expect(classifier.classify(rawTypeName: "BOOL").isBooleanType) + } + + @Test("DATE classifies as date") + func dateType() { + #expect(isDate(classifier.classify(rawTypeName: "DATE"))) + } + + @Test("DATETIME classifies as datetime") + func datetimeType() { + #expect(isDatetime(classifier.classify(rawTypeName: "DATETIME"))) + } + + @Test("TIMESTAMP classifies as timestamp") + func timestampType() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP"))) + } + + @Test("TIME classifies as timestamp") + func timeType() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIME"))) + } + + @Test("TEXT classifies as text") + func textType() { + #expect(isText(classifier.classify(rawTypeName: "TEXT"))) + } + + @Test("VARCHAR(255) classifies as text") + func varcharType() { + #expect(isText(classifier.classify(rawTypeName: "VARCHAR(255)"))) + } + + @Test("LONGTEXT classifies as text") + func longtextType() { + #expect(isText(classifier.classify(rawTypeName: "LONGTEXT"))) + } + + @Test("GEOMETRY classifies as spatial") + func geometryType() { + #expect(isSpatial(classifier.classify(rawTypeName: "GEOMETRY"))) + } + + @Test("POINT classifies as spatial") + func pointType() { + #expect(isSpatial(classifier.classify(rawTypeName: "POINT"))) + } + } + + // MARK: - MSSQL Types + + @Suite("MSSQL Types") + struct MSSQLTests { + private let classifier = ColumnTypeClassifier() + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isDatetime(_ type: ColumnType) -> Bool { + if case .datetime = type { return true } + return false + } + + @Test("BIT classifies as boolean") + func bitType() { + #expect(classifier.classify(rawTypeName: "BIT").isBooleanType) + } + + @Test("bit (lowercase) classifies as boolean") + func bitLowercase() { + #expect(classifier.classify(rawTypeName: "bit").isBooleanType) + } + + @Test("MONEY classifies as decimal") + func moneyType() { + #expect(isDecimal(classifier.classify(rawTypeName: "MONEY"))) + } + + @Test("SMALLMONEY classifies as decimal") + func smallmoneyType() { + #expect(isDecimal(classifier.classify(rawTypeName: "SMALLMONEY"))) + } + + @Test("IMAGE classifies as blob") + func imageType() { + #expect(classifier.classify(rawTypeName: "IMAGE").isBlobType) + } + + @Test("VARBINARY(MAX) classifies as blob") + func varbinaryMax() { + #expect(classifier.classify(rawTypeName: "VARBINARY(MAX)").isBlobType) + } + + @Test("VARBINARY(100) classifies as blob") + func varbinary100() { + #expect(classifier.classify(rawTypeName: "VARBINARY(100)").isBlobType) + } + + @Test("BINARY(16) classifies as blob") + func binary16() { + #expect(classifier.classify(rawTypeName: "BINARY(16)").isBlobType) + } + + @Test("DATETIME2 classifies as datetime") + func datetime2() { + #expect(isDatetime(classifier.classify(rawTypeName: "DATETIME2"))) + } + + @Test("DATETIMEOFFSET classifies as datetime") + func datetimeoffset() { + #expect(isDatetime(classifier.classify(rawTypeName: "DATETIMEOFFSET"))) + } + + @Test("SMALLDATETIME classifies as datetime") + func smalldatetime() { + #expect(isDatetime(classifier.classify(rawTypeName: "SMALLDATETIME"))) + } + + @Test("NVARCHAR(MAX) classifies as text") + func nvarcharMax() { + #expect(isText(classifier.classify(rawTypeName: "NVARCHAR(MAX)"))) + } + + @Test("NTEXT classifies as text") + func ntextType() { + #expect(isText(classifier.classify(rawTypeName: "NTEXT"))) + } + + @Test("UNIQUEIDENTIFIER classifies as text") + func uniqueidentifier() { + #expect(isText(classifier.classify(rawTypeName: "UNIQUEIDENTIFIER"))) + } + + @Test("SQL_VARIANT classifies as text") + func sqlVariant() { + #expect(isText(classifier.classify(rawTypeName: "SQL_VARIANT"))) + } + } + + // MARK: - ClickHouse Types + + @Suite("ClickHouse Types") + struct ClickHouseTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isDate(_ type: ColumnType) -> Bool { + if case .date = type { return true } + return false + } + + private func isDatetime(_ type: ColumnType) -> Bool { + if case .datetime = type { return true } + return false + } + + @Test("DateTime64(3) classifies as datetime") + func datetime64() { + #expect(isDatetime(classifier.classify(rawTypeName: "DateTime64(3)"))) + } + + @Test("DateTime64(3, 'UTC') classifies as datetime") + func datetime64WithTimezone() { + #expect(isDatetime(classifier.classify(rawTypeName: "DateTime64(3, 'UTC')"))) + } + + @Test("Enum8('a' = 1, 'b' = 2) classifies as enum") + func enum8() { + #expect(classifier.classify(rawTypeName: "Enum8('a' = 1, 'b' = 2)").isEnumType) + } + + @Test("Enum16('x' = 1) classifies as enum") + func enum16() { + #expect(classifier.classify(rawTypeName: "Enum16('x' = 1)").isEnumType) + } + + @Test("Float32 classifies as decimal") + func float32() { + #expect(isDecimal(classifier.classify(rawTypeName: "Float32"))) + } + + @Test("Float64 classifies as decimal") + func float64() { + #expect(isDecimal(classifier.classify(rawTypeName: "Float64"))) + } + + @Test("Decimal128(3) classifies as decimal") + func decimal128() { + #expect(isDecimal(classifier.classify(rawTypeName: "Decimal128(3)"))) + } + + @Test("Int8 classifies as integer") + func int8() { + #expect(isInteger(classifier.classify(rawTypeName: "Int8"))) + } + + @Test("Int16 classifies as integer") + func int16() { + #expect(isInteger(classifier.classify(rawTypeName: "Int16"))) + } + + @Test("Int32 classifies as integer") + func int32() { + #expect(isInteger(classifier.classify(rawTypeName: "Int32"))) + } + + @Test("Int64 classifies as integer") + func int64() { + #expect(isInteger(classifier.classify(rawTypeName: "Int64"))) + } + + @Test("Int128 classifies as integer") + func int128() { + #expect(isInteger(classifier.classify(rawTypeName: "Int128"))) + } + + @Test("Int256 classifies as integer") + func int256() { + #expect(isInteger(classifier.classify(rawTypeName: "Int256"))) + } + + @Test("UInt8 classifies as integer") + func uint8() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt8"))) + } + + @Test("UInt16 classifies as integer") + func uint16() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt16"))) + } + + @Test("UInt32 classifies as integer") + func uint32() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt32"))) + } + + @Test("UInt64 classifies as integer") + func uint64() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt64"))) + } + + @Test("UInt128 classifies as integer") + func uint128() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt128"))) + } + + @Test("UInt256 classifies as integer") + func uint256() { + #expect(isInteger(classifier.classify(rawTypeName: "UInt256"))) + } + + @Test("Date32 classifies as date") + func date32() { + #expect(isDate(classifier.classify(rawTypeName: "Date32"))) + } + + @Test("Bool classifies as boolean") + func boolType() { + #expect(classifier.classify(rawTypeName: "Bool").isBooleanType) + } + + @Test("UUID classifies as text") + func uuidType() { + #expect(isText(classifier.classify(rawTypeName: "UUID"))) + } + + @Test("FixedString(36) classifies as text") + func fixedString() { + #expect(isText(classifier.classify(rawTypeName: "FixedString(36)"))) + } + + @Test("String classifies as text") + func stringType() { + #expect(isText(classifier.classify(rawTypeName: "String"))) + } + } + + // MARK: - DuckDB Types + + @Suite("DuckDB Types") + struct DuckDBTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + @Test("UTINYINT classifies as integer") + func utinyint() { + #expect(isInteger(classifier.classify(rawTypeName: "UTINYINT"))) + } + + @Test("USMALLINT classifies as integer") + func usmallint() { + #expect(isInteger(classifier.classify(rawTypeName: "USMALLINT"))) + } + + @Test("UINTEGER classifies as integer") + func uinteger() { + #expect(isInteger(classifier.classify(rawTypeName: "UINTEGER"))) + } + + @Test("UBIGINT classifies as integer") + func ubigint() { + #expect(isInteger(classifier.classify(rawTypeName: "UBIGINT"))) + } + + @Test("HUGEINT classifies as integer") + func hugeint() { + #expect(isInteger(classifier.classify(rawTypeName: "HUGEINT"))) + } + + @Test("UHUGEINT classifies as integer") + func uhugeint() { + #expect(isInteger(classifier.classify(rawTypeName: "UHUGEINT"))) + } + + @Test("ENUM classifies as enum") + func enumType() { + #expect(classifier.classify(rawTypeName: "ENUM").isEnumType) + } + + @Test("BOOLEAN classifies as boolean") + func booleanType() { + #expect(classifier.classify(rawTypeName: "BOOLEAN").isBooleanType) + } + + @Test("BLOB classifies as blob") + func blobType() { + #expect(classifier.classify(rawTypeName: "BLOB").isBlobType) + } + + @Test("TIMESTAMP_S classifies as timestamp") + func timestampS() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_S"))) + } + + @Test("TIMESTAMP_MS classifies as timestamp") + func timestampMs() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_MS"))) + } + + @Test("TIMESTAMP_NS classifies as timestamp") + func timestampNs() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_NS"))) + } + + @Test("TIMESTAMPTZ classifies as timestamp") + func timestamptz() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMPTZ"))) + } + } + + // MARK: - PostgreSQL Types + + @Suite("PostgreSQL Types") + struct PostgreSQLTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + @Test("BOOLEAN classifies as boolean") + func booleanType() { + #expect(classifier.classify(rawTypeName: "BOOLEAN").isBooleanType) + } + + @Test("boolean (lowercase) classifies as boolean") + func booleanLowercase() { + #expect(classifier.classify(rawTypeName: "boolean").isBooleanType) + } + + @Test("SERIAL classifies as integer") + func serialType() { + #expect(isInteger(classifier.classify(rawTypeName: "SERIAL"))) + } + + @Test("BIGSERIAL classifies as integer") + func bigserialType() { + #expect(isInteger(classifier.classify(rawTypeName: "BIGSERIAL"))) + } + + @Test("SMALLSERIAL classifies as integer") + func smallserialType() { + #expect(isInteger(classifier.classify(rawTypeName: "SMALLSERIAL"))) + } + + @Test("JSONB classifies as json") + func jsonbType() { + #expect(classifier.classify(rawTypeName: "JSONB").isJsonType) + } + + @Test("BYTEA classifies as blob") + func byteaType() { + #expect(classifier.classify(rawTypeName: "BYTEA").isBlobType) + } + + @Test("TIMESTAMPTZ classifies as timestamp") + func timestamptz() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMPTZ"))) + } + + @Test("ENUM(mood) classifies as enum") + func enumMood() { + #expect(classifier.classify(rawTypeName: "ENUM(mood)").isEnumType) + } + + @Test("MONEY classifies as decimal") + func moneyType() { + #expect(isDecimal(classifier.classify(rawTypeName: "MONEY"))) + } + + @Test("DOUBLE PRECISION classifies as decimal") + func doublePrecision() { + #expect(isDecimal(classifier.classify(rawTypeName: "DOUBLE PRECISION"))) + } + } + + // MARK: - SQLite Types + + @Suite("SQLite Types") + struct SQLiteTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + @Test("INTEGER classifies as integer") + func integerType() { + #expect(isInteger(classifier.classify(rawTypeName: "INTEGER"))) + } + + @Test("REAL classifies as decimal") + func realType() { + #expect(isDecimal(classifier.classify(rawTypeName: "REAL"))) + } + + @Test("BLOB classifies as blob") + func blobType() { + #expect(classifier.classify(rawTypeName: "BLOB").isBlobType) + } + + @Test("TEXT classifies as text") + func textType() { + #expect(isText(classifier.classify(rawTypeName: "TEXT"))) + } + + @Test("NUMERIC classifies as decimal") + func numericType() { + #expect(isDecimal(classifier.classify(rawTypeName: "NUMERIC"))) + } + } + + // MARK: - Oracle Types + + @Suite("Oracle Types") + struct OracleTests { + private let classifier = ColumnTypeClassifier() + + private func isDecimal(_ type: ColumnType) -> Bool { + if case .decimal = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + @Test("NUMBER classifies as decimal") + func numberType() { + #expect(isDecimal(classifier.classify(rawTypeName: "NUMBER"))) + } + + @Test("NUMBER(10,2) classifies as decimal") + func numberWithParams() { + #expect(isDecimal(classifier.classify(rawTypeName: "NUMBER(10,2)"))) + } + + @Test("VARCHAR2(50) classifies as text") + func varchar2() { + #expect(isText(classifier.classify(rawTypeName: "VARCHAR2(50)"))) + } + + @Test("CLOB classifies as text") + func clobType() { + #expect(isText(classifier.classify(rawTypeName: "CLOB"))) + } + + @Test("RAW classifies as blob") + func rawType() { + #expect(classifier.classify(rawTypeName: "RAW").isBlobType) + } + + @Test("TIMESTAMP WITH TIME ZONE classifies as timestamp") + func timestampWithTimeZone() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP WITH TIME ZONE"))) + } + } + + // MARK: - Fallback Patterns + + @Suite("Fallback Patterns") + struct FallbackTests { + private let classifier = ColumnTypeClassifier() + + private func isInteger(_ type: ColumnType) -> Bool { + if case .integer = type { return true } + return false + } + + private func isText(_ type: ColumnType) -> Bool { + if case .text = type { return true } + return false + } + + private func isTimestamp(_ type: ColumnType) -> Bool { + if case .timestamp = type { return true } + return false + } + + @Test("BIGSERIAL falls back to integer via SERIAL suffix") + func bigserialFallback() { + #expect(isInteger(classifier.classify(rawTypeName: "BIGSERIAL"))) + } + + @Test("SMALLSERIAL falls back to integer via SERIAL suffix") + func smallserialFallback() { + #expect(isInteger(classifier.classify(rawTypeName: "SMALLSERIAL"))) + } + + @Test("MEDIUMTEXT falls back to text via TEXT suffix") + func mediumtextFallback() { + #expect(isText(classifier.classify(rawTypeName: "MEDIUMTEXT"))) + } + + @Test("TINYTEXT falls back to text via TEXT suffix") + func tinytextFallback() { + #expect(isText(classifier.classify(rawTypeName: "TINYTEXT"))) + } + + @Test("TIMESTAMP WITH LOCAL TIME ZONE falls back to timestamp via prefix") + func timestampWithLocalTz() { + #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP WITH LOCAL TIME ZONE"))) + } + + @Test("LONGBLOB falls back to blob via contains BLOB") + func longblobFallback() { + #expect(classifier.classify(rawTypeName: "LONGBLOB").isBlobType) + } + + @Test("unknown_type_xyz falls back to text") + func unknownFallback() { + #expect(isText(classifier.classify(rawTypeName: "unknown_type_xyz"))) + } + } +} diff --git a/TableProTests/Core/Services/ColumnTypeTests.swift b/TableProTests/Core/Services/ColumnTypeTests.swift index 79ef0fc6..85c9e89c 100644 --- a/TableProTests/Core/Services/ColumnTypeTests.swift +++ b/TableProTests/Core/Services/ColumnTypeTests.swift @@ -236,4 +236,68 @@ struct ColumnTypeTests { let type = ColumnType.set(rawType: nil, values: nil) #expect(type.badgeLabel == "set") } + + // MARK: - isLongText for NTEXT + + @Test("NTEXT is long text") + func ntextIsLongText() { + let type = ColumnType.text(rawType: "NTEXT") + #expect(type.isLongText) + } + + @Test("NTEXT is not very long text") + func ntextIsNotVeryLongText() { + let type = ColumnType.text(rawType: "NTEXT") + #expect(!type.isVeryLongText) + } + + // MARK: - parseClickHouseEnumValues + + @Test("parses Enum8 with values and assignments") + func parseEnum8Values() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum8('active' = 1, 'inactive' = 2)") + #expect(result == ["active", "inactive"]) + } + + @Test("parses Enum16 with single value") + func parseEnum16SingleValue() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum16('only' = 1)") + #expect(result == ["only"]) + } + + @Test("parses Enum8 with escaped quotes") + func parseEnum8EscapedQuotes() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum8('it\\'s' = 1, 'ok' = 2)") + #expect(result == ["it's", "ok"]) + } + + @Test("parses Enum8 with negative assignments") + func parseEnum8NegativeAssignments() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum8('a' = -1, 'b' = 0, 'c' = 1)") + #expect(result == ["a", "b", "c"]) + } + + @Test("parses Enum8 with spaces in values") + func parseEnum8WithSpaces() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum8('hello world' = 1, 'foo bar' = 2)") + #expect(result == ["hello world", "foo bar"]) + } + + @Test("returns nil for regular ENUM prefix") + func parseClickHouseReturnsNilForRegularEnum() { + let result = ColumnType.parseClickHouseEnumValues(from: "ENUM('a','b')") + #expect(result == nil) + } + + @Test("returns nil for non-enum type") + func parseClickHouseReturnsNilForNonEnum() { + let result = ColumnType.parseClickHouseEnumValues(from: "String") + #expect(result == nil) + } + + @Test("returns nil for empty Enum8") + func parseClickHouseEmptyEnum() { + let result = ColumnType.parseClickHouseEnumValues(from: "Enum8()") + #expect(result == nil) + } } From e813daabe7f116812a0b7359ff0a77a7a641181c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 26 Mar 2026 11:18:52 +0700 Subject: [PATCH 2/2] fix: remove duplicate INT8 key and add multi-value nullable enum test --- TablePro/Core/Services/ColumnTypeClassifier.swift | 2 +- TableProTests/Core/Services/ColumnTypeClassifierTests.swift | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index 723a349b..31c0b514 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -102,7 +102,7 @@ struct ColumnTypeClassifier { for key in [ "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", - "INT2", "INT4", "INT8", + "INT2", "INT4", "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT" diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 887788f8..ab7f7b46 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -101,6 +101,12 @@ struct ColumnTypeClassifierTests { #expect(result.isEnumType) } + @Test("Nullable(Enum8('a' = 1, 'b' = 2)) classifies as enum") + func nullableEnumMultiValue() { + let result = classifier.classify(rawTypeName: "Nullable(Enum8('a' = 1, 'b' = 2))") + #expect(result.isEnumType) + } + @Test("Empty string classifies as text") func emptyString() { let result = classifier.classify(rawTypeName: "")