diff --git a/CHANGELOG.md b/CHANGELOG.md index f041f7bf4..711099064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283) +- Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283) + +### Changed + +- Drivers populate allowed enum values directly in column metadata instead of parsing them downstream +- PluginKit ABI bumped to version 13; all registry plugins need to be re-tagged + ## [0.42.0] - 2026-05-16 ### Added diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index b542d52ba..c58cdd310 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProMinAppVersion 0.42.0 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 2c6c1754e..a2d547447 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesExportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index e5789fe66..26ae078ac 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 12 + 13 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 39a2e42e3..9445ceb92 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -342,7 +342,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { isPrimaryKey: pkColumns.contains(name), defaultValue: defaultValue, extra: extra, - comment: (comment?.isEmpty == false) ? comment : nil + comment: (comment?.isEmpty == false) ? comment : nil, + allowedValues: EnumValueParser.parseClickHouseEnum(from: ClickHousePluginDriver.unwrapTypeWrappers(dataType)) ) } } @@ -402,13 +403,25 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { isPrimaryKey: pkLookup[tableName]?.contains(colName) == true, defaultValue: defaultValue, extra: extra, - comment: (comment?.isEmpty == false) ? comment : nil + comment: (comment?.isEmpty == false) ? comment : nil, + allowedValues: EnumValueParser.parseClickHouseEnum(from: ClickHousePluginDriver.unwrapTypeWrappers(dataType)) ) columnsByTable[tableName, default: []].append(colInfo) } return columnsByTable } + static func unwrapTypeWrappers(_ value: String) -> String { + for prefix in ["Nullable(", "LowCardinality("] { + if value.hasPrefix(prefix), value.hasSuffix(")") { + let start = value.index(value.startIndex, offsetBy: prefix.count) + let end = value.index(before: value.endIndex) + return unwrapTypeWrappers(String(value[start.. [PluginIndexInfo] { let escapedTable = table.replacingOccurrences(of: "'", with: "''") var indexes: [PluginIndexInfo] = [] diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 25d2c3f88..e3c593105 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index b542d52ba..c58cdd310 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProMinAppVersion 0.42.0 diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index cc9505919..453a005db 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -785,6 +785,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await executeParameterized(query: query, parameters: [.text(schemaName), .text(table)]) let pkColumns = try await fetchPrimaryKeyColumns(table: table, schema: schemaName) + let enumMap = try await fetchEnumLabelMap(schema: schemaName) return result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, @@ -801,7 +802,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType: dataType, isNullable: isNullable, isPrimaryKey: isPrimaryKey, - defaultValue: defaultValue + defaultValue: defaultValue, + allowedValues: resolveEnumValues(dataType: dataType, enumMap: enumMap) ) } } @@ -833,6 +835,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + let enumMap = try await fetchEnumLabelMap(schema: schemaName) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -851,7 +854,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType: dataType, isNullable: isNullable, isPrimaryKey: isPrimaryKey, - defaultValue: defaultValue + defaultValue: defaultValue, + allowedValues: resolveEnumValues(dataType: dataType, enumMap: enumMap) ) allColumns[tableName, default: []].append(column) @@ -860,6 +864,46 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return allColumns } + private func fetchEnumLabelMap(schema: String) async throws -> [String: [String]] { + let typeNamesQuery = """ + SELECT type_name + FROM duckdb_types() + WHERE schema_name = $1 AND type_category = 'ENUM' + """ + let typeResult: PluginQueryResult + do { + typeResult = try await executeParameterized(query: typeNamesQuery, parameters: [.text(schema)]) + } catch { + return [:] + } + let typeNames = typeResult.rows.compactMap { $0[safe: 0]?.asText } + guard !typeNames.isEmpty else { return [:] } + + var map: [String: [String]] = [:] + for typeName in typeNames { + let quoted = "\"\(typeName.replacingOccurrences(of: "\"", with: "\"\""))\"" + let valuesQuery = "SELECT UNNEST(enum_range(NULL::\(quoted)))::VARCHAR AS value" + let valuesResult: PluginQueryResult + do { + valuesResult = try await execute(query: valuesQuery) + } catch { + continue + } + let labels = valuesResult.rows.compactMap { $0[safe: 0]?.asText } + if !labels.isEmpty { + map[typeName] = labels + } + } + return map + } + + private func resolveEnumValues(dataType: String, enumMap: [String: [String]]) -> [String]? { + if let values = enumMap[dataType], !values.isEmpty { + return values + } + return EnumValueParser.parseMySQLEnumOrSet(from: dataType) + } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { let schemaName = resolveSchema(schema) let query = """ diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index f0137ccd7..3ac6bcdf7 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 12 + 13 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index b542d52ba..c58cdd310 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProMinAppVersion 0.42.0 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index b542d52ba..c58cdd310 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProMinAppVersion 0.42.0 diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 5331e56c4..5993d460e 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesExportFormatIds json diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index b542d52ba..c58cdd310 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProMinAppVersion 0.42.0 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 60aa64a8b..87d737126 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index f0137ccd7..3ac6bcdf7 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 12 + 13 diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index f0137ccd7..3ac6bcdf7 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 12 + 13 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index be8de0885..9c6e9928d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -160,6 +160,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { filter: "{}", sort: nil, projection: nil, skip: 0, limit: 50 ).docs + let enumMap = (try? await fetchJsonSchemaEnums(conn: conn, table: table)) ?? [:] + if docs.isEmpty { return [ PluginColumnInfo( @@ -176,11 +178,45 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let typeName = bsonTypeToString(types[index]) return PluginColumnInfo( name: name, dataType: typeName, isNullable: name != "_id", isPrimaryKey: name == "_id", - defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil, + allowedValues: enumMap[name] ) } } + private func fetchJsonSchemaEnums(conn: MongoDBConnection, table: String) async throws -> [String: [String]] { + let escaped = escapeJsonString(table) + let result = try await conn.runCommand( + "{\"listCollections\": 1, \"filter\": {\"name\": \"\(escaped)\"}}", + database: currentDb + ) + guard let firstDoc = result.first, + let cursor = firstDoc["cursor"] as? [String: Any], + let firstBatch = cursor["firstBatch"] as? [[String: Any]], + let collInfo = firstBatch.first, + let options = collInfo["options"] as? [String: Any], + let validator = options["validator"] as? [String: Any], + let jsonSchema = validator["$jsonSchema"] as? [String: Any], + let properties = jsonSchema["properties"] as? [String: Any] + else { return [:] } + + var map: [String: [String]] = [:] + for (colName, spec) in properties { + guard let specDict = spec as? [String: Any] else { continue } + if let enumValues = extractStringEnum(specDict["enum"]) { + map[colName] = enumValues + } + } + return map + } + + private func extractStringEnum(_ value: Any?) -> [String]? { + guard let array = value as? [Any], !array.isEmpty else { return nil } + guard array.allSatisfy({ $0 is String }) else { return nil } + let strings = array.compactMap { $0 as? String } + return strings.isEmpty ? nil : strings + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { guard mongoConnection != nil else { throw MongoDBPluginError.notConnected diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 10d07a5a6..4b14aac08 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 79d338fb9..df120395e 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -219,6 +219,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let upperType = dataType.uppercased() let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET(")) ? dataType : upperType + let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType) return PluginColumnInfo( name: name, @@ -229,7 +230,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { extra: extra, charset: charset, collation: collation == "NULL" ? nil : collation, - comment: comment?.isEmpty == false ? comment : nil + comment: comment?.isEmpty == false ? comment : nil, + allowedValues: allowedValues ) } } @@ -270,6 +272,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let upperType = dataType.uppercased() let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET(")) ? dataType : upperType + let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType) let column = PluginColumnInfo( name: name, @@ -280,7 +283,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { extra: extra, charset: charset, collation: collation == "NULL" ? nil : collation, - comment: comment?.isEmpty == false ? comment : nil + comment: comment?.isEmpty == false ? comment : nil, + allowedValues: allowedValues ) allColumns[tableName, default: []].append(column) diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index f0137ccd7..3ac6bcdf7 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 12 + 13 diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index e09f6b0c1..9480cd907 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index f1d4360f6..1513bd861 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -10,6 +10,7 @@ extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") let safeTable = escapeLiteralForColumns(table) + let enumMap = try await fetchEnumLabelMap(schema: safeSchema) let caps = versionedCapabilities let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" @@ -54,12 +55,13 @@ extension PostgreSQLPluginDriver { """ let result = try await execute(query: query) return result.rows.compactMap { row in - mapPgColumnRow(row, tableNameOffset: 0) + mapPgColumnRow(row, tableNameOffset: 0, enumLabelsByType: enumMap) } } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") + let enumMap = try await fetchEnumLabelMap(schema: safeSchema) let caps = versionedCapabilities let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" @@ -106,18 +108,41 @@ extension PostgreSQLPluginDriver { var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { guard row.count >= 5, let tableName = row[0].asText else { continue } - if let column = mapPgColumnRow(row, tableNameOffset: 1) { + if let column = mapPgColumnRow(row, tableNameOffset: 1, enumLabelsByType: enumMap) { allColumns[tableName, default: []].append(column) } } return allColumns } + fileprivate func fetchEnumLabelMap(schema: String) async throws -> [String: [String]] { + let query = """ + SELECT t.typname, e.enumlabel + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname = '\(schema)' + ORDER BY t.typname, e.enumsortorder + """ + let result = try await execute(query: query) + var map: [String: [String]] = [:] + for row in result.rows { + guard let typeName = row[safe: 0]?.asText, + let label = row[safe: 1]?.asText else { continue } + map[typeName, default: []].append(label) + } + return map + } + fileprivate func escapeLiteralForColumns(_ str: String) -> String { str.replacingOccurrences(of: "'", with: "''") } - fileprivate func mapPgColumnRow(_ row: [PluginCellValue], tableNameOffset: Int) -> PluginColumnInfo? { + fileprivate func mapPgColumnRow( + _ row: [PluginCellValue], + tableNameOffset: Int, + enumLabelsByType: [String: [String]] + ) -> PluginColumnInfo? { let nameIdx = tableNameOffset let typeIdx = tableNameOffset + 1 let nullableIdx = tableNameOffset + 2 @@ -135,10 +160,18 @@ extension PostgreSQLPluginDriver { else { return nil } let udtName = row.count > udtIdx ? row[udtIdx].asText : nil + let allowedValues: [String]? let dataType: String if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { - dataType = "ENUM(\(udt))" + if let labels = enumLabelsByType[udt] { + allowedValues = labels + dataType = "ENUM" + } else { + allowedValues = nil + dataType = "ENUM(\(udt))" + } } else { + allowedValues = nil dataType = rawDataType.uppercased() } @@ -165,7 +198,8 @@ extension PostgreSQLPluginDriver { collation: collation, comment: comment?.isEmpty == false ? comment : nil, identityKind: pgIdentityKind(attidentity), - isGenerated: attgenerated == "s" + isGenerated: attgenerated == "s", + allowedValues: allowedValues ) } diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 314f48aeb..132300e11 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 12 + 13 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index 69ced0401..a58f44d82 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index c9421d897..87cc1c78e 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index ef9dd1e72..3afb6a70b 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 12 + 13 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/TableProPluginKit/EnumValueParser.swift b/Plugins/TableProPluginKit/EnumValueParser.swift new file mode 100644 index 000000000..cabe454da --- /dev/null +++ b/Plugins/TableProPluginKit/EnumValueParser.swift @@ -0,0 +1,80 @@ +import Foundation + +public enum EnumValueParser { + public static func parseMySQLEnumOrSet(from typeString: String) -> [String]? { + let upper = typeString.uppercased() + guard upper.hasPrefix("ENUM(") || upper.hasPrefix("SET(") else { + return nil + } + return parseQuotedList(in: typeString, mode: .csv) + } + + public static func parseClickHouseEnum(from typeString: String) -> [String]? { + let upper = typeString.uppercased() + guard upper.hasPrefix("ENUM8(") || upper.hasPrefix("ENUM16(") else { + return nil + } + return parseQuotedList(in: typeString, mode: .quotedOnly) + } + + private enum ParseMode { + case csv + case quotedOnly + } + + private static func parseQuotedList(in typeString: String, mode: ParseMode) -> [String]? { + guard let openParen = typeString.firstIndex(of: "("), + let closeParen = typeString.lastIndex(of: ")") else { + return nil + } + let inner = typeString[typeString.index(after: openParen).. TableProPluginKitVersion - 12 + 13 TableProProvidesExportFormatIds xlsx diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 85ab45176..6005f62c7 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -202,7 +202,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { extra: col.extra, charset: col.charset, collation: col.collation, - comment: col.comment + comment: col.comment, + allowedValues: col.allowedValues ) } } @@ -376,7 +377,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { result[table] = cols.map { col in ColumnInfo(name: col.name, dataType: col.dataType, isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, - extra: col.extra, charset: col.charset, collation: col.collation, comment: col.comment) + extra: col.extra, charset: col.charset, collation: col.collation, comment: col.comment, + allowedValues: col.allowedValues) } } return result diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 5d3873d9e..4aab940b1 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -13,7 +13,7 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() - static let currentPluginKitVersion = 12 + static let currentPluginKitVersion = 13 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index 1a1d77261..87af963d7 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -174,93 +174,4 @@ enum ColumnType: Equatable { } } - // MARK: - Enum Value Parsing - - /// Parse enum/set values from a type string like "ENUM('a','b','c')" or "SET('x','y')" - static func parseEnumValues(from typeString: String) -> [String]? { - let upper = typeString.uppercased() - guard upper.hasPrefix("ENUM(") || upper.hasPrefix("SET(") else { - return nil - } - - // Find the opening paren and closing paren - guard let openParen = typeString.firstIndex(of: "("), - let closeParen = typeString.lastIndex(of: ")") else { - return nil - } - - let inner = typeString[typeString.index(after: openParen).. [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).. Void let onUnset: () -> Void @@ -181,6 +182,7 @@ struct FilterPanelView: View { filter: coordinator.filterBinding(for: filter), columns: columns, completions: completionItems(), + enumValuesByColumn: enumValuesByColumn, onAdd: { coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index de9c70a84..e6a294f6f 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -9,12 +9,24 @@ struct FilterRowView: View { @Binding var filter: TableFilter let columns: [String] let completions: [String] + var enumValuesByColumn: [String: [String]] = [:] let onAdd: () -> Void let onDuplicate: () -> Void let onRemove: () -> Void let onSubmit: () -> Void @Binding var focusedFilterId: UUID? + private var pickerEligibleOperators: Set { + [.equal, .notEqual] + } + + private var allowedValuesForCurrentColumn: [String]? { + guard !filter.isRawSQL, + let values = enumValuesByColumn[filter.columnName], + !values.isEmpty else { return nil } + return values + } + var body: some View { HStack(spacing: 4) { columnPicker @@ -79,16 +91,21 @@ struct FilterRowView: View { ) .accessibilityLabel(String(localized: "WHERE clause")) } else if filter.filterOperator.requiresValue { - FilterValueTextField( - text: $filter.value, - focusedId: $focusedFilterId, - identity: filter.id, - placeholder: String(localized: "Value"), - completions: completions, - onSubmit: onSubmit - ) - .frame(minWidth: 80) - .accessibilityLabel(String(localized: "Filter value")) + if let allowedValues = allowedValuesForCurrentColumn, + pickerEligibleOperators.contains(filter.filterOperator) { + enumValuePicker(allowedValues: allowedValues) + } else { + FilterValueTextField( + text: $filter.value, + focusedId: $focusedFilterId, + identity: filter.id, + placeholder: String(localized: "Value"), + completions: completions, + onSubmit: onSubmit + ) + .frame(minWidth: 80) + .accessibilityLabel(String(localized: "Filter value")) + } if filter.filterOperator.requiresSecondValue { Text("and") @@ -160,6 +177,26 @@ struct FilterRowView: View { } } + @ViewBuilder + private func enumValuePicker(allowedValues: [String]) -> some View { + let isDrift = !filter.value.isEmpty && !allowedValues.contains(filter.value) + Picker("", selection: $filter.value) { + ForEach(allowedValues, id: \.self) { value in + Text(value).tag(value) + } + if isDrift { + Divider() + Text(filter.value).tag(filter.value) + } + } + .pickerStyle(.menu) + .controlSize(.small) + .frame(minWidth: 100) + .labelsHidden() + .accessibilityLabel(String(localized: "Filter value")) + .onChange(of: filter.value) { _, _ in onSubmit() } + } + private struct OperatorMenuLabel: View { let op: FilterOperator diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 51ab077c2..16417b738 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -490,6 +490,7 @@ struct MainEditorContentView: View { columns: resolvedRows.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, + enumValuesByColumn: resolvedRows.columnEnumValues, onApply: onApplyFilters, onUnset: onClearFilters ) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a3984195..01455e286 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1154,7 +1154,6 @@ final class MainContentCoordinator { } } - /// Fetch enum/set values for columns from database-specific sources func fetchEnumValues( columnInfo: [ColumnInfo], tableName: String, @@ -1163,37 +1162,14 @@ final class MainContentCoordinator { ) async -> [String: [String]] { var result: [String: [String]] = [:] - // 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) { + if let values = col.allowedValues, !values.isEmpty { result[col.name] = values - } else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) { - result[col.name] = values - } - } - - // Fetch actual enum values from catalog via dependent types (PostgreSQL returns values, others return []) - if let enumTypes = try? await driver.fetchDependentTypes(forTable: tableName), - !enumTypes.isEmpty { - let typeMap = Dictionary(uniqueKeysWithValues: enumTypes.map { ($0.name, $0.labels) }) - for col in columnInfo where col.dataType.uppercased().hasPrefix("ENUM(") { - let raw = col.dataType - if let openParen = raw.firstIndex(of: "("), - let closeParen = raw.lastIndex(of: ")") { - let typeName = String(raw[raw.index(after: openParen).. Double(totalAvailable) - ? totalAvailable - trailing - : totalAvailable + let trailingGap: CGFloat = trailing > 0 ? trailing + 4 : 0 + let availableWidth = max(0, totalAvailable - trailingGap) + let ellipsisLine = makeEllipsisLine() + let ellipsisWidth = CTLineGetTypographicBounds(ellipsisLine, nil, nil, nil) + guard Double(availableWidth) >= ellipsisWidth else { return } let lineToDraw: CTLine if typographicWidth > Double(availableWidth) { - let ellipsis = makeEllipsisLine() - lineToDraw = CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsis) ?? fullLine + lineToDraw = CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsisLine) ?? ellipsisLine } else { lineToDraw = fullLine } @@ -292,6 +295,8 @@ final class DataGridCellView: NSView { case .dropdown, .boolean, .json, .blob: guard isEditableCell else { return .zero } let size = NSSize(width: 12, height: 14) + let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset + guard bounds.width >= minRequired else { return .zero } let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width let y = (bounds.height - size.height) / 2 return NSRect(x: x, y: y, width: size.width, height: size.height) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index f7002c5c2..3080619b2 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -648,9 +648,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData for i in 0.. Void + ) { + let menu = NSMenu() + menu.autoenablesItems = false + + if isNullable { + let nullItem = NSMenuItem( + title: String(localized: "NULL"), + action: nil, + keyEquivalent: "" + ) + nullItem.attributedTitle = NSAttributedString( + string: String(localized: "NULL"), + attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize).italic()] + ) + nullItem.target = ItemTarget.shared + nullItem.action = #selector(ItemTarget.invoke(_:)) + nullItem.representedObject = ItemPayload(value: nil, onCommit: onCommit) + if currentValue == nil { nullItem.state = .on } + menu.addItem(nullItem) + menu.addItem(.separator()) + } + + for value in allowedValues { + let item = NSMenuItem(title: value, action: nil, keyEquivalent: "") + item.target = ItemTarget.shared + item.action = #selector(ItemTarget.invoke(_:)) + item.representedObject = ItemPayload(value: value, onCommit: onCommit) + if currentValue == value { item.state = .on } + menu.addItem(item) + } + + if let current = currentValue, + !current.isEmpty, + !allowedValues.contains(current) { + menu.addItem(.separator()) + let driftItem = NSMenuItem(title: current, action: nil, keyEquivalent: "") + driftItem.target = ItemTarget.shared + driftItem.action = #selector(ItemTarget.invoke(_:)) + driftItem.representedObject = ItemPayload(value: current, onCommit: onCommit) + driftItem.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", + accessibilityDescription: nil) + driftItem.toolTip = String(localized: "Value is not in the declared enum.") + driftItem.state = .on + menu.addItem(driftItem) + } + + if currentValue == nil || (currentValue?.isEmpty ?? true), + let defaultValue, + allowedValues.contains(defaultValue) { + menu.items.first(where: { $0.title == defaultValue })?.image = NSImage( + systemSymbolName: "arrow.uturn.left.circle", accessibilityDescription: nil + ) + } + + let anchor = NSPoint(x: rect.minX, y: rect.maxY) + menu.popUp(positioning: nil, at: anchor, in: view) + } + + static func presentSet( + relativeTo rect: NSRect, + in view: NSView, + allowedValues: [String], + currentCsv: String?, + onCommit: @escaping (String) -> Void + ) { + let selected = Set( + (currentCsv ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ) + + let menu = NSMenu() + menu.autoenablesItems = false + + let coordinator = SetSelectionCoordinator( + allowedValues: allowedValues, + initialSelection: selected, + onCommit: onCommit + ) + + for value in allowedValues { + let item = NSMenuItem(title: value, action: nil, keyEquivalent: "") + item.target = coordinator + item.action = #selector(SetSelectionCoordinator.toggle(_:)) + item.representedObject = value + item.state = selected.contains(value) ? .on : .off + menu.addItem(item) + } + + for current in selected where !allowedValues.contains(current) { + menu.addItem(.separator()) + let driftItem = NSMenuItem(title: current, action: nil, keyEquivalent: "") + driftItem.target = coordinator + driftItem.action = #selector(SetSelectionCoordinator.toggle(_:)) + driftItem.representedObject = current + driftItem.state = .on + driftItem.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", + accessibilityDescription: nil) + driftItem.toolTip = String(localized: "Value is not in the declared set.") + menu.addItem(driftItem) + } + + menu.delegate = coordinator + + let anchor = NSPoint(x: rect.minX, y: rect.maxY) + menu.popUp(positioning: nil, at: anchor, in: view) + } +} + +private struct ItemPayload { + let value: String? + let onCommit: (String?) -> Void +} + +@MainActor +private final class ItemTarget: NSObject { + static let shared = ItemTarget() + + @objc func invoke(_ sender: NSMenuItem) { + guard let payload = sender.representedObject as? ItemPayload else { return } + payload.onCommit(payload.value) + } +} + +@MainActor +private final class SetSelectionCoordinator: NSObject, NSMenuDelegate { + private let allowedValues: [String] + private var selection: Set + private let initialSelection: Set + private let onCommit: (String) -> Void + private var committed = false + + init(allowedValues: [String], initialSelection: Set, onCommit: @escaping (String) -> Void) { + self.allowedValues = allowedValues + self.selection = initialSelection + self.initialSelection = initialSelection + self.onCommit = onCommit + } + + @objc func toggle(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String else { return } + if selection.contains(value) { + selection.remove(value) + sender.state = .off + } else { + selection.insert(value) + sender.state = .on + } + committed = true + } + + func menuDidClose(_ menu: NSMenu) { + guard committed, selection != initialSelection else { return } + let ordered = allowedValues.filter(selection.contains) + + selection.filter { !allowedValues.contains($0) }.sorted() + onCommit(ordered.joined(separator: ",")) + } +} + +private extension NSFont { + func italic() -> NSFont { + let descriptor = fontDescriptor.withSymbolicTraits(.italic) + return NSFont(descriptor: descriptor, size: pointSize) ?? self + } +} diff --git a/TablePro/Views/Results/EnumPopoverContentView.swift b/TablePro/Views/Results/EnumPopoverContentView.swift deleted file mode 100644 index e1e08be1c..000000000 --- a/TablePro/Views/Results/EnumPopoverContentView.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// EnumPopoverContentView.swift -// TablePro -// -// Searchable dropdown for ENUM column editing. -// - -import SwiftUI - -private let enumNullMarker = "\u{2300} NULL" - -struct EnumPopoverContentView: View { - let allValues: [String] - let currentValue: String? - let isNullable: Bool - let onCommit: (String?) -> Void - let onDismiss: () -> Void - - @State private var searchText = "" - - private static let rowHeight: CGFloat = 24 - private static let searchAreaHeight: CGFloat = 44 - private static let maxHeight: CGFloat = 320 - - private var filteredValues: [String] { - let query = searchText.lowercased() - if query.isEmpty { return allValues } - return allValues.filter { $0.lowercased().contains(query) } - } - - private var listHeight: CGFloat { - let contentHeight = CGFloat(filteredValues.count) * Self.rowHeight - return min(contentHeight, Self.maxHeight - Self.searchAreaHeight) - } - - var body: some View { - VStack(spacing: 0) { - NativeSearchField(text: $searchText, placeholder: String(localized: "Search...")) - .padding(.horizontal, 8) - .padding(.vertical, 8) - - Divider() - - List { - ForEach(filteredValues, id: \.self) { value in - Button { commitValue(value) } label: { - rowLabel(for: value) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets( - top: 2, leading: 6, bottom: 2, trailing: 6 - )) - } - } - .listStyle(.plain) - .environment(\.defaultMinListRowHeight, Self.rowHeight) - .frame(height: listHeight) - .onKeyPress(.return) { - guard let firstValue = filteredValues.first else { return .ignored } - commitValue(firstValue) - return .handled - } - } - .frame(width: 280) - } - - @ViewBuilder - private func rowLabel(for value: String) -> some View { - if value == enumNullMarker { - Text(value) - .font(.system(.callout, design: .monospaced).italic()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } else if value == currentValue { - Text(value) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.tint) - .lineLimit(1) - .truncationMode(.tail) - } else { - Text(value) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - private func commitValue(_ value: String) { - if value == enumNullMarker { - onCommit(nil) - } else { - onCommit(value) - } - onDismiss() - } -} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 4c6b68d32..8323045fd 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -94,10 +94,12 @@ extension TableViewCoordinator { if ct.isBooleanType { showDropdownMenu(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isEnumType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { - showEnumPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isSetType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { - showSetPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) + } else if let values = tableRows.columnEnumValues[columnName], !values.isEmpty { + if ct.isSetType { + showSetPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) + } else { + showEnumPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) + } } else if ct.isJsonType { showJSONEditorPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else if ct.isBlobType { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 76fda3084..453c30803 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -216,27 +216,18 @@ extension TableViewCoordinator { let currentValue = cellValue(at: row, column: columnIndex) let isNullable = tableRows.columnNullable[columnName] ?? true - - var values: [String] = [] - if isNullable { - values.append("\u{2300} NULL") - } - values.append(contentsOf: allowedValues) + let defaultValue = tableRows.columnDefaults[columnName] ?? nil let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( + EnumMenuPicker.presentEnum( relativeTo: cellRect, - of: tableView - ) { [weak self] dismiss in - EnumPopoverContentView( - allValues: values, - currentValue: currentValue, - isNullable: isNullable, - onCommit: { newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss - ) + in: tableView, + allowedValues: allowedValues, + currentValue: currentValue, + isNullable: isNullable, + defaultValue: defaultValue + ) { [weak self] newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) } } @@ -248,31 +239,14 @@ extension TableViewCoordinator { guard let allowedValues = tableRows.columnEnumValues[columnName] else { return } let currentValue = cellValue(at: row, column: columnIndex) - - let currentSet: Set - if let value = currentValue { - currentSet = Set(value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) - } else { - currentSet = [] - } - var selections: [String: Bool] = [:] - for value in allowedValues { - selections[value] = currentSet.contains(value) - } - let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( + EnumMenuPicker.presentSet( relativeTo: cellRect, - of: tableView - ) { [weak self] dismiss in - SetPopoverContentView( - allowedValues: allowedValues, - initialSelections: selections, - onCommit: { newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss - ) + in: tableView, + allowedValues: allowedValues, + currentCsv: currentValue + ) { [weak self] newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift similarity index 100% rename from TablePro/Views/RightSidebar/FieldEditors/FieldEditorResolver.swift rename to TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift diff --git a/TableProTests/Core/Services/ColumnTypeTests.swift b/TableProTests/Core/Services/ColumnTypeTests.swift index 7fe70b2ca..62c2bb744 100644 --- a/TableProTests/Core/Services/ColumnTypeTests.swift +++ b/TableProTests/Core/Services/ColumnTypeTests.swift @@ -163,52 +163,70 @@ struct ColumnTypeTests { @Test("parses ENUM with multiple values") func parseEnumMultipleValues() { - let result = ColumnType.parseEnumValues(from: "ENUM('a','b','c')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('a','b','c')") #expect(result == ["a", "b", "c"]) } @Test("parses SET with multiple values") func parseSetMultipleValues() { - let result = ColumnType.parseEnumValues(from: "SET('x','y')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "SET('x','y')") #expect(result == ["x", "y"]) } @Test("parses enum prefix case-insensitively") func parseEnumCaseInsensitive() { - let result = ColumnType.parseEnumValues(from: "enum('Active','Inactive')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "enum('Active','Inactive')") #expect(result == ["Active", "Inactive"]) } @Test("parses values with spaces") func parseValuesWithSpaces() { - let result = ColumnType.parseEnumValues(from: "ENUM('hello world','foo bar')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('hello world','foo bar')") #expect(result == ["hello world", "foo bar"]) } @Test("parses values with escaped quotes") func parseValuesWithEscapedQuotes() { - let result = ColumnType.parseEnumValues(from: "ENUM('it\\'s','ok')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('it\\'s','ok')") #expect(result == ["it's", "ok"]) } @Test("returns nil for empty parentheses") func parseEmptyParens() { - let result = ColumnType.parseEnumValues(from: "ENUM()") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM()") #expect(result == nil) } @Test("returns nil for non-enum type string") func parseNonEnumPrefix() { - let result = ColumnType.parseEnumValues(from: "VARCHAR(255)") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "VARCHAR(255)") #expect(result == nil) } @Test("parses single value") func parseSingleValue() { - let result = ColumnType.parseEnumValues(from: "ENUM('only')") + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('only')") #expect(result == ["only"]) } + @Test("parses values with SQL doubled-quote escape") + func parseValuesWithDoubledQuote() { + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('a''b','c')") + #expect(result == ["a'b", "c"]) + } + + @Test("parses ClickHouse enum with doubled-quote escape") + func parseClickHouseDoubledQuote() { + let result = EnumValueParser.parseClickHouseEnum(from: "Enum8('a''b' = 1, 'c' = 2)") + #expect(result == ["a'b", "c"]) + } + + @Test("stray backslash outside quotes does not corrupt parse") + func parseStrayBackslashOutsideQuotes() { + let result = EnumValueParser.parseMySQLEnumOrSet(from: "ENUM('a'\\,'b')") + #expect(result == ["a", "b"]) + } + // MARK: - Other Type Properties Are False for Enum/Set @Test("enumType is not JSON type") @@ -303,49 +321,49 @@ struct ColumnTypeTests { @Test("parses Enum8 with values and assignments") func parseEnum8Values() { - let result = ColumnType.parseClickHouseEnumValues(from: "Enum8('active' = 1, 'inactive' = 2)") + let result = EnumValueParser.parseClickHouseEnum(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)") + let result = EnumValueParser.parseClickHouseEnum(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)") + let result = EnumValueParser.parseClickHouseEnum(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)") + let result = EnumValueParser.parseClickHouseEnum(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)") + let result = EnumValueParser.parseClickHouseEnum(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')") + let result = EnumValueParser.parseClickHouseEnum(from: "ENUM('a','b')") #expect(result == nil) } @Test("returns nil for non-enum type") func parseClickHouseReturnsNilForNonEnum() { - let result = ColumnType.parseClickHouseEnumValues(from: "String") + let result = EnumValueParser.parseClickHouseEnum(from: "String") #expect(result == nil) } @Test("returns nil for empty Enum8") func parseClickHouseEmptyEnum() { - let result = ColumnType.parseClickHouseEnumValues(from: "Enum8()") + let result = EnumValueParser.parseClickHouseEnum(from: "Enum8()") #expect(result == nil) } } diff --git a/TableProTests/Plugins/PluginColumnInfoCodableTests.swift b/TableProTests/Plugins/PluginColumnInfoCodableTests.swift new file mode 100644 index 000000000..1de031392 --- /dev/null +++ b/TableProTests/Plugins/PluginColumnInfoCodableTests.swift @@ -0,0 +1,43 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PluginColumnInfo Codable") +struct PluginColumnInfoCodableTests { + @Test("allowedValues round-trips through JSON encoding") + func allowedValuesRoundTrip() throws { + let original = PluginColumnInfo( + name: "status", + dataType: "ENUM", + allowedValues: ["active", "inactive", "pending"] + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: data) + #expect(decoded.allowedValues == ["active", "inactive", "pending"]) + } + + @Test("nil allowedValues encodes and decodes back to nil") + func nilAllowedValuesRoundTrip() throws { + let original = PluginColumnInfo(name: "id", dataType: "INTEGER") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: data) + #expect(decoded.allowedValues == nil) + } + + @Test("decoding a payload without allowedValues keeps it nil for forward compatibility") + func legacyPayloadDecodesToNilAllowedValues() throws { + let legacyJson = """ + { + "name": "id", + "dataType": "INTEGER", + "isNullable": false, + "isPrimaryKey": true, + "isGenerated": false + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: legacyJson) + #expect(decoded.allowedValues == nil) + #expect(decoded.name == "id") + #expect(decoded.isPrimaryKey) + } +}