diff --git a/CHANGELOG.md b/CHANGELOG.md index e316799c9..414ce8c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Converted `DatabaseType` from closed enum to string-based struct, enabling future plugin-defined database types - Moved string literal escaping into plugin drivers via `escapeStringLiteral` on `PluginDatabaseDriver` and `DatabaseDriver` protocols; `SQLEscaping.escapeStringLiteral` now uses ANSI SQL escaping only (doubles single quotes, strips null bytes) - SQL autocomplete data types and CREATE TABLE options now use plugin-provided dialect data instead of hardcoded per-database switches - `FilterSQLGenerator` now uses `SQLDialectDescriptor` data (regex syntax, boolean literals, LIKE escape style, pagination style) instead of `DatabaseType` switch statements diff --git a/CLAUDE.md b/CLAUDE.md index 56947f841..1cd2ae2fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,14 @@ When adding a new driver: create a new plugin bundle under `Plugins/`, implement When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (with default implementation), then update `PluginDriverAdapter` to bridge it to `DatabaseDriver`. +### DatabaseType (String-Based Struct) + +`DatabaseType` is a string-based struct (not an enum). Key rules: +- All `switch` statements on `DatabaseType` must include `default:` — the type is open +- Use static constants (`.mysql`, `.postgresql`) for known types +- Unknown types (from future plugins) are valid — they round-trip through Codable +- Use `DatabaseType.allKnownTypes` (not `allCases`) for the canonical list of built-in types + ### Editor Architecture (CodeEditSourceEditor) - **`SQLEditorTheme`** — single source of truth for editor colors/fonts diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 8195c5341..c5463b154 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -44,13 +44,10 @@ struct SQLStatementGenerator { self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) } + private static let dollarStyleTypes: Set = [.postgresql, .redshift, .duckdb] + private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle { - switch databaseType { - case .postgresql, .redshift, .duckdb: - return .dollar - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: - return .questionMark - } + dollarStyleTypes.contains(databaseType) ? .dollar : .questionMark } // MARK: - Public API diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 9d4dacb91..6506ed528 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -303,28 +303,19 @@ extension DatabaseDriver { guard seconds > 0 else { return } let ms = seconds * 1_000 do { - switch connection.type { - case .mysql: + let type = connection.type + if type == .mysql { _ = try await execute(query: "SET SESSION max_execution_time = \(ms)") - case .mariadb: + } else if type == .mariadb { _ = try await execute(query: "SET SESSION max_statement_time = \(seconds)") - case .postgresql, .redshift: + } else if type == .postgresql || type == .redshift { _ = try await execute(query: "SET statement_timeout = '\(ms)'") - case .sqlite: - break // SQLite busy_timeout handled by driver directly - case .duckdb: - break - case .mongodb: - break // MongoDB timeout handled per-operation by MongoDBDriver - case .redis: - break // Redis does not support session-level query timeouts - case .mssql: + } else if type == .mssql { _ = try await execute(query: "SET LOCK_TIMEOUT \(ms)") - case .oracle: - break // Oracle timeout handled per-statement by OracleDriver - case .clickhouse: + } else if type == .clickhouse { _ = try await execute(query: "SET max_execution_time = \(seconds)") } + // sqlite, duckdb, mongodb, redis, oracle: no session-level timeout } catch { Logger(subsystem: "com.TablePro", category: "DatabaseDriver") .warning("Failed to set query timeout: \(error.localizedDescription)") diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 0c4938430..6081532ba 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -319,6 +319,22 @@ final class PluginManager { } } + // MARK: - Available Database Types + + /// All database types with loaded plugins, ordered by display name. + var availableDatabaseTypes: [DatabaseType] { + var types: [DatabaseType] = [] + for entry in plugins where entry.isEnabled { + if let typeId = entry.databaseTypeId { + types.append(DatabaseType(rawValue: typeId)) + } + for additionalId in entry.additionalTypeIds { + types.append(DatabaseType(rawValue: additionalId)) + } + } + return types.sorted { $0.rawValue < $1.rawValue } + } + // MARK: - Driver Availability func isDriverAvailable(for databaseType: DatabaseType) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift b/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift index 9483e7138..dd0d76709 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift @@ -87,8 +87,8 @@ enum DeeplinkHandler { guard let name = value("name"), !name.isEmpty, let host = value("host"), !host.isEmpty, let typeStr = value("type"), - let dbType = DatabaseType(rawValue: typeStr) - ?? DatabaseType.allCases.first(where: { + let dbType = DatabaseType(validating: typeStr) + ?? DatabaseType.allKnownTypes.first(where: { $0.rawValue.lowercased() == typeStr.lowercased() }) else { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index c2395fa1f..0ad97664c 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -586,7 +586,7 @@ private struct StoredConnection: Codable { port: port, database: database, username: username, - type: DatabaseType(rawValue: type) ?? .mysql, + type: DatabaseType(rawValue: type), sshConfig: sshConfig, sslConfig: sslConfig, color: parsedColor, diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 72e7c12f9..995cf5721 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -26,20 +26,22 @@ struct ConnectionURLFormatter { // MARK: - Private + private static let urlSchemeMap: [DatabaseType: String] = [ + .mysql: "mysql", + .mariadb: "mariadb", + .postgresql: "postgresql", + .redshift: "redshift", + .sqlite: "sqlite", + .mongodb: "mongodb", + .redis: "redis", + .mssql: "sqlserver", + .oracle: "oracle", + .clickhouse: "clickhouse", + .duckdb: "duckdb", + ] + private static func urlScheme(for type: DatabaseType) -> String { - switch type { - case .mysql: return "mysql" - case .mariadb: return "mariadb" - case .postgresql: return "postgresql" - case .redshift: return "redshift" - case .sqlite: return "sqlite" - case .mongodb: return "mongodb" - case .redis: return "redis" - case .mssql: return "sqlserver" - case .oracle: return "oracle" - case .clickhouse: return "clickhouse" - case .duckdb: return "duckdb" - } + urlSchemeMap[type] ?? type.rawValue.lowercased() } private static func formatSQLite(_ database: String) -> String { diff --git a/TablePro/Core/Utilities/SQL/SQLParameterInliner.swift b/TablePro/Core/Utilities/SQL/SQLParameterInliner.swift index 97b04f689..2ae8e0311 100644 --- a/TablePro/Core/Utilities/SQL/SQLParameterInliner.swift +++ b/TablePro/Core/Utilities/SQL/SQLParameterInliner.swift @@ -17,11 +17,12 @@ struct SQLParameterInliner { /// - statement: The parameterized statement containing SQL with placeholders and bound values. /// - databaseType: The database type, which determines placeholder style (`?` vs `$N`). /// - Returns: A SQL string with placeholders replaced by formatted literal values. + private static let dollarPlaceholderTypes: Set = [.postgresql, .redshift, .duckdb] + static func inline(_ statement: ParameterizedStatement, databaseType: DatabaseType) -> String { - switch databaseType { - case .postgresql, .redshift, .duckdb: + if dollarPlaceholderTypes.contains(databaseType) { return inlineDollarPlaceholders(statement.sql, parameters: statement.parameters) - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: + } else { return inlineQuestionMarkPlaceholders(statement.sql, parameters: statement.parameters) } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index e47b41d93..faaaa8fc0 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -197,120 +197,142 @@ struct SSLConfiguration: Codable, Hashable { // MARK: - Database Type /// Represents the type of database -enum DatabaseType: String, CaseIterable, Identifiable, Codable { - case mysql = "MySQL" - case mariadb = "MariaDB" - case postgresql = "PostgreSQL" - case sqlite = "SQLite" - case redshift = "Redshift" - case mongodb = "MongoDB" - case redis = "Redis" - case mssql = "SQL Server" - case oracle = "Oracle" - case clickhouse = "ClickHouse" - case duckdb = "DuckDB" - +struct DatabaseType: Hashable, Identifiable, Sendable { + let rawValue: String + init(rawValue: String) { self.rawValue = rawValue } var id: String { rawValue } - var displayName: String { rawValue } +} + +extension DatabaseType { + static let mysql = DatabaseType(rawValue: "MySQL") + static let mariadb = DatabaseType(rawValue: "MariaDB") + static let postgresql = DatabaseType(rawValue: "PostgreSQL") + static let sqlite = DatabaseType(rawValue: "SQLite") + static let redshift = DatabaseType(rawValue: "Redshift") + static let mongodb = DatabaseType(rawValue: "MongoDB") + static let redis = DatabaseType(rawValue: "Redis") + static let mssql = DatabaseType(rawValue: "SQL Server") + static let oracle = DatabaseType(rawValue: "Oracle") + static let clickhouse = DatabaseType(rawValue: "ClickHouse") + static let duckdb = DatabaseType(rawValue: "DuckDB") +} + +extension DatabaseType: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +extension DatabaseType { + /// All built-in database types. + static let allKnownTypes: [DatabaseType] = [ + .mysql, .mariadb, .postgresql, .sqlite, .redshift, + .mongodb, .redis, .mssql, .oracle, .clickhouse, .duckdb, + ] + + /// Compatibility shim for CaseIterable call sites. + static var allCases: [DatabaseType] { allKnownTypes } +} + +extension DatabaseType { + /// Returns nil if rawValue doesn't match any known type. + init?(validating rawValue: String) { + guard Self.allKnownTypes.contains(where: { $0.rawValue == rawValue }) else { return nil } + self.rawValue = rawValue + } +} + +extension DatabaseType { /// Plugin type ID used for PluginManager lookup. - /// Maps database types that share a plugin (e.g., MySQL/MariaDB, PostgreSQL/Redshift). var pluginTypeId: String { - switch self { - case .mysql, .mariadb: return "MySQL" - case .postgresql, .redshift: return "PostgreSQL" - case .mssql: return "SQL Server" - case .sqlite: return "SQLite" - case .mongodb: return "MongoDB" - case .redis: return "Redis" - case .oracle: return "Oracle" - case .clickhouse: return "ClickHouse" - case .duckdb: return "DuckDB" - } + Self.pluginTypeIdMap[self] ?? rawValue } var isDownloadablePlugin: Bool { - switch self { - case .oracle, .clickhouse, .sqlite, .duckdb: return true - default: return false - } + Self.isDownloadablePluginSet.contains(self) } - /// Asset name for each database type icon var iconName: String { - switch self { - case .mysql: - return "mysql-icon" - case .mariadb: - return "mariadb-icon" - case .postgresql: - return "postgresql-icon" - case .sqlite: - return "sqlite-icon" - case .redshift: - return "redshift-icon" - case .mongodb: - return "mongodb-icon" - case .redis: - return "redis-icon" - case .mssql: - return "mssql-icon" - case .oracle: - return "oracle-icon" - case .clickhouse: - return "clickhouse-icon" - case .duckdb: - return "duckdb-icon" - } + Self.iconNameMap[self] ?? "database-icon" } - /// Default port for each database type var defaultPort: Int { - switch self { - case .mysql, .mariadb: return 3_306 - case .postgresql: return 5_432 - case .sqlite: return 0 - case .redshift: return 5_439 - case .mongodb: return 27_017 - case .redis: return 6_379 - case .mssql: return 1_433 - case .oracle: return 1_521 - case .clickhouse: return 8_123 - case .duckdb: return 0 - } + Self.defaultPortMap[self] ?? 0 } - /// Whether this database type typically requires authentication credentials. - /// MySQL, MariaDB, and PostgreSQL default to "root" when no username is provided; - /// MongoDB and SQLite commonly run without authentication. var requiresAuthentication: Bool { - switch self { - case .mysql, .mariadb, .postgresql, .redshift, .mssql, .oracle, .clickhouse: return true - case .sqlite, .duckdb, .mongodb, .redis: return false - } + Self.requiresAuthenticationSet.contains(self) } - /// Whether this database type supports foreign key constraints var supportsForeignKeys: Bool { - switch self { - case .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mssql, .oracle, .duckdb: - return true - case .mongodb, .redis, .clickhouse: - return false - } + Self.supportsForeignKeysSet.contains(self) } - /// Whether this database type supports SQL-based schema editing (ALTER TABLE etc.) var supportsSchemaEditing: Bool { - switch self { - case .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle, .clickhouse, .duckdb: - return true - case .redshift, .mongodb, .redis: - return false - } + Self.supportsSchemaEditingSet.contains(self) } + private static let pluginTypeIdMap: [DatabaseType: String] = [ + .mysql: "MySQL", .mariadb: "MySQL", + .postgresql: "PostgreSQL", .redshift: "PostgreSQL", + .mssql: "SQL Server", + .sqlite: "SQLite", + .mongodb: "MongoDB", + .redis: "Redis", + .oracle: "Oracle", + .clickhouse: "ClickHouse", + .duckdb: "DuckDB", + ] + + private static let isDownloadablePluginSet: Set = [ + .oracle, .clickhouse, .sqlite, .duckdb, + ] + + private static let iconNameMap: [DatabaseType: String] = [ + .mysql: "mysql-icon", + .mariadb: "mariadb-icon", + .postgresql: "postgresql-icon", + .sqlite: "sqlite-icon", + .redshift: "redshift-icon", + .mongodb: "mongodb-icon", + .redis: "redis-icon", + .mssql: "mssql-icon", + .oracle: "oracle-icon", + .clickhouse: "clickhouse-icon", + .duckdb: "duckdb-icon", + ] + + private static let defaultPortMap: [DatabaseType: Int] = [ + .mysql: 3_306, .mariadb: 3_306, + .postgresql: 5_432, + .sqlite: 0, + .redshift: 5_439, + .mongodb: 27_017, + .redis: 6_379, + .mssql: 1_433, + .oracle: 1_521, + .clickhouse: 8_123, + .duckdb: 0, + ] + + private static let requiresAuthenticationSet: Set = [ + .mysql, .mariadb, .postgresql, .redshift, .mssql, .oracle, .clickhouse, + ] + + private static let supportsForeignKeysSet: Set = [ + .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mssql, .oracle, .duckdb, + ] + + private static let supportsSchemaEditingSet: Set = [ + .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle, .clickhouse, .duckdb, + ] } // MARK: - Connection Color diff --git a/TablePro/Theme/Theme.swift b/TablePro/Theme/Theme.swift index 52f18da52..91a39631e 100644 --- a/TablePro/Theme/Theme.swift +++ b/TablePro/Theme/Theme.swift @@ -25,6 +25,7 @@ enum Theme { static let oracleColor = Color(red: 0.76, green: 0.09, blue: 0.07) // #C3160B Oracle red static let clickhouseColor = Color(red: 1.0, green: 0.82, blue: 0.0) static let duckdbColor = Color(red: 1.0, green: 0.85, blue: 0.0) + static let defaultDatabaseColor = Color.gray // MARK: - Semantic Colors @@ -100,29 +101,20 @@ extension View { extension DatabaseType { var themeColor: Color { - switch self { - case .mysql: - return Theme.mysqlColor - case .mariadb: - return Theme.mariadbColor - case .postgresql: - return Theme.postgresqlColor - case .sqlite: - return Theme.sqliteColor - case .redshift: - return Theme.redshiftColor - case .mongodb: - return Theme.mongodbColor - case .redis: - return Theme.redisColor - case .mssql: - return Theme.mssqlColor - case .oracle: - return Theme.oracleColor - case .clickhouse: - return Theme.clickhouseColor - case .duckdb: - return Theme.duckdbColor - } + Self.themeColorMap[self] ?? Theme.defaultDatabaseColor } + + private static let themeColorMap: [DatabaseType: Color] = [ + .mysql: Theme.mysqlColor, + .mariadb: Theme.mariadbColor, + .postgresql: Theme.postgresqlColor, + .sqlite: Theme.sqliteColor, + .redshift: Theme.redshiftColor, + .mongodb: Theme.mongodbColor, + .redis: Theme.redisColor, + .mssql: Theme.mssqlColor, + .oracle: Theme.oracleColor, + .clickhouse: Theme.clickhouseColor, + .duckdb: Theme.duckdbColor, + ] } diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index abe215b95..7be13baf7 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -173,27 +173,21 @@ final class DatabaseSwitcherViewModel { if isSchemaMode { return name.hasPrefix("pg_") } - switch databaseType { - case .mysql, .mariadb: - return ["information_schema", "mysql", "performance_schema", "sys"].contains(name) - case .postgresql: - return ["postgres", "template0", "template1"].contains(name) - case .redshift: - return ["dev", "padb_harvest"].contains(name) - case .clickhouse: - return ["information_schema", "INFORMATION_SCHEMA", "system"].contains(name) - case .sqlite: - return false - case .duckdb: - return ["information_schema", "pg_catalog"].contains(name.lowercased()) - case .mongodb: - return false - case .redis: - return false - case .mssql: - return ["master", "tempdb", "model", "msdb"].contains(name) - case .oracle: - return ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"].contains(name) + if databaseType == .duckdb { + return Self.duckdbSystemItems.contains(name.lowercased()) } + return Self.systemItemNames[databaseType]?.contains(name) ?? false } + + private static let duckdbSystemItems: Set = ["information_schema", "pg_catalog"] + + private static let systemItemNames: [DatabaseType: Set] = [ + .mysql: ["information_schema", "mysql", "performance_schema", "sys"], + .mariadb: ["information_schema", "mysql", "performance_schema", "sys"], + .postgresql: ["postgres", "template0", "template1"], + .redshift: ["dev", "padb_harvest"], + .clickhouse: ["information_schema", "INFORMATION_SCHEMA", "system"], + .mssql: ["master", "tempdb", "model", "msdb"], + .oracle: ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"], + ] } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index f858e0f55..af246d6aa 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -25,7 +25,7 @@ struct ConnectionFormView: View { private var isNew: Bool { connectionId == nil } private var availableDatabaseTypes: [DatabaseType] { - DatabaseType.allCases + DatabaseType.allKnownTypes } private var additionalConnectionFields: [ConnectionField] { @@ -745,17 +745,8 @@ struct ConnectionFormView: View { // MARK: - Helpers private var defaultPort: String { - switch type { - case .mysql, .mariadb: return "3306" - case .postgresql: return "5432" - case .redshift: return "5439" - case .clickhouse: return "8123" - case .sqlite, .duckdb: return "" - case .mongodb: return "27017" - case .redis: return "6379" - case .mssql: return "1433" - case .oracle: return "1521" - } + let port = type.defaultPort + return port == 0 ? "" : String(port) } private var isValid: Bool { diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 175ad2b82..268f2e359 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -434,8 +434,8 @@ struct ExportDialog: View { do { var items: [ExportDatabaseItem] = [] - switch connection.type { - case .postgresql, .redshift: + let dbType = connection.type + if dbType == .postgresql || dbType == .redshift { // PostgreSQL: fetch schemas within current database (can't query across databases) let schemas = try await fetchPostgreSQLSchemas(driver: driver) for schema in schemas { @@ -462,16 +462,14 @@ struct ExportDialog: View { if item2.name == "public" { return false } return item1.name < item2.name } - - case .sqlite, .mongodb, .redis, .duckdb: - let fallbackName = connection.type == .redis ? "db0" : "main" + } else if dbType == .sqlite || dbType == .mongodb || dbType == .redis || dbType == .duckdb { + let fallbackName = dbType == .redis ? "db0" : "main" let dbItem = try await buildFlatDatabaseItem( driver: driver, name: connection.database.isEmpty ? fallbackName : connection.database ) if let dbItem { items.append(dbItem) } - - case .mssql: + } else if dbType == .mssql { // MSSQL: fetch schemas within current database let schemas = try await driver.fetchSchemas() for schema in schemas { @@ -497,8 +495,7 @@ struct ExportDialog: View { if item2.name == "dbo" { return false } return item1.name < item2.name } - - case .oracle: + } else if dbType == .oracle { // Oracle: fetch schemas (users) and their tables let schemas = try await driver.fetchSchemas() for schema in schemas { @@ -519,9 +516,8 @@ struct ExportDialog: View { )) } } - - case .clickhouse, .mysql, .mariadb: - // MySQL/MariaDB/ClickHouse: fetch all databases and their tables + } else { + // MySQL/MariaDB/ClickHouse and other types: fetch all databases and their tables let databases = try await driver.fetchDatabases() for dbName in databases { let tables = try await fetchTablesForDatabase(dbName, driver: driver) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 613857cf7..110667584 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -243,9 +243,10 @@ extension MainContentCoordinator { WindowOpener.shared.openNativeTab(payload) } + // swiftlint:disable:next function_body_length private func allTablesMetadataSQL() -> String? { - switch connection.type { - case .postgresql: + let dbType = connection.type + if dbType == .postgresql { let schema = (DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable)?.escapedSchema ?? "public" return """ SELECT @@ -261,7 +262,7 @@ extension MainContentCoordinator { WHERE schemaname = '\(schema)' ORDER BY relname """ - case .redshift: + } else if dbType == .redshift { let schema = (DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable)?.escapedSchema ?? "public" return """ SELECT @@ -277,7 +278,7 @@ extension MainContentCoordinator { WHERE schema = '\(schema)' ORDER BY "table" """ - case .clickhouse: + } else if dbType == .clickhouse { return """ SELECT database as `schema`, @@ -290,7 +291,7 @@ extension MainContentCoordinator { WHERE database = currentDatabase() ORDER BY name """ - case .mysql, .mariadb: + } else if dbType == .mysql || dbType == .mariadb { return """ SELECT TABLE_SCHEMA as `schema`, @@ -309,7 +310,7 @@ extension MainContentCoordinator { WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME """ - case .sqlite: + } else if dbType == .sqlite { return """ SELECT '' as schema, @@ -327,7 +328,7 @@ extension MainContentCoordinator { AND name NOT LIKE 'sqlite_%' ORDER BY name """ - case .mssql: + } else if dbType == .mssql { return """ SELECT s.name as schema_name, @@ -344,7 +345,7 @@ extension MainContentCoordinator { GROUP BY s.name, t.name, p.rows, v.object_id ORDER BY t.name """ - case .oracle: + } else if dbType == .oracle { let schema = (DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable)?.escapedSchema ?? "SYSTEM" return """ SELECT @@ -356,7 +357,7 @@ extension MainContentCoordinator { WHERE OWNER = '\(schema)' ORDER BY TABLE_NAME """ - case .duckdb: + } else if dbType == .duckdb { let schema = (DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable)?.escapedSchema ?? "main" return """ SELECT @@ -367,14 +368,14 @@ extension MainContentCoordinator { WHERE table_schema = '\(schema)' ORDER BY table_name """ - case .mongodb: + } else if dbType == .mongodb { tabManager.addTab( initialQuery: "db.runCommand({\"listCollections\": 1, \"nameOnly\": false})", databaseName: connection.database ) runQuery() return nil - case .redis: + } else if dbType == .redis { tabManager.addTab( initialQuery: "SCAN 0 MATCH * COUNT 100", databaseName: connection.database @@ -382,6 +383,7 @@ extension MainContentCoordinator { runQuery() return nil } + return nil } // MARK: - Database Switching diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 35329d8c9..8f8586d96 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -16,118 +16,78 @@ enum DataTypeCategory: String, CaseIterable { case other = "Other" func types(for dbType: DatabaseType) -> [String] { - switch self { - case .numeric: - switch dbType { - case .mysql, .mariadb: - return ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"] - case .postgresql, .redshift: - return ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"] - case .mssql: - return ["TINYINT", "SMALLINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", "BIT"] - case .oracle: - return ["NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE", "INTEGER", "SMALLINT", "FLOAT"] - case .clickhouse: - return [ - "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", - "Int8", "Int16", "Int32", "Int64", "Int128", "Int256", - "Float32", "Float64", "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", "Bool" - ] - case .sqlite: - return ["INTEGER", "REAL", "NUMERIC"] - case .duckdb: - return ["INTEGER", "BIGINT", "HUGEINT", "SMALLINT", "TINYINT", "DOUBLE", "FLOAT", "DECIMAL", "REAL", "NUMERIC"] - case .mongodb: - return ["Int32", "Int64", "Double", "Decimal128"] - case .redis: - return ["Integer"] - } - case .string: - switch dbType { - case .mysql, .mariadb: - return ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"] - case .postgresql, .redshift: - return ["CHAR", "VARCHAR", "TEXT"] - case .mssql: - return ["CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT"] - case .oracle: - return ["CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG"] - case .clickhouse: - return ["String", "FixedString", "UUID", "IPv4", "IPv6"] - case .sqlite: - return ["TEXT"] - case .duckdb: - return ["VARCHAR", "TEXT", "CHAR", "BPCHAR"] - case .mongodb: - return ["String", "ObjectId", "UUID"] - case .redis: - return ["String"] - } - case .dateTime: - switch dbType { - case .mysql, .mariadb: - return ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"] - case .postgresql, .redshift: - return ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"] - case .mssql: - return ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"] - case .oracle: - return ["DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND"] - case .clickhouse: - return ["Date", "Date32", "DateTime", "DateTime64"] - case .sqlite: - return ["DATE", "DATETIME"] - case .duckdb: - return ["DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL"] - case .mongodb: - return ["Date", "Timestamp"] - case .redis: - return [] - } - case .binary: - switch dbType { - case .mysql, .mariadb: - return ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"] - case .postgresql, .redshift: - return ["BYTEA"] - case .mssql: - return ["BINARY", "VARBINARY", "IMAGE"] - case .oracle: - return ["BLOB", "RAW", "LONG RAW", "BFILE"] - case .clickhouse: - return [] - case .sqlite: - return ["BLOB"] - case .duckdb: - return ["BLOB", "BYTEA"] - case .mongodb: - return ["BinData"] - case .redis: - return [] - } - case .other: - switch dbType { - case .mysql, .mariadb: - return ["BOOLEAN", "ENUM", "SET", "JSON"] - case .postgresql, .redshift: - return ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"] - case .mssql: - return ["BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", "ROWVERSION", "HIERARCHYID"] - case .oracle: - return ["BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY"] - case .clickhouse: - return ["Array", "Tuple", "Map", "Nested", "JSON", "Nullable", "LowCardinality", "Enum8", "Enum16", "Nothing"] - case .sqlite: - return ["BOOLEAN"] - case .duckdb: - return ["BOOLEAN", "UUID", "JSON", "LIST", "MAP", "STRUCT", "ENUM", "BIT", "UNION"] - case .mongodb: - return ["Boolean", "Object", "Array", "Null", "Regex"] - case .redis: - return ["List", "Set", "Sorted Set", "Hash", "Stream"] - } - } + Self.typeMap[self]?[dbType] ?? [] } + + // swiftlint:disable:next line_length + private static let typeMap: [DataTypeCategory: [DatabaseType: [String]]] = [ + .numeric: [ + .mysql: ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"], + .mariadb: ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"], + .postgresql: ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"], + .redshift: ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"], + .mssql: ["TINYINT", "SMALLINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", "BIT"], + .oracle: ["NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE", "INTEGER", "SMALLINT", "FLOAT"], + .clickhouse: [ + "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", + "Int8", "Int16", "Int32", "Int64", "Int128", "Int256", + "Float32", "Float64", "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", "Bool", + ], + .sqlite: ["INTEGER", "REAL", "NUMERIC"], + .duckdb: ["INTEGER", "BIGINT", "HUGEINT", "SMALLINT", "TINYINT", "DOUBLE", "FLOAT", "DECIMAL", "REAL", "NUMERIC"], + .mongodb: ["Int32", "Int64", "Double", "Decimal128"], + .redis: ["Integer"], + ], + .string: [ + .mysql: ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"], + .mariadb: ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"], + .postgresql: ["CHAR", "VARCHAR", "TEXT"], + .redshift: ["CHAR", "VARCHAR", "TEXT"], + .mssql: ["CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT"], + .oracle: ["CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG"], + .clickhouse: ["String", "FixedString", "UUID", "IPv4", "IPv6"], + .sqlite: ["TEXT"], + .duckdb: ["VARCHAR", "TEXT", "CHAR", "BPCHAR"], + .mongodb: ["String", "ObjectId", "UUID"], + .redis: ["String"], + ], + .dateTime: [ + .mysql: ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], + .mariadb: ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], + .postgresql: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"], + .redshift: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"], + .mssql: ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"], + .oracle: ["DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND"], + .clickhouse: ["Date", "Date32", "DateTime", "DateTime64"], + .sqlite: ["DATE", "DATETIME"], + .duckdb: ["DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL"], + .mongodb: ["Date", "Timestamp"], + ], + .binary: [ + .mysql: ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"], + .mariadb: ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"], + .postgresql: ["BYTEA"], + .redshift: ["BYTEA"], + .mssql: ["BINARY", "VARBINARY", "IMAGE"], + .oracle: ["BLOB", "RAW", "LONG RAW", "BFILE"], + .sqlite: ["BLOB"], + .duckdb: ["BLOB", "BYTEA"], + .mongodb: ["BinData"], + ], + .other: [ + .mysql: ["BOOLEAN", "ENUM", "SET", "JSON"], + .mariadb: ["BOOLEAN", "ENUM", "SET", "JSON"], + .postgresql: ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"], + .redshift: ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"], + .mssql: ["BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", "ROWVERSION", "HIERARCHYID"], + .oracle: ["BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY"], + .clickhouse: ["Array", "Tuple", "Map", "Nested", "JSON", "Nullable", "LowCardinality", "Enum8", "Enum16", "Nothing"], + .sqlite: ["BOOLEAN"], + .duckdb: ["BOOLEAN", "UUID", "JSON", "LIST", "MAP", "STRUCT", "ENUM", "BIT", "UNION"], + .mongodb: ["Boolean", "Object", "Array", "Null", "Regex"], + .redis: ["List", "Set", "Sorted Set", "Hash", "Stream"], + ], + ] } struct TypePickerContentView: View { diff --git a/TableProTests/Models/DatabaseTypeMSSQLTests.swift b/TableProTests/Models/DatabaseTypeMSSQLTests.swift index 7ba6f668b..acd5abbd8 100644 --- a/TableProTests/Models/DatabaseTypeMSSQLTests.swift +++ b/TableProTests/Models/DatabaseTypeMSSQLTests.swift @@ -43,15 +43,15 @@ struct DatabaseTypeMSSQLTests { #expect(DatabaseType.mssql.iconName == "mssql-icon") } - // MARK: - allCases Tests + // MARK: - allKnownTypes Tests - @Test("allCases contains mssql") - func allCasesContainsMSSql() { - #expect(DatabaseType.allCases.contains(.mssql)) + @Test("allKnownTypes contains mssql") + func allKnownTypesContainsMSSql() { + #expect(DatabaseType.allKnownTypes.contains(.mssql)) } - @Test("allCases contains mssql entry") - func allCasesCount() { + @Test("allCases shim contains mssql") + func allCasesContainsMSSql() { #expect(DatabaseType.allCases.contains(.mssql)) } } diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift index c2a3dad1e..642dd5f8c 100644 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ b/TableProTests/Models/DatabaseTypeRedisTests.swift @@ -38,7 +38,12 @@ struct DatabaseTypeRedisTests { #expect(DatabaseType.redis.themeColor == Theme.redisColor) } - @Test("Included in allCases") + @Test("Included in allKnownTypes") + func includedInAllKnownTypes() { + #expect(DatabaseType.allKnownTypes.contains(.redis)) + } + + @Test("Included in allCases shim") func includedInAllCases() { #expect(DatabaseType.allCases.contains(.redis)) } diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 53b1bad79..01447574d 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -37,9 +37,14 @@ struct DatabaseTypeTests { #expect(DatabaseType.mongodb.defaultPort == 27_017) } - @Test("CaseIterable count is 11") - func testCaseIterableCount() { - #expect(DatabaseType.allCases.count == 11) + @Test("allKnownTypes count is 11") + func testAllKnownTypesCount() { + #expect(DatabaseType.allKnownTypes.count == 11) + } + + @Test("allCases shim matches allKnownTypes") + func testAllCasesShim() { + #expect(DatabaseType.allCases == DatabaseType.allKnownTypes) } @Test("Raw value matches display name", arguments: [ @@ -85,4 +90,68 @@ struct DatabaseTypeTests { func testClickHouseIconName() { #expect(DatabaseType.clickhouse.iconName == "clickhouse-icon") } + + // MARK: - Plugin Type ID Alias Tests + + @Test("MariaDB pluginTypeId maps to MySQL plugin") + func testMariaDBPluginTypeId() { + #expect(DatabaseType.mariadb.pluginTypeId == "MySQL") + } + + @Test("Redshift pluginTypeId maps to PostgreSQL plugin") + func testRedshiftPluginTypeId() { + #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") + } + + @Test("Unknown type pluginTypeId falls back to rawValue") + func testUnknownPluginTypeIdFallback() { + #expect(DatabaseType(rawValue: "FutureDB").pluginTypeId == "FutureDB") + } + + // MARK: - Struct Behavior Tests + + @Test("Struct equality via rawValue") + func testStructEquality() { + #expect(DatabaseType(rawValue: "MySQL") == .mysql) + } + + @Test("Unknown type round-trips via rawValue") + func testUnknownTypeRoundTrip() { + #expect(DatabaseType(rawValue: "FutureDB").rawValue == "FutureDB") + } + + @Test("Validating init rejects unknown type") + func testValidatingInitRejectsUnknown() { + #expect(DatabaseType(validating: "FutureDB") == nil) + } + + @Test("Validating init accepts known type") + func testValidatingInitAcceptsKnown() { + #expect(DatabaseType(validating: "MySQL") == .mysql) + } + + @Test("Codable round-trip for known type") + func testCodableRoundTrip() throws { + let original = DatabaseType.postgresql + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: data) + #expect(decoded == original) + } + + @Test("Codable round-trip for unknown type") + func testCodableUnknownRoundTrip() throws { + let original = DatabaseType(rawValue: "FutureDB") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: data) + #expect(decoded == original) + #expect(decoded.rawValue == "FutureDB") + } + + @Test("Hashable set membership works") + func testHashableSetMembership() { + let types: Set = [.mysql, .postgresql, .sqlite] + #expect(types.contains(.mysql)) + #expect(types.contains(.postgresql)) + #expect(!types.contains(.redis)) + } }