From 59733f7db92d12e00fd66f4409fcf2122a4b73f5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 19 Apr 2026 23:05:23 +0700 Subject: [PATCH 1/2] feat: drop database protocol, capability, and plugin implementations (#758) --- .../PluginDatabaseDriver.swift | 6 +++ .../BigQueryDriverPlugin/BigQueryPlugin.swift | 2 + .../BigQueryPluginDriver.swift | 6 +++ .../CassandraPlugin.swift | 7 +++ .../ClickHousePlugin.swift | 6 +++ .../CloudflareD1Plugin.swift | 2 + .../CloudflareD1PluginDriver.swift | 20 +++++++++ .../D1HttpClient.swift | 8 ++++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 7 +++ .../MongoDBDriverPlugin/MongoDBPlugin.swift | 2 + .../MongoDBPluginDriver.swift | 8 ++++ Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 2 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 5 +++ .../PostgreSQLPlugin.swift | 1 + .../PostgreSQLPluginDriver.swift | 5 +++ .../RedshiftPluginDriver.swift | 5 +++ Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../PluginDatabaseDriver.swift | 6 +++ TablePro/Core/Database/DatabaseDriver.swift | 8 ++++ .../Core/Plugins/PluginDriverAdapter.swift | 4 ++ .../Plugins/PluginManager+Registration.swift | 5 +++ ...PluginMetadataRegistry+CloudDefaults.swift | 6 ++- ...ginMetadataRegistry+RegistryDefaults.swift | 38 ++++++++++++---- .../Core/Plugins/PluginMetadataRegistry.swift | 44 ++++++++++++++++--- .../DatabaseSwitcherViewModel.swift | 9 ++++ 25 files changed, 196 insertions(+), 18 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift index 7e647bdbe..6643cbdfc 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift @@ -92,6 +92,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { 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 dropDatabase(name: String) async throws func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult // Query building (optional, for NoSQL plugins) @@ -268,6 +269,11 @@ public extension PluginDatabaseDriver { ) } + func dropDatabase(name: String) async throws { + throw NSError(domain: "PluginDatabaseDriver", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) + } + func switchDatabase(to database: String) async throws { throw NSError( domain: "TableProPluginKit", diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index d64bd400c..550b4b706 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -209,6 +209,8 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { ] } + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { BigQueryPluginDriver(config: config) } diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 40b9ba167..e45f65790 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -811,6 +811,12 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send _ = try await conn.executeQuery("CREATE SCHEMA `\(escaped)`") } + func dropDatabase(name: String) async throws { + guard let conn = connection else { throw BigQueryError.notConnected } + let escaped = name.replacingOccurrences(of: "`", with: "\\`") + _ = try await conn.executeQuery("DROP SCHEMA `\(escaped)`") + } + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { guard let conn = connection else { return nil } let dataset = lock.withLock { _currentDataset } ?? "" diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 9d70435ce..771bbeb36 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -104,6 +104,8 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { ) } + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { CassandraPluginDriver(config: config) } @@ -1244,6 +1246,11 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen _ = try await execute(query: query) } + func dropDatabase(name: String) async throws { + let safeKs = escapeIdentifier(name) + _ = try await execute(query: "DROP KEYSPACE \"\(safeKs)\"") + } + func switchDatabase(to database: String) async throws { try await connectionActor.switchKeyspace(database) stateLock.lock() diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 3d5cee3b0..edb7abdc3 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -53,6 +53,7 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] static let supportsQueryProgress = true + static let supportsDropDatabase = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "`", @@ -583,6 +584,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "CREATE DATABASE `\(escapedName)`") } + func dropDatabase(name: String) async throws { + let escapedName = name.replacingOccurrences(of: "`", with: "``") + _ = try await execute(query: "DROP DATABASE `\(escapedName)`") + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index f43235601..b6014ece5 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -99,6 +99,8 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { ) ] + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { CloudflareD1PluginDriver(config: config) } diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index d4cc53dca..aae530409 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -512,6 +512,26 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable lock.unlock() } + func dropDatabase(name: String) async throws { + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + lock.lock() + let uuid = databaseNameToUuid[name] + lock.unlock() + + guard let databaseId = uuid ?? (isUuid(name) ? name : nil) else { + throw CloudflareD1Error(message: String(format: String(localized: "Database '%@' not found"), name)) + } + + try await client.deleteDatabase(databaseId: databaseId) + + lock.lock() + databaseNameToUuid.removeValue(forKey: name) + lock.unlock() + } + func switchDatabase(to database: String) async throws { lock.lock() var uuid = databaseNameToUuid[database] diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index 8df400035..b821b98dd 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -260,6 +260,14 @@ final class D1HttpClient: @unchecked Sendable { return result } + func deleteDatabase(databaseId: String) async throws { + let url = try baseURL(databaseId: databaseId) + let data = try await performRequest(url: url, method: "DELETE", body: nil) + + let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: data) + try checkApiSuccess(envelope) + } + // MARK: - Private Helpers private func baseURL(databaseId: String?) throws -> URL { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 1b83f3d16..f2d88c8fc 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -93,6 +93,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { autoLimitStyle: .top ) + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MSSQLPluginDriver(config: config) } @@ -1362,6 +1364,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "CREATE DATABASE \(quotedName)") } + func dropDatabase(name: String) async throws { + let quotedName = "[\(name.replacingOccurrences(of: "]", with: "]]"))]" + _ = try await execute(query: "DROP DATABASE \(quotedName)") + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 97d201777..076626597 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -126,6 +126,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { ] } + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MongoDBPluginDriver(config: config) } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index e02e9c01d..77faf8066 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -457,6 +457,14 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { _ = try await conn.runCommand("{\"drop\": \"__tablepro_init\"}", database: name) } + func dropDatabase(name: String) async throws { + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + + _ = try await conn.runCommand("{\"dropDatabase\": 1}", database: name) + } + // MARK: - Database Switching func switchDatabase(to database: String) async throws { diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index b5a53ce93..d240a1876 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -93,6 +93,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { requiresBackslashEscaping: true ) + static let supportsDropDatabase = true + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MySQLPluginDriver(config: config) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 976c9142f..70535ac70 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -650,6 +650,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: query) } + func dropDatabase(name: String) async throws { + let escapedName = name.replacingOccurrences(of: "`", with: "``") + _ = try await execute(query: "DROP DATABASE `\(escapedName)`") + } + // MARK: - Database Switching func switchDatabase(to database: String) async throws { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 595d76a27..d761d0679 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -66,6 +66,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsForeignKeyDisable = false static let requiresReconnectForDatabaseSwitch = true static let parameterStyle: ParameterStyle = .dollar + static let supportsDropDatabase = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "\"", diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 9eb79ee0e..ae1e289fc 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -842,6 +842,11 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: query) } + func dropDatabase(name: String) async throws { + let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") + _ = try await execute(query: "DROP DATABASE \"\(escapedName)\"") + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 4ff3bbaa2..a2f1e38e3 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -687,6 +687,11 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: query) } + func dropDatabase(name: String) async throws { + let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") + _ = try await execute(query: "DROP DATABASE \"\(escapedName)\"") + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 466fd3246..341982829 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -54,6 +54,7 @@ public protocol DriverPlugin: TableProPlugin { static var isDownloadable: Bool { get } static var postConnectActions: [PostConnectAction] { get } static var parameterStyle: ParameterStyle { get } + static var supportsDropDatabase: Bool { get } } public extension DriverPlugin { @@ -114,4 +115,5 @@ public extension DriverPlugin { static var parameterStyle: ParameterStyle { .questionMark } static var isDownloadable: Bool { false } static var postConnectActions: [PostConnectAction] { [] } + static var supportsDropDatabase: Bool { false } } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 6db922581..2c5e70a22 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -78,6 +78,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { 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 dropDatabase(name: String) async throws func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult // Query building (optional, for NoSQL plugins) @@ -223,6 +224,11 @@ public extension PluginDatabaseDriver { throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "createDatabase not supported"]) } + func dropDatabase(name: String) async throws { + throw NSError(domain: "PluginDatabaseDriver", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) + } + func switchDatabase(to database: String) async throws { throw NSError( domain: "TableProPluginKit", diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 87c11ab5a..cbd491fc8 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -130,6 +130,9 @@ protocol DatabaseDriver: AnyObject { /// Create a new database func createDatabase(name: String, charset: String, collation: String?) async throws + /// Drop a database + func dropDatabase(name: String) async throws + // MARK: - Maintenance /// Returns the list of supported maintenance operations (e.g. "VACUUM", "ANALYZE"). @@ -233,6 +236,11 @@ extension DatabaseDriver { return true } + func dropDatabase(name: String) async throws { + throw NSError(domain: "DatabaseDriver", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) + } + /// Default fetchAllDatabaseMetadata: falls back to per-database calls (N+1). /// Drivers should override with a single bulk query where possible. func fetchAllDatabaseMetadata() async throws -> [DatabaseMetadata] { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 69fbf4aa7..20fdbc887 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -258,6 +258,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.createDatabase(name: name, charset: charset, collation: collation) } + func dropDatabase(name: String) async throws { + try await pluginDriver.dropDatabase(name: name) + } + // MARK: - Batch Operations func fetchAllColumns() async throws -> [String: [ColumnInfo]] { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index c945a6f0c..187397b44 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -366,6 +366,11 @@ extension PluginManager { .supportsColumnReorder ?? false } + func supportsDropDatabase(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsDropDatabase ?? false + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) else { return .limit diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 66957b520..1ce256172 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -31,7 +31,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "", @@ -172,7 +173,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 16e061415..afacce278 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -527,7 +527,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -598,7 +599,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -639,7 +641,19 @@ extension PluginMetadataRegistry { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: false, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "dbo", defaultGroupName: "main", @@ -685,7 +699,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -741,7 +756,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: true, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -817,7 +833,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -871,7 +888,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -924,7 +942,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -1006,7 +1025,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "main", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index d5c11e046..0e7269631 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -50,6 +50,7 @@ struct PluginMetadataSnapshot: Sendable { let supportsReadOnlyMode: Bool let supportsQueryProgress: Bool let requiresReconnectForDatabaseSwitch: Bool + let supportsDropDatabase: Bool static let defaults = CapabilityFlags( supportsSchemaSwitching: false, @@ -61,7 +62,8 @@ struct PluginMetadataSnapshot: Sendable { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ) } @@ -343,7 +345,19 @@ final class PluginMetadataRegistry: @unchecked Sendable { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: true, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", defaultGroupName: "main", @@ -373,7 +387,19 @@ final class PluginMetadataRegistry: @unchecked Sendable { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: true, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", defaultGroupName: "main", @@ -414,7 +440,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -458,7 +485,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -501,7 +529,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -660,7 +689,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: driverType.supportsForeignKeyDisable, supportsReadOnlyMode: driverType.supportsReadOnlyMode, supportsQueryProgress: driverType.supportsQueryProgress, - requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch + requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, + supportsDropDatabase: driverType.supportsDropDatabase ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index dda3f9218..fbad0b3d9 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -140,6 +140,15 @@ final class DatabaseSwitcherViewModel { try await driver.createDatabase(name: name, charset: charset, collation: collation) } + /// Drop a database + func dropDatabase(name: String) async throws { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + + try await driver.dropDatabase(name: name) + } + // MARK: - Keyboard Navigation func moveUp() { From f1fa8658243057b56f71b4b868991460213169d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 19 Apr 2026 23:07:28 +0700 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20drop=20database=20UI=20=E2=80=94=20?= =?UTF-8?q?confirmation=20dialog,=20context=20menu,=20toolbar=20button=20(?= =?UTF-8?q?#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../DatabaseSwitcherSheet.swift | 55 +++++++++++ .../DatabaseSwitcher/DropDatabaseSheet.swift | 97 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 666ab2ea6..89f6f8954 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 +- Drop database from the database switcher dialog (context menu, toolbar button, Delete key) - Structure tab: search, sort, count badges, PK column, Copy As (CSV/JSON/SQL), destructive change confirmation - Structure tab: DDL view with tree-sitter highlighting, line numbers, and "Open in Editor" - Structure tab: charset/collation (MySQL), index prefix length, partial indexes (PostgreSQL), cross-schema FK diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 977416df2..3c0839ec4 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -23,6 +23,8 @@ struct DatabaseSwitcherSheet: View { @State private var viewModel: DatabaseSwitcherViewModel @State private var showCreateDialog = false + @State private var showDropDialog = false + @State private var databaseToDrop: String? private enum FocusField { case search @@ -118,6 +120,13 @@ struct DatabaseSwitcherSheet: View { .sheet(isPresented: $showCreateDialog) { CreateDatabaseSheet(databaseType: databaseType, viewModel: viewModel) } + .sheet(isPresented: $showDropDialog) { + if let name = databaseToDrop { + DropDatabaseSheet(databaseName: name, viewModel: viewModel) { + databaseToDrop = nil + } + } + } .onExitCommand { // SwiftUI handles sheet priority automatically - no nested sheets take precedence dismiss() @@ -144,6 +153,11 @@ struct DatabaseSwitcherSheet: View { viewModel.moveUp() return .handled } + .onKeyPress(.delete) { + guard canDropSelected else { return .ignored } + initiateDropForSelected() + return .handled + } } // MARK: - Toolbar @@ -180,6 +194,17 @@ struct DatabaseSwitcherSheet: View { .buttonStyle(.borderless) .help(String(localized: "Create new database")) } + + // Drop + if !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) { + Button(action: { initiateDropForSelected() }) { + Image(systemName: "trash") + .frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + .disabled(!canDropSelected) + .help(String(localized: "Drop selected database")) + } } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -258,6 +283,18 @@ struct DatabaseSwitcherSheet: View { openSelectedDatabase() } ) + .contextMenu { + if !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) + && !database.isSystemDatabase && database.name != activeName + { + Button(role: .destructive) { + databaseToDrop = database.name + showDropDialog = true + } label: { + Label(String(localized: "Drop Database..."), systemImage: "trash") + } + } + } } // MARK: - Empty States @@ -370,6 +407,24 @@ struct DatabaseSwitcherSheet: View { .padding(12) } + // MARK: - Drop Helpers + + private var canDropSelected: Bool { + guard !isSchemaMode, + PluginManager.shared.supportsDropDatabase(for: databaseType), + let selected = viewModel.selectedDatabase, + selected != activeName + else { return false } + let isSystem = viewModel.filteredDatabases.first { $0.name == selected }?.isSystemDatabase ?? false + return !isSystem + } + + private func initiateDropForSelected() { + guard canDropSelected, let selected = viewModel.selectedDatabase else { return } + databaseToDrop = selected + showDropDialog = true + } + // MARK: - Actions private func openSelectedDatabase() { diff --git a/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift new file mode 100644 index 000000000..6fbe62921 --- /dev/null +++ b/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift @@ -0,0 +1,97 @@ +// +// DropDatabaseSheet.swift +// TablePro +// +// Confirmation dialog for dropping a database. +// + +import SwiftUI + +struct DropDatabaseSheet: View { + @Environment(\.dismiss) private var dismiss + + let databaseName: String + let viewModel: DatabaseSwitcherViewModel + let onDropped: () -> Void + + @State private var isDropping = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Drop Database") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) + .padding(.vertical, 12) + + Divider() + + // Content + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 36)) + .foregroundStyle(.red) + + Text(String(format: String(localized: "Drop database '%@'?"), databaseName)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) + .multilineTextAlignment(.center) + + Text("All tables and data will be permanently deleted.") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if let error = errorMessage { + Text(error) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(Color(nsColor: .systemRed)) + .multilineTextAlignment(.center) + } + } + .padding(20) + + Divider() + + // Footer + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(isDropping ? String(localized: "Dropping...") : String(localized: "Drop")) { + dropDatabase() + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isDropping) + } + .padding(12) + } + .frame(width: 340) + .onExitCommand { + if !isDropping { + dismiss() + } + } + } + + private func dropDatabase() { + isDropping = true + errorMessage = nil + + Task { + do { + try await viewModel.dropDatabase(name: databaseName) + await viewModel.refreshDatabases() + onDropped() + dismiss() + } catch { + errorMessage = error.localizedDescription + isDropping = false + } + } + } +}