diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d339eb39..dd074f5f3 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
@@ -24,6 +25,8 @@ 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).
- DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention.
- Data grid column identifiers are now the column name (with positional fallback for duplicate names), so saved widths follow the column across schema changes that shift its position. Identifier resolution moved from static `DataGridView` helpers to a `ColumnIdentitySchema` value type owned by the coordinator.
- `ColumnLayoutStorage` singleton replaced by a `ColumnLayoutPersisting` protocol with an injectable `FileColumnLayoutPersister` default. The coordinator depends on the protocol, not the concrete class, so tests can substitute a fake.
@@ -67,7 +70,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
-- Using the template0 database to resolve database creation failures in PostgreSQL
+- 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..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 }
@@ -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 56cd3402c..f06849013 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
@@ -820,26 +820,182 @@ 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
+
+ async let templateDefaultsTask = fetchTemplate1Defaults()
+ async let collationsTask = fetchCollations()
+ let templateDefaults = await templateDefaultsTask
+ let collations = await collationsTask
+ let serverCollate = templateDefaults?.collate
+ let serverIcuLocale = templateDefaults?.iculocale
+ 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")
+ ]
+ let defaultProvider = templateDefaults?.provider == "i" ? "icu" : "libc"
+ fields.append(PluginCreateDatabaseFormSpec.Field(
+ id: "provider",
+ label: String(localized: "Locale Provider"),
+ kind: .picker(options: providerOptions, defaultValue: defaultProvider)
+ ))
+ }
+
+ 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: [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: serverIcuLocale),
+ visibleWhen: PluginCreateDatabaseFormSpec.Visibility(fieldId: "provider", equals: "icu")
+ ))
+ }
+
+ 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 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)
+ 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
+ )
+ }
+ 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),
+ sqlState: nil,
+ detail: nil
+ )
+ }
+ let escapedCollation = escapeLiteral(collation)
+ sql += " LC_COLLATE '\(escapedCollation)' LC_CTYPE '\(escapedCollation)'"
+
+ guard let templateDefaults = await templateDefaultsTask else {
+ throw LibPQPluginError(
+ message: String(localized: "Failed to read template1 collation defaults"),
+ sqlState: nil,
+ detail: nil
+ )
+ }
+ if templateDefaults.collate != collation {
+ sql += " TEMPLATE template0"
}
- let escapedCollation = collation.replacingOccurrences(of: "'", with: "''")
- query += " TEMPLATE 'template0' LC_COLLATE '\(escapedCollation)'"
+
+ 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: query)
+
+ _ = try await execute(query: sql)
}
func dropDatabase(name: String) async throws {
@@ -847,6 +1003,90 @@ 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 struct Template1Defaults {
+ let collate: String
+ let ctype: String
+ let provider: String?
+ let iculocale: String?
+ }
+
+ private func fetchTemplate1Defaults() async -> Template1Defaults? {
+ let majorVersion = parsedServerMajorVersion() ?? 0
+ 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 \(selectColumns) FROM pg_database WHERE datname = 'template1'"
+ )
+ guard let row = result.rows.first,
+ row.count >= 4,
+ let collate = row[0],
+ let ctype = row[1] else {
+ return nil
+ }
+ 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)"
+ )
+ 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/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/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..1310f3245 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,269 @@ 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
}
- .onChange(of: charset) { _, newCharset in
- if let firstCollation = config.collations[newCharset]?.first {
- collation = firstCollation
+ return allOptions.filter { $0.group == groupValue }
+ }
+
+ private func resetGroupedFields(after sourceId: String, in spec: CreateDatabaseFormSpec) {
+ for field in spec.fields where field.groupedBy == sourceId {
+ 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 ?? ""
}
}
}
- private func createDatabase() {
- guard !databaseName.isEmpty else { return }
+ 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)
+ }
+ }
+
+ private func initializeValues(from spec: CreateDatabaseFormSpec) {
+ var initial: [String: String] = [:]
+ var sources: Set = []
+ for field in spec.fields {
+ 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)
+ }
+ }
+ values = initial
+ groupSourceFieldIds = sources
+ }
+
+ 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..fcc7a800c 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,15 @@ struct DatabaseSwitcherSheet: View {
// MARK: - Actions
+ private func refreshCreateSupport() async {
+ do {
+ let spec = try await viewModel.loadCreateDatabaseForm()
+ supportsCreateDatabase = spec != nil
+ } catch {
+ supportsCreateDatabase = false
+ }
+ }
+
private func openSelectedDatabase() {
guard let database = viewModel.selectedDatabase else { return }