From 856b11675a956255b2448e3c581cfd81d03a590a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 17:52:22 +0700 Subject: [PATCH 1/5] refactor(create-database): server-driven dialog (fixes #927) --- CHANGELOG.md | 4 + .../BigQueryPluginDriver.swift | 8 +- Plugins/BigQueryDriverPlugin/Info.plist | 2 +- Plugins/CSVExportPlugin/Info.plist | 2 +- .../CassandraPlugin.swift | 8 +- Plugins/CassandraDriverPlugin/Info.plist | 2 +- .../ClickHousePlugin.swift | 8 +- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- .../CloudflareD1PluginDriver.swift | 8 +- Plugins/CloudflareD1DriverPlugin/Info.plist | 2 +- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 4 - Plugins/DuckDBDriverPlugin/Info.plist | 2 +- Plugins/DynamoDBDriverPlugin/Info.plist | 2 +- Plugins/EtcdDriverPlugin/Info.plist | 2 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 2 +- .../LibSQLPluginDriver.swift | 4 - Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 8 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- .../MongoDBPluginDriver.swift | 10 +- Plugins/MySQLDriverPlugin/Info.plist | 2 +- .../MySQLPluginDriver+CreateDatabase.swift | 207 ++++++++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 26 -- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- .../PostgreSQLPluginDriver.swift | 242 +++++++++++++- .../RedshiftPluginDriver.swift | 47 ++- Plugins/RedisDriverPlugin/Info.plist | 2 +- .../RedisDriverPlugin/RedisPluginDriver.swift | 4 - Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 4 - .../PluginCreateDatabaseFormSpec.swift | 72 ++++ .../PluginDatabaseDriver.swift | 13 +- Plugins/XLSXExportPlugin/Info.plist | 2 +- TablePro/Core/Database/DatabaseDriver.swift | 6 +- .../Core/Plugins/PluginDriverAdapter.swift | 51 ++- TablePro/Core/Plugins/PluginManager.swift | 2 +- .../Schema/CreateDatabaseFormSpec.swift | 36 ++ .../Models/Schema/CreateDatabaseOptions.swift | 70 ---- .../DatabaseSwitcherViewModel.swift | 12 +- .../CreateDatabaseSheet.swift | 315 ++++++++++++------ .../DatabaseSwitcherSheet.swift | 17 +- 46 files changed, 935 insertions(+), 293 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift create mode 100644 Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift create mode 100644 TablePro/Models/Schema/CreateDatabaseFormSpec.swift delete mode 100644 TablePro/Models/Schema/CreateDatabaseOptions.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d519cfac..58344486b 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 ### Added +- PostgreSQL ICU collation provider in Create Database (PG 15+). Provider picker is added when the server reports PG 15 or newer. ICU locale list comes from `pg_collation`. SQL emission is version-aware: PG 16+ uses unified `LOCALE`, PG 15 uses `ICU_LOCALE` with `LC_COLLATE 'C' LC_CTYPE 'C'`. - Connection URL parsing: SSH `user:password@host` split, `safeModeLevel` from TablePlus URLs, case-insensitive query params - Connection URL export: SSH password, Redis database index, MongoDB auth params (`authSource`, `authMechanism`, `replicaSet`), and multi-host - SSH Private Key auth resolves keys from `~/.ssh/config` and default locations (`id_ed25519`, `id_rsa`, `id_ecdsa`) when no explicit key path is set @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts @@ -49,6 +51,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection form: `usePrivateKey=true` from URL no longer disables Test/Create buttons - Transient connections from URL clean up keychain entries on connection failure - Native Search Field focus regression when clearing text +- PostgreSQL Create Database failed with `new collation incompatible with template database` on glibc-initialized servers (#927). Encodings, collations, and the `template1` defaults are now read from the server. `LC_CTYPE` mirrors `LC_COLLATE`, and `TEMPLATE template0` is added automatically when the chosen collation differs from `template1.datcollate`. +- Redshift Create Database emitted PostgreSQL `LC_COLLATE` syntax which is invalid Redshift grammar. Now emits `COLLATE { CASE_SENSITIVE | CASE_INSENSITIVE }`. ## [0.36.0] - 2026-04-27 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index e45f65790..98d95d3d1 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -805,9 +805,13 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send "CREATE OR REPLACE VIEW \(quoteIdentifier(viewName)) AS\nSELECT * FROM table_name;" } - func createDatabase(name: String, charset: String, collation: String?) async throws { + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { guard let conn = connection else { throw BigQueryError.notConnected } - let escaped = name.replacingOccurrences(of: "`", with: "\\`") + let escaped = request.name.replacingOccurrences(of: "`", with: "\\`") _ = try await conn.executeQuery("CREATE SCHEMA `\(escaped)`") } diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 59a79798a..173ef74c0 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -1237,8 +1237,12 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen return databases.map { PluginDatabaseMetadata(name: $0) } } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let safeKs = escapeIdentifier(name) + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + let safeKs = escapeIdentifier(request.name) let query = """ CREATE KEYSPACE "\(safeKs)" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3} diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 3af9b6d37..1d6eb773e 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -19,7 +19,7 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) TableProPluginKitVersion - 7 + 8 NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 34eb9ef08..4fb380ca2 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -579,8 +579,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let escapedName = name.replacingOccurrences(of: "`", with: "``") + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + let escapedName = request.name.replacingOccurrences(of: "`", with: "``") _ = try await execute(query: "CREATE DATABASE `\(escapedName)`") } diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index aae530409..8dda2807a 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -500,12 +500,16 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable PluginDatabaseMetadata(name: database) } - func createDatabase(name: String, charset: String, collation: String?) async throws { + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { guard let client = getClient() else { throw CloudflareD1Error.notConnected } - let newDb = try await client.createDatabase(name: name) + let newDb = try await client.createDatabase(name: request.name) lock.lock() databaseNameToUuid[newDb.name] = newDb.uuid diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index cd5c2ffe9..f31523c1a 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index dd3e0b816..932ad7d8d 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -1061,10 +1061,6 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { PluginDatabaseMetadata(name: database) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - throw DuckDBPluginError.unsupportedOperation - } - // MARK: - EXPLAIN func buildExplainQuery(_ sql: String) -> String? { diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index cd5c2ffe9..f31523c1a 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 9b5860792..43a8f9fca 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -463,10 +463,6 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { PluginDatabaseMetadata(name: database) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - throw LibSQLError(message: String(localized: "Creating databases is not supported")) - } - func dropDatabase(name: String) async throws { throw LibSQLError(message: String(localized: "Dropping databases is not supported")) } diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index de6c93bfd..cd715527f 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -1359,8 +1359,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginDatabaseMetadata(name: database) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let quotedName = "[\(name.replacingOccurrences(of: "]", with: "]]"))]" + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + let quotedName = "[\(request.name.replacingOccurrences(of: "]", with: "]]"))]" _ = try await execute(query: "CREATE DATABASE \(quotedName)") } diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 5a89ea722..f1f81f23d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -451,13 +451,17 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { ) } - func createDatabase(name: String, charset: String, collation: String?) async throws { + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected } - _ = try await conn.insertOne(database: name, collection: "__tablepro_init", document: "{\"_init\": true}") - _ = try await conn.runCommand("{\"drop\": \"__tablepro_init\"}", database: name) + _ = try await conn.insertOne(database: request.name, collection: "__tablepro_init", document: "{\"_init\": true}") + _ = try await conn.runCommand("{\"drop\": \"__tablepro_init\"}", database: request.name) } func dropDatabase(name: String) async throws { diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift new file mode 100644 index 000000000..751f87995 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift @@ -0,0 +1,207 @@ +import Foundation +import TableProPluginKit + +extension MySQLPluginDriver { + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + let charsetDefaults = try await fetchCharsetDefaults() + let collations = try await fetchCollationCatalog() + let serverDefaults = await fetchServerCharsetDefaults() + + guard !charsetDefaults.isEmpty, !collations.isEmpty else { + return nil + } + + let resolvedCharset = serverDefaults.charset ?? charsetDefaults.first?.charset + let charsetOptions = charsetDefaults.map { entry -> PluginCreateDatabaseFormSpec.Option in + let isServerDefault = entry.charset == serverDefaults.charset + return PluginCreateDatabaseFormSpec.Option( + value: entry.charset, + label: entry.charset, + subtitle: isServerDefault ? String(localized: "(server default)") : nil, + group: nil + ) + } + + let collationOptions = collations.map { entry -> PluginCreateDatabaseFormSpec.Option in + let isServerDefault = entry.collation == serverDefaults.collation + return PluginCreateDatabaseFormSpec.Option( + value: entry.collation, + label: entry.collation, + subtitle: isServerDefault ? String(localized: "(server default)") : nil, + group: entry.charset + ) + } + + let collationDefault: String? = { + if let serverCollation = serverDefaults.collation, + collations.contains(where: { $0.collation == serverCollation }) { + return serverCollation + } + guard let chosenCharset = resolvedCharset else { return nil } + return charsetDefaults.first(where: { $0.charset == chosenCharset })?.defaultCollation + }() + + let charsetField = PluginCreateDatabaseFormSpec.Field( + id: "charset", + label: String(localized: "Character Set"), + kind: .picker(options: charsetOptions, defaultValue: resolvedCharset) + ) + + let collationField = PluginCreateDatabaseFormSpec.Field( + id: "collation", + label: String(localized: "Collation"), + kind: .searchable(options: collationOptions, defaultValue: collationDefault), + groupedBy: "charset" + ) + + return PluginCreateDatabaseFormSpec(fields: [charsetField, collationField], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + guard let charset = request.values["charset"], !charset.isEmpty else { + throw MariaDBPluginError( + code: 0, + message: String(localized: "Character set is required"), + sqlState: nil + ) + } + + guard isSafeCharsetIdentifier(charset) else { + throw MariaDBPluginError( + code: 0, + message: String(format: String(localized: "Invalid character set: %@"), charset), + sqlState: nil + ) + } + + let availableCharsets = try await fetchCharsetDefaults().map(\.charset) + guard availableCharsets.contains(charset) else { + throw MariaDBPluginError( + code: 0, + message: String(format: String(localized: "Unknown character set: %@"), charset), + sqlState: nil + ) + } + + let collationValue = request.values["collation"].flatMap { $0.isEmpty ? nil : $0 } + + if let collation = collationValue { + guard isSafeCharsetIdentifier(collation) else { + throw MariaDBPluginError( + code: 0, + message: String(format: String(localized: "Invalid collation: %@"), collation), + sqlState: nil + ) + } + + let collations = try await fetchCollationCatalog() + guard let match = collations.first(where: { $0.collation == collation }) else { + throw MariaDBPluginError( + code: 0, + message: String(format: String(localized: "Unknown collation: %@"), collation), + sqlState: nil + ) + } + guard match.charset == charset else { + throw MariaDBPluginError( + code: 0, + message: String( + format: String(localized: "Collation %@ is not valid for character set %@"), + collation, + charset + ), + sqlState: nil + ) + } + } + + let escapedName = request.name.replacingOccurrences(of: "`", with: "``") + var query = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset)" + if let collation = collationValue { + query += " COLLATE \(collation)" + } + + _ = try await execute(query: query) + } +} + +private extension MySQLPluginDriver { + struct CharsetDefault { + let charset: String + let defaultCollation: String + } + + struct CollationEntry { + let collation: String + let charset: String + } + + struct ServerCharsetDefaults { + let charset: String? + let collation: String? + } + + func fetchCharsetDefaults() async throws -> [CharsetDefault] { + let query = """ + SELECT character_set_name, default_collate_name + FROM information_schema.character_sets + ORDER BY character_set_name + """ + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let charset = row[safe: 0] ?? nil, + let collation = row[safe: 1] ?? nil else { + return nil + } + return CharsetDefault(charset: charset, defaultCollation: collation) + } + } + + func fetchCollationCatalog() async throws -> [CollationEntry] { + let query = """ + SELECT collation_name, character_set_name + FROM information_schema.collations + ORDER BY collation_name + """ + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let collation = row[safe: 0] ?? nil, + let charset = row[safe: 1] ?? nil else { + return nil + } + return CollationEntry(collation: collation, charset: charset) + } + } + + enum SessionVariable: String { + case characterSetDatabase = "character_set_database" + case collationDatabase = "collation_database" + } + + func fetchServerCharsetDefaults() async -> ServerCharsetDefaults { + let charset = await fetchSessionVariable(.characterSetDatabase) + let collation = await fetchSessionVariable(.collationDatabase) + return ServerCharsetDefaults(charset: charset, collation: collation) + } + + func fetchSessionVariable(_ variable: SessionVariable) async -> String? { + do { + let result = try await execute(query: "SHOW VARIABLES LIKE '\(variable.rawValue)'") + guard let row = result.rows.first, let value = row[safe: 1] ?? nil else { + return nil + } + return value + } catch { + Self.logger.warning( + "Failed to read session variable \(variable.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return nil + } + } + + func isSafeCharsetIdentifier(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index f8b61307b..d502e115b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -624,32 +624,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let escapedName = name.replacingOccurrences(of: "`", with: "``") - - let validCharsets = [ - "utf8mb4", "utf8mb3", "utf8", "latin1", "ascii", - "binary", "utf16", "utf32", "cp1251", "big5", - "euckr", "gb2312", "gbk", "sjis" - ] - guard validCharsets.contains(charset) else { - throw MariaDBPluginError(code: 0, message: "Invalid character set: \(charset)", sqlState: nil) - } - - var query = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset)" - - if let collation = collation { - let allowedChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) - let isSafe = collation.unicodeScalars.allSatisfy { allowedChars.contains($0) } - guard collation.hasPrefix(charset), isSafe else { - throw MariaDBPluginError(code: 0, message: "Invalid collation for charset", sqlState: nil) - } - query += " COLLATE \(collation)" - } - - _ = try await execute(query: query) - } - func dropDatabase(name: String) async throws { let escapedName = name.replacingOccurrences(of: "`", with: "``") _ = try await execute(query: "DROP DATABASE `\(escapedName)`") diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index ee69f7f0c..4b8df3f15 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -820,26 +820,173 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") - let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP", - "EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"] - let normalizedCharset = charset.uppercased() - guard validCharsets.contains(normalizedCharset) else { - throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil) + private static let supportedEncodings: [String] = [ + "UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP", + "EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK" + ] + + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + let majorVersion = parsedServerMajorVersion() + let supportsProvider = (majorVersion ?? 0) >= 15 + + let templateDefaults = await fetchTemplate1Defaults() + let serverCollate = templateDefaults?.collate + + let collations = await fetchCollations() + let libcCollations = collations.libc + let icuCollations = collations.icu + + let encodingOptions = Self.supportedEncodings.map { + PluginCreateDatabaseFormSpec.Option(value: $0, label: $0) + } + + var fields: [PluginCreateDatabaseFormSpec.Field] = [ + PluginCreateDatabaseFormSpec.Field( + id: "encoding", + label: String(localized: "Encoding"), + kind: .picker(options: encodingOptions, defaultValue: "UTF8") + ) + ] + + if supportsProvider { + let providerOptions: [PluginCreateDatabaseFormSpec.Option] = [ + PluginCreateDatabaseFormSpec.Option(value: "libc", label: "libc"), + PluginCreateDatabaseFormSpec.Option(value: "icu", label: "icu") + ] + fields.append(PluginCreateDatabaseFormSpec.Field( + id: "provider", + label: String(localized: "Locale Provider"), + kind: .picker(options: providerOptions, defaultValue: "libc") + )) } - var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'" - if let collation = collation { - let allowedCollationChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") - let isValidCollation = collation.unicodeScalars.allSatisfy { allowedCollationChars.contains($0) } - guard isValidCollation else { - throw LibPQPluginError(message: "Invalid collation", sqlState: nil, detail: nil) + let serverDefaultSubtitle = String(localized: "(server default)") + let libcOptions: [PluginCreateDatabaseFormSpec.Option] = libcCollations.map { name in + PluginCreateDatabaseFormSpec.Option( + value: name, + label: name, + subtitle: name == serverCollate ? serverDefaultSubtitle : nil + ) + } + + fields.append(PluginCreateDatabaseFormSpec.Field( + id: "collation", + label: String(localized: "Collation"), + kind: .searchable(options: libcOptions, defaultValue: serverCollate), + visibleWhen: supportsProvider + ? PluginCreateDatabaseFormSpec.Visibility(fieldId: "provider", equals: "libc") + : nil + )) + + if supportsProvider { + let icuOptions = icuCollations.map { + PluginCreateDatabaseFormSpec.Option(value: $0, label: $0) } - let escapedCollation = collation.replacingOccurrences(of: "'", with: "''") - query += " LC_COLLATE '\(escapedCollation)'" + fields.append(PluginCreateDatabaseFormSpec.Field( + id: "icu_locale", + label: String(localized: "ICU Locale"), + kind: .searchable(options: icuOptions, defaultValue: nil), + visibleWhen: PluginCreateDatabaseFormSpec.Visibility(fieldId: "provider", equals: "icu") + )) } - _ = try await execute(query: query) + + return PluginCreateDatabaseFormSpec(fields: fields) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + let quotedName = request.name.replacingOccurrences(of: "\"", with: "\"\"") + + guard let encoding = request.values["encoding"] else { + throw LibPQPluginError( + message: String(localized: "Encoding is required"), + sqlState: nil, + detail: nil + ) + } + guard Self.supportedEncodings.contains(encoding) else { + throw LibPQPluginError( + message: String(format: String(localized: "Invalid encoding: %@"), encoding), + sqlState: nil, + detail: nil + ) + } + + var sql = "CREATE DATABASE \"\(quotedName)\" ENCODING '\(encoding)'" + + let majorVersion = parsedServerMajorVersion() + let supportsProvider = (majorVersion ?? 0) >= 15 + let provider = supportsProvider ? (request.values["provider"] ?? "libc") : "libc" + + switch provider { + case "libc": + guard let collation = request.values["collation"], !collation.isEmpty else { + throw LibPQPluginError( + message: String(localized: "Collation is required"), + sqlState: nil, + detail: nil + ) + } + let allowedCollations = await fetchCollations().libc + guard allowedCollations.contains(collation) else { + throw LibPQPluginError( + message: String(format: String(localized: "Invalid collation: %@"), collation), + sqlState: nil, + detail: nil + ) + } + let escapedCollation = escapeLiteral(collation) + sql += " LC_COLLATE '\(escapedCollation)' LC_CTYPE '\(escapedCollation)'" + + guard let templateDefaults = await fetchTemplate1Defaults() else { + throw LibPQPluginError( + message: String(localized: "Failed to read template1 collation defaults"), + sqlState: nil, + detail: nil + ) + } + if templateDefaults.collate != collation { + sql += " TEMPLATE template0" + } + + case "icu": + guard supportsProvider else { + throw LibPQPluginError( + message: String(localized: "ICU provider requires PostgreSQL 15 or newer"), + sqlState: nil, + detail: nil + ) + } + guard let icuLocale = request.values["icu_locale"], !icuLocale.isEmpty else { + throw LibPQPluginError( + message: String(localized: "ICU locale is required"), + sqlState: nil, + detail: nil + ) + } + let allowedIcu = await fetchCollations().icu + guard allowedIcu.contains(icuLocale) else { + throw LibPQPluginError( + message: String(format: String(localized: "Invalid ICU locale: %@"), icuLocale), + sqlState: nil, + detail: nil + ) + } + let escapedIcu = escapeLiteral(icuLocale) + if let major = majorVersion, major >= 16 { + sql += " LOCALE_PROVIDER 'icu' LOCALE '\(escapedIcu)' TEMPLATE template0" + } else { + sql += " LOCALE_PROVIDER 'icu' ICU_LOCALE '\(escapedIcu)' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0" + } + + default: + throw LibPQPluginError( + message: String(format: String(localized: "Invalid locale provider: %@"), provider), + sqlState: nil, + detail: nil + ) + } + + _ = try await execute(query: sql) } func dropDatabase(name: String) async throws { @@ -847,6 +994,69 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "DROP DATABASE \"\(escapedName)\"") } + private func parsedServerMajorVersion() -> Int? { + guard let raw = serverVersion else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let scanner = Scanner(string: trimmed) + scanner.charactersToBeSkipped = nil + _ = scanner.scanCharacters(from: CharacterSet.decimalDigits.inverted) + guard let digitRun = scanner.scanCharacters(from: .decimalDigits), + let value = Int(digitRun) else { + return nil + } + if value > 999 { + return value / 10000 + } + return value + } + + private func fetchTemplate1Defaults() async -> (collate: String, ctype: String)? { + do { + let result = try await execute( + query: "SELECT datcollate, datctype FROM pg_database WHERE datname = 'template1'" + ) + guard let row = result.rows.first, + row.count >= 2, + let collate = row[0], + let ctype = row[1] else { + return nil + } + return (collate: collate, ctype: ctype) + } catch { + Self.logger.error( + "Failed to read template1 defaults: \(error.localizedDescription, privacy: .public)" + ) + return nil + } + } + + private func fetchCollations() async -> (libc: [String], icu: [String]) { + do { + let result = try await execute( + query: "SELECT collname, collprovider FROM pg_collation WHERE collprovider IN ('b', 'c', 'i') ORDER BY collname" + ) + var libc: [String] = [] + var icu: [String] = [] + for row in result.rows { + guard row.count >= 2, let name = row[0], let provider = row[1] else { continue } + switch provider { + case "b", "c": + libc.append(name) + case "i": + icu.append(name) + default: + continue + } + } + return (libc: libc, icu: icu) + } catch { + Self.logger.error( + "Failed to read pg_collation: \(error.localizedDescription, privacy: .public)" + ) + return (libc: [], icu: []) + } + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index a2f1e38e3..e5ee149d9 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -663,28 +663,39 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func createDatabase(name: String, charset: String, collation: String?) async throws { - let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") - let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP", - "EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"] - let normalizedCharset = charset.uppercased() - guard validCharsets.contains(normalizedCharset) else { - throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil) + private static let supportedCollations: [String] = ["CASE_SENSITIVE", "CASE_INSENSITIVE"] + + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + let options = Self.supportedCollations.map { + PluginCreateDatabaseFormSpec.Option(value: $0, label: $0) } + let field = PluginCreateDatabaseFormSpec.Field( + id: "collate", + label: String(localized: "Collation"), + kind: .picker(options: options, defaultValue: "CASE_SENSITIVE") + ) + return PluginCreateDatabaseFormSpec(fields: [field]) + } - var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'" - if let collation = collation { - let allowedCollationChars = CharacterSet( - charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + guard let collate = request.values["collate"] else { + throw LibPQPluginError( + message: String(localized: "Collation is required"), + sqlState: nil, + detail: nil + ) + } + guard Self.supportedCollations.contains(collate) else { + throw LibPQPluginError( + message: String(format: String(localized: "Invalid collation: %@"), collate), + sqlState: nil, + detail: nil ) - let isValidCollation = collation.unicodeScalars.allSatisfy { allowedCollationChars.contains($0) } - guard isValidCollation else { - throw LibPQPluginError(message: "Invalid collation", sqlState: nil, detail: nil) - } - let escapedCollation = collation.replacingOccurrences(of: "'", with: "''") - query += " LC_COLLATE '\(escapedCollation)'" } - _ = try await execute(query: query) + + let quotedName = request.name.replacingOccurrences(of: "\"", with: "\"\"") + let sql = "CREATE DATABASE \"\(quotedName)\" COLLATE \(collate)" + _ = try await execute(query: sql) } func dropDatabase(name: String) async throws { diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index e5b18002f..a010dfca7 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -19,7 +19,7 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) TableProPluginKitVersion - 7 + 8 NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 67223d5b9..fe9ab2a61 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -347,10 +347,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginDatabaseMetadata(name: dbName, tableCount: keyCount) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - throw NSError(domain: "RedisDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "Redis databases are pre-allocated"]) - } - // MARK: - Schema Support var supportsSchemas: Bool { false } diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 2d70e38d0..6f34f75a0 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -832,10 +832,6 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { PluginDatabaseMetadata(name: database) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - throw SQLitePluginError.unsupportedOperation - } - // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift b/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift new file mode 100644 index 000000000..af8dfa286 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct PluginCreateDatabaseFormSpec: Sendable { + public struct Option: Sendable, Hashable { + public let value: String + public let label: String + public let subtitle: String? + public let group: String? + + public init(value: String, label: String, subtitle: String? = nil, group: String? = nil) { + self.value = value + self.label = label + self.subtitle = subtitle + self.group = group + } + } + + public enum FieldKind: Sendable { + case picker(options: [Option], defaultValue: String?) + case searchable(options: [Option], defaultValue: String?) + } + + public struct Visibility: Sendable { + public let fieldId: String + public let equals: String + + public init(fieldId: String, equals: String) { + self.fieldId = fieldId + self.equals = equals + } + } + + public struct Field: Sendable { + public let id: String + public let label: String + public let kind: FieldKind + public let visibleWhen: Visibility? + public let groupedBy: String? + + public init( + id: String, + label: String, + kind: FieldKind, + visibleWhen: Visibility? = nil, + groupedBy: String? = nil + ) { + self.id = id + self.label = label + self.kind = kind + self.visibleWhen = visibleWhen + self.groupedBy = groupedBy + } + } + + public let fields: [Field] + public let footnote: String? + + public init(fields: [Field], footnote: String? = nil) { + self.fields = fields + self.footnote = footnote + } +} + +public struct PluginCreateDatabaseRequest: Sendable { + public let name: String + public let values: [String: String] + + public init(name: String, values: [String: String]) { + self.name = name + self.values = values + } +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 66f0d484b..e7ae3ee3c 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -77,7 +77,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] - func createDatabase(name: String, charset: String, collation: String?) async throws + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws func dropDatabase(name: String) async throws func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult @@ -222,8 +223,14 @@ public extension PluginDatabaseDriver { func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] { [] } func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { [] } - func createDatabase(name: String, charset: String, collation: String?) async throws { - throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "createDatabase not supported"]) + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { nil } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + throw NSError( + domain: "PluginDatabaseDriver", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Create database is not supported by this driver"] + ) } func dropDatabase(name: String) async throws { diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index 1b09605ab..3cd90bcf7 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 7 + 8 diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 74488d1db..146927cab 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -130,10 +130,10 @@ protocol DatabaseDriver: AnyObject { /// Default implementation falls back to per-database calls. func fetchAllDatabaseMetadata() async throws -> [DatabaseMetadata] - /// Create a new database - func createDatabase(name: String, charset: String, collation: String?) async throws + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? + + func createDatabase(_ request: CreateDatabaseRequest) async throws - /// Drop a database func dropDatabase(name: String) async throws // MARK: - Maintenance diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 7a0591c9b..d8e3cb35d 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -301,8 +301,14 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { ) } - func createDatabase(name: String, charset: String, collation: String?) async throws { - try await pluginDriver.createDatabase(name: name, charset: charset, collation: collation) + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { + guard let pluginSpec = try await pluginDriver.createDatabaseFormSpec() else { return nil } + return mapFormSpec(pluginSpec) + } + + func createDatabase(_ request: CreateDatabaseRequest) async throws { + let pluginRequest = PluginCreateDatabaseRequest(name: request.name, values: request.values) + try await pluginDriver.createDatabase(pluginRequest) } func dropDatabase(name: String) async throws { @@ -560,3 +566,44 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { return result } } + +private extension PluginDriverAdapter { + func mapFormSpec(_ spec: PluginCreateDatabaseFormSpec) -> CreateDatabaseFormSpec { + CreateDatabaseFormSpec( + fields: spec.fields.map(mapFormField), + footnote: spec.footnote + ) + } + + func mapFormField(_ field: PluginCreateDatabaseFormSpec.Field) -> CreateDatabaseFormSpec.Field { + CreateDatabaseFormSpec.Field( + id: field.id, + label: field.label, + kind: mapFieldKind(field.kind), + visibleWhen: field.visibleWhen.map(mapVisibility), + groupedBy: field.groupedBy + ) + } + + func mapFieldKind(_ kind: PluginCreateDatabaseFormSpec.FieldKind) -> CreateDatabaseFormSpec.FieldKind { + switch kind { + case .picker(let options, let defaultValue): + return .picker(options: options.map(mapOption), defaultValue: defaultValue) + case .searchable(let options, let defaultValue): + return .searchable(options: options.map(mapOption), defaultValue: defaultValue) + } + } + + func mapOption(_ option: PluginCreateDatabaseFormSpec.Option) -> CreateDatabaseFormSpec.Option { + CreateDatabaseFormSpec.Option( + value: option.value, + label: option.label, + subtitle: option.subtitle, + group: option.group + ) + } + + func mapVisibility(_ visibility: PluginCreateDatabaseFormSpec.Visibility) -> CreateDatabaseFormSpec.Visibility { + CreateDatabaseFormSpec.Visibility(fieldId: visibility.fieldId, equals: visibility.equals) + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index e0984be83..32cfa1a3b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -12,7 +12,7 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() - static let currentPluginKitVersion = 7 + static let currentPluginKitVersion = 8 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" diff --git a/TablePro/Models/Schema/CreateDatabaseFormSpec.swift b/TablePro/Models/Schema/CreateDatabaseFormSpec.swift new file mode 100644 index 000000000..659e3481e --- /dev/null +++ b/TablePro/Models/Schema/CreateDatabaseFormSpec.swift @@ -0,0 +1,36 @@ +import Foundation + +internal struct CreateDatabaseFormSpec: Sendable { + internal struct Option: Sendable, Hashable { + internal let value: String + internal let label: String + internal let subtitle: String? + internal let group: String? + } + + internal enum FieldKind: Sendable { + case picker(options: [Option], defaultValue: String?) + case searchable(options: [Option], defaultValue: String?) + } + + internal struct Visibility: Sendable { + internal let fieldId: String + internal let equals: String + } + + internal struct Field: Sendable, Identifiable { + internal let id: String + internal let label: String + internal let kind: FieldKind + internal let visibleWhen: Visibility? + internal let groupedBy: String? + } + + internal let fields: [Field] + internal let footnote: String? +} + +internal struct CreateDatabaseRequest: Sendable { + internal let name: String + internal let values: [String: String] +} diff --git a/TablePro/Models/Schema/CreateDatabaseOptions.swift b/TablePro/Models/Schema/CreateDatabaseOptions.swift deleted file mode 100644 index 1d9ff1f27..000000000 --- a/TablePro/Models/Schema/CreateDatabaseOptions.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// CreateDatabaseOptions.swift -// TablePro -// -// Database-type-specific options for CREATE DATABASE dialog. -// - -import Foundation - -struct CreateDatabaseOptions { - struct Config { - let charsetLabel: String - let collationLabel: String - let defaultCharset: String - let defaultCollation: String - let charsets: [String] - let collations: [String: [String]] - let showOptions: Bool - } - - static func config(for type: DatabaseType) -> Config { - if type == .mysql || type == .mariadb { - return Config( - charsetLabel: "Character Set", - collationLabel: "Collation", - defaultCharset: "utf8mb4", - defaultCollation: "utf8mb4_unicode_ci", - charsets: CreateTableOptions.charsets, - collations: CreateTableOptions.collations, - showOptions: true - ) - } else if type == .postgresql || type == .redshift { - return Config( - charsetLabel: "Encoding", - collationLabel: "LC_COLLATE", - defaultCharset: "UTF8", - defaultCollation: "en_US.UTF-8", - charsets: postgresqlEncodings, - collations: postgresqlLocales, - showOptions: true - ) - } else { - return Config( - charsetLabel: "", - collationLabel: "", - defaultCharset: "", - defaultCollation: "", - charsets: [], - collations: [:], - showOptions: false - ) - } - } - - private static let postgresqlEncodings = [ - "UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP", - "EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK" - ] - - // PostgreSQL LC_COLLATE is OS-locale based, not encoding-dependent - private static let localeOptions = ["en_US.UTF-8", "C", "POSIX", "C.UTF-8"] - - private static let postgresqlLocales: [String: [String]] = { - var result: [String: [String]] = [:] - for enc in postgresqlEncodings { - result[enc] = localeOptions - } - return result - }() -} diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index fbad0b3d9..ef76545d0 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -131,13 +131,19 @@ final class DatabaseSwitcherViewModel { await fetchDatabases() } - /// Create a new database - func createDatabase(name: String, charset: String, collation: String?) async throws { + func loadCreateDatabaseForm() async throws -> CreateDatabaseFormSpec? { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { throw DatabaseError.notConnected } + return try await driver.createDatabaseFormSpec() + } - try await driver.createDatabase(name: name, charset: charset, collation: collation) + func createDatabase(name: String, values: [String: String]) async throws { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + let request = CreateDatabaseRequest(name: name, values: values) + try await driver.createDatabase(request) } /// Drop a database diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index c8c1c2e97..ce162c872 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -1,10 +1,3 @@ -// -// CreateDatabaseSheet.swift -// TablePro -// -// Sheet for creating a new database with charset and collation options. -// - import SwiftUI struct CreateDatabaseSheet: View { @@ -13,133 +6,261 @@ struct CreateDatabaseSheet: View { let databaseType: DatabaseType let viewModel: DatabaseSwitcherViewModel + @State private var loadState: LoadState = .loading @State private var databaseName = "" - @State private var charset: String - @State private var collation: String + @State private var values: [String: String] = [:] + @State private var groupSourceFieldIds: Set = [] @State private var isCreating = false @State private var errorMessage: String? - private let config: CreateDatabaseOptions.Config - - init(databaseType: DatabaseType, viewModel: DatabaseSwitcherViewModel) { - self.databaseType = databaseType - self.viewModel = viewModel - let cfg = CreateDatabaseOptions.config(for: databaseType) - self.config = cfg - self._charset = State(initialValue: cfg.defaultCharset) - self._collation = State(initialValue: cfg.defaultCollation) + private enum LoadState { + case loading + case ready(CreateDatabaseFormSpec) + case unsupported + case failed(String) } var body: some View { VStack(spacing: 0) { - // Header - Text("Create Database") - .font(.body.weight(.semibold)) - .padding(.vertical, 12) - + header Divider() + formBody + Divider() + footer + } + .frame(width: 380) + .onExitCommand { + if !isCreating { + dismiss() + } + } + .task { await load() } + } - // Form - VStack(alignment: .leading, spacing: 16) { - // Database name - VStack(alignment: .leading, spacing: 6) { - Text("Database Name") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - - TextField("Enter database name", text: $databaseName) - .textFieldStyle(.roundedBorder) - .font(.body) - } + private var header: some View { + Text(String(localized: "Create Database")) + .font(.body.weight(.semibold)) + .padding(.vertical, 12) + } - if config.showOptions { - // Charset / Encoding - VStack(alignment: .leading, spacing: 6) { - Text(config.charsetLabel) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - - Picker("", selection: $charset) { - ForEach(config.charsets, id: \.self) { cs in - Text(cs).tag(cs) - } - } - .labelsHidden() - .pickerStyle(.menu) - .font(.body) - } - - // Collation / LC_COLLATE - VStack(alignment: .leading, spacing: 6) { - Text(config.collationLabel) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - - Picker("", selection: $collation) { - ForEach(config.collations[charset] ?? [], id: \.self) { col in - Text(col).tag(col) - } - } - .labelsHidden() - .pickerStyle(.menu) - .font(.body) - } - } + private var formBody: some View { + VStack(alignment: .leading, spacing: 16) { + nameField - // Error message - if let error = errorMessage { - Text(error) + switch loadState { + case .loading: + loadingView + case .ready(let spec): + fieldsList(spec: spec) + if let footnote = spec.footnote { + Text(footnote) .font(.subheadline) - .foregroundStyle(Color(nsColor: .systemRed)) + .foregroundStyle(.secondary) } + case .unsupported: + Text(String(localized: "This engine does not support creating databases.")) + .font(.subheadline) + .foregroundStyle(.secondary) + case .failed(let message): + failureView(message: message) } - .padding(20) - Divider() + if let error = errorMessage { + Text(error) + .font(.subheadline) + .foregroundStyle(Color(nsColor: .systemRed)) + } + } + .padding(20) + } - // Footer - HStack { - Button("Cancel") { - dismiss() - } + private var nameField: some View { + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "Database Name")) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + + TextField(String(localized: "Enter database name"), text: $databaseName) + .textFieldStyle(.roundedBorder) + .font(.body) + } + } - Spacer() + private var loadingView: some View { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.7) + Text(String(localized: "Loading options...")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private func failureView(message: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Failed to load options")) + .font(.subheadline.weight(.medium)) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + Button(String(localized: "Retry")) { + Task { await load() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func fieldsList(spec: CreateDatabaseFormSpec) -> some View { + ForEach(visibleFields(in: spec)) { field in + fieldView(field: field, spec: spec) + } + } + + private func fieldView(field: CreateDatabaseFormSpec.Field, spec: CreateDatabaseFormSpec) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(field.label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + + picker(for: field, spec: spec) + .labelsHidden() + .pickerStyle(.menu) + .font(.body) + } + } - Button(isCreating ? String(localized: "Creating...") : String(localized: "Create")) { - createDatabase() + private func picker(for field: CreateDatabaseFormSpec.Field, spec: CreateDatabaseFormSpec) -> some View { + let binding = Binding( + get: { values[field.id] ?? "" }, + set: { newValue in + values[field.id] = newValue + if groupSourceFieldIds.contains(field.id) { + resetGroupedFields(after: field.id, in: spec) } - .buttonStyle(.borderedProminent) - .disabled(databaseName.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: []) } - .padding(12) + ) + let options = filteredOptions(for: field) + return Picker("", selection: binding) { + ForEach(options, id: \.value) { option in + Text(displayLabel(for: option)).tag(option.value) + } } - .frame(width: 380) - .onExitCommand { - if !isCreating { + } + + private var footer: some View { + HStack { + Button(String(localized: "Cancel")) { dismiss() } + + Spacer() + + Button(isCreating ? String(localized: "Creating...") : String(localized: "Create")) { + submit() + } + .buttonStyle(.borderedProminent) + .disabled(!canSubmit) + .keyboardShortcut(.return, modifiers: []) + } + .padding(12) + } + + private var canSubmit: Bool { + guard !databaseName.isEmpty, !isCreating else { return false } + if case .ready = loadState { return true } + return false + } + + private func visibleFields(in spec: CreateDatabaseFormSpec) -> [CreateDatabaseFormSpec.Field] { + spec.fields.filter(isVisible(_:)) + } + + private func isVisible(_ field: CreateDatabaseFormSpec.Field) -> Bool { + guard let visibility = field.visibleWhen else { return true } + return values[visibility.fieldId] == visibility.equals + } + + private func filteredOptions(for field: CreateDatabaseFormSpec.Field) -> [CreateDatabaseFormSpec.Option] { + let allOptions = options(from: field.kind) + guard allOptions.contains(where: { $0.group != nil }) else { return allOptions } + guard let sourceId = field.groupedBy, + let groupValue = values[sourceId] else { + return allOptions + } + return allOptions.filter { $0.group == groupValue } + } + + private func resetGroupedFields(after sourceId: String, in spec: CreateDatabaseFormSpec) { + for field in spec.fields where field.groupedBy == sourceId { + values[field.id] = defaultValue(from: field.kind) ?? "" + } + } + + private func options(from kind: CreateDatabaseFormSpec.FieldKind) -> [CreateDatabaseFormSpec.Option] { + switch kind { + case .picker(let options, _), .searchable(let options, _): + return options + } + } + + private func defaultValue(from kind: CreateDatabaseFormSpec.FieldKind) -> String? { + switch kind { + case .picker(_, let defaultValue), .searchable(_, let defaultValue): + return defaultValue + } + } + + private func displayLabel(for option: CreateDatabaseFormSpec.Option) -> String { + guard let subtitle = option.subtitle, !subtitle.isEmpty else { return option.label } + return "\(option.label) (\(subtitle))" + } + + private func load() async { + loadState = .loading + errorMessage = nil + do { + guard let spec = try await viewModel.loadCreateDatabaseForm() else { + loadState = .unsupported + return + } + initializeValues(from: spec) + loadState = .ready(spec) + } catch { + loadState = .failed(error.localizedDescription) } - .onChange(of: charset) { _, newCharset in - if let firstCollation = config.collations[newCharset]?.first { - collation = firstCollation + } + + private func initializeValues(from spec: CreateDatabaseFormSpec) { + var initial: [String: String] = [:] + var sources: Set = [] + for field in spec.fields { + if let defaultValue = defaultValue(from: field.kind) { + initial[field.id] = defaultValue + } + if let sourceId = field.groupedBy { + sources.insert(sourceId) } } + values = initial + groupSourceFieldIds = sources } - private func createDatabase() { - guard !databaseName.isEmpty else { return } + private func submit() { + guard canSubmit else { return } + guard case .ready(let spec) = loadState else { return } isCreating = true errorMessage = nil let name = databaseName - let cs = config.showOptions ? charset : "" - let col: String? = config.showOptions ? collation : nil + let submissionValues = values.filter { entry in + spec.fields.first { $0.id == entry.key } + .map { isVisible($0) } ?? false + } Task { do { - try await viewModel.createDatabase(name: name, charset: cs, collation: col) + try await viewModel.createDatabase(name: name, values: submissionValues) await viewModel.refreshDatabases() dismiss() } catch { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 14a4f8f5b..1abe6c7fb 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -25,6 +25,7 @@ struct DatabaseSwitcherSheet: View { @State private var showCreateDialog = false @State private var showDropDialog = false @State private var databaseToDrop: String? + @State private var supportsCreateDatabase = false private enum FocusField { case databaseList @@ -112,6 +113,7 @@ struct DatabaseSwitcherSheet: View { : String(localized: "Open Database")) .background(Color(nsColor: .windowBackgroundColor)) .task { await viewModel.fetchDatabases() } + .task { await refreshCreateSupport() } .sheet(isPresented: $showCreateDialog) { CreateDatabaseSheet(databaseType: databaseType, viewModel: viewModel) } @@ -171,10 +173,7 @@ struct DatabaseSwitcherSheet: View { .buttonStyle(.borderless) .help(String(localized: "Refresh database list")) - // Create (only for non-SQLite) - if databaseType != .sqlite && databaseType != .redis - && databaseType != .etcd && !isSchemaMode - { + if !isSchemaMode && supportsCreateDatabase { Button(action: { showCreateDialog = true }) { Image(systemName: "plus") .frame(width: 24, height: 24) @@ -401,6 +400,16 @@ struct DatabaseSwitcherSheet: View { // MARK: - Actions + private func refreshCreateSupport() async { + guard !isSchemaMode else { return } + do { + let spec = try await viewModel.loadCreateDatabaseForm() + supportsCreateDatabase = spec != nil + } catch { + supportsCreateDatabase = false + } + } + private func openSelectedDatabase() { guard let database = viewModel.selectedDatabase else { return } From 12216ff7b3944f0805cac330256829cd98a0b46b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 19:46:22 +0700 Subject: [PATCH 2/5] fix(mysql): expose logger to extension file --- Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index d502e115b..fbecc7911 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -18,7 +18,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// Detected server type from version string after connecting private var isMariaDB = false - private static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") + internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") var currentSchema: String? { nil } var serverVersion: String? { _serverVersion } From c25a8a797cb97c02a8ebefa4ea407f134d6ababb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 19:52:13 +0700 Subject: [PATCH 3/5] fix(create-database): show button in PG and remove duplicate parens --- TablePro/Resources/Localizable.xcstrings | 9 +++++++++ .../Views/DatabaseSwitcher/CreateDatabaseSheet.swift | 2 +- .../Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift | 1 - 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 17d2330b3..1dfd89061 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -17629,6 +17629,9 @@ } } } + }, + "Failed to load options" : { + }, "Failed to load plugin registry" : { "extractionState" : "stale", @@ -23801,6 +23804,9 @@ } } } + }, + "Loading options..." : { + }, "Loading plugins..." : { "extractionState" : "stale", @@ -40810,6 +40816,9 @@ } } } + }, + "This engine does not support creating databases." : { + }, "This file is encrypted" : { "localizations" : { diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index ce162c872..fbb8b89b4 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -212,7 +212,7 @@ struct CreateDatabaseSheet: View { private func displayLabel(for option: CreateDatabaseFormSpec.Option) -> String { guard let subtitle = option.subtitle, !subtitle.isEmpty else { return option.label } - return "\(option.label) (\(subtitle))" + return "\(option.label) \(subtitle)" } private func load() async { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 1abe6c7fb..fcc7a800c 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -401,7 +401,6 @@ struct DatabaseSwitcherSheet: View { // MARK: - Actions private func refreshCreateSupport() async { - guard !isSchemaMode else { return } do { let spec = try await viewModel.loadCreateDatabaseForm() supportsCreateDatabase = spec != nil From b322169a932392678a709271fefb4b807adb2854 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 20:32:33 +0700 Subject: [PATCH 4/5] fix(create-database): default provider from template1 and fall back when picker default missing --- .../PostgreSQLPluginDriver.swift | 19 ++++++++++++++----- .../CreateDatabaseSheet.swift | 14 +++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 4b8df3f15..966e58a0d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -853,10 +853,11 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { PluginCreateDatabaseFormSpec.Option(value: "libc", label: "libc"), PluginCreateDatabaseFormSpec.Option(value: "icu", label: "icu") ] + let defaultProvider = templateDefaults?.provider == "i" ? "icu" : "libc" fields.append(PluginCreateDatabaseFormSpec.Field( id: "provider", label: String(localized: "Locale Provider"), - kind: .picker(options: providerOptions, defaultValue: "libc") + kind: .picker(options: providerOptions, defaultValue: defaultProvider) )) } @@ -1010,18 +1011,26 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return value } - private func fetchTemplate1Defaults() async -> (collate: String, ctype: String)? { + private struct Template1Defaults { + let collate: String + let ctype: String + let provider: String? + } + + private func fetchTemplate1Defaults() async -> Template1Defaults? { + let majorVersion = parsedServerMajorVersion() ?? 0 + let providerColumn = majorVersion >= 15 ? ", datlocprovider" : ", NULL" do { let result = try await execute( - query: "SELECT datcollate, datctype FROM pg_database WHERE datname = 'template1'" + query: "SELECT datcollate, datctype\(providerColumn) FROM pg_database WHERE datname = 'template1'" ) guard let row = result.rows.first, - row.count >= 2, + row.count >= 3, let collate = row[0], let ctype = row[1] else { return nil } - return (collate: collate, ctype: ctype) + return Template1Defaults(collate: collate, ctype: ctype, provider: row[2]) } catch { Self.logger.error( "Failed to read template1 defaults: \(error.localizedDescription, privacy: .public)" diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index fbb8b89b4..1310f3245 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -192,7 +192,12 @@ struct CreateDatabaseSheet: View { private func resetGroupedFields(after sourceId: String, in spec: CreateDatabaseFormSpec) { for field in spec.fields where field.groupedBy == sourceId { - values[field.id] = defaultValue(from: field.kind) ?? "" + let visible = filteredOptions(for: field).map(\.value) + if let preferred = defaultValue(from: field.kind), visible.contains(preferred) { + values[field.id] = preferred + } else { + values[field.id] = visible.first ?? "" + } } } @@ -234,8 +239,11 @@ struct CreateDatabaseSheet: View { var initial: [String: String] = [:] var sources: Set = [] for field in spec.fields { - if let defaultValue = defaultValue(from: field.kind) { - initial[field.id] = defaultValue + let optionValues = options(from: field.kind).map(\.value) + if let preferred = defaultValue(from: field.kind), optionValues.contains(preferred) { + initial[field.id] = preferred + } else if let first = optionValues.first { + initial[field.id] = first } if let sourceId = field.groupedBy { sources.insert(sourceId) From 0d51c228ffa9ba22ed95b221cfcb2622f1862cec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 20:50:09 +0700 Subject: [PATCH 5/5] fix(postgresql): preselect ICU locale from template1 and parallelize fetches --- .../PostgreSQLPluginDriver.swift | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 966e58a0d..f06849013 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -829,10 +829,12 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let majorVersion = parsedServerMajorVersion() let supportsProvider = (majorVersion ?? 0) >= 15 - let templateDefaults = await fetchTemplate1Defaults() + async let templateDefaultsTask = fetchTemplate1Defaults() + async let collationsTask = fetchCollations() + let templateDefaults = await templateDefaultsTask + let collations = await collationsTask let serverCollate = templateDefaults?.collate - - let collations = await fetchCollations() + let serverIcuLocale = templateDefaults?.iculocale let libcCollations = collations.libc let icuCollations = collations.icu @@ -880,13 +882,17 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { )) if supportsProvider { - let icuOptions = icuCollations.map { - PluginCreateDatabaseFormSpec.Option(value: $0, label: $0) + let icuOptions: [PluginCreateDatabaseFormSpec.Option] = icuCollations.map { name in + PluginCreateDatabaseFormSpec.Option( + value: name, + label: name, + subtitle: name == serverIcuLocale ? serverDefaultSubtitle : nil + ) } fields.append(PluginCreateDatabaseFormSpec.Field( id: "icu_locale", label: String(localized: "ICU Locale"), - kind: .searchable(options: icuOptions, defaultValue: nil), + kind: .searchable(options: icuOptions, defaultValue: serverIcuLocale), visibleWhen: PluginCreateDatabaseFormSpec.Visibility(fieldId: "provider", equals: "icu") )) } @@ -927,7 +933,9 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { detail: nil ) } - let allowedCollations = await fetchCollations().libc + async let allowedCollationsTask = fetchCollations().libc + async let templateDefaultsTask = fetchTemplate1Defaults() + let allowedCollations = await allowedCollationsTask guard allowedCollations.contains(collation) else { throw LibPQPluginError( message: String(format: String(localized: "Invalid collation: %@"), collation), @@ -938,7 +946,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let escapedCollation = escapeLiteral(collation) sql += " LC_COLLATE '\(escapedCollation)' LC_CTYPE '\(escapedCollation)'" - guard let templateDefaults = await fetchTemplate1Defaults() else { + guard let templateDefaults = await templateDefaultsTask else { throw LibPQPluginError( message: String(localized: "Failed to read template1 collation defaults"), sqlState: nil, @@ -1015,22 +1023,35 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let collate: String let ctype: String let provider: String? + let iculocale: String? } private func fetchTemplate1Defaults() async -> Template1Defaults? { let majorVersion = parsedServerMajorVersion() ?? 0 - let providerColumn = majorVersion >= 15 ? ", datlocprovider" : ", NULL" + let selectColumns: String + if majorVersion >= 17 { + selectColumns = "datcollate, datctype, datlocprovider, datlocale" + } else if majorVersion >= 15 { + selectColumns = "datcollate, datctype, datlocprovider, daticulocale" + } else { + selectColumns = "datcollate, datctype, NULL, NULL" + } do { let result = try await execute( - query: "SELECT datcollate, datctype\(providerColumn) FROM pg_database WHERE datname = 'template1'" + query: "SELECT \(selectColumns) FROM pg_database WHERE datname = 'template1'" ) guard let row = result.rows.first, - row.count >= 3, + row.count >= 4, let collate = row[0], let ctype = row[1] else { return nil } - return Template1Defaults(collate: collate, ctype: ctype, provider: row[2]) + return Template1Defaults( + collate: collate, + ctype: ctype, + provider: row[2], + iculocale: row[3] + ) } catch { Self.logger.error( "Failed to read template1 defaults: \(error.localizedDescription, privacy: .public)"