From 53a9ae0fbcae04f8c6aa748f45c55f24383358f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 10:39:31 +0700 Subject: [PATCH 1/3] fix: add SQL fallbacks for DROP/TRUNCATE and fix MySQL FK metadata after db switch --- CHANGELOG.md | 1 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 11 +- .../Core/Plugins/PluginDriverAdapter.swift | 26 +++- ...inContentCoordinator+TableOperations.swift | 22 +-- .../PluginDriverAdapterTableOpsTests.swift | 137 ++++++++++++++++++ 5 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c71ef8ff9..fc6ea15d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection test not cleaning up SSH tunnel on completion - Test connection success indicator not resetting after field changes - SSH port field accepting invalid values +- DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers ## [0.19.1] - 2026-03-16 diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 47f566428..b94123a2d 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -13,6 +13,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private var mariadbConnection: MariaDBPluginConnection? private var _serverVersion: String? + private var _activeDatabase: String /// Detected server type from version string after connecting private var isMariaDB = false @@ -49,6 +50,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { init(config: DriverConnectionConfig) { self.config = config + self._activeDatabase = config.database } // MARK: - Connection @@ -223,7 +225,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let dbName = config.database + let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let query = """ SELECT @@ -310,7 +312,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - let dbName = config.database + let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let escapedTable = table.replacingOccurrences(of: "'", with: "''") @@ -351,7 +353,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { - let dbName = config.database + let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let query = """ @@ -394,7 +396,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { - let dbName = config.database + let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let escapedTable = table.replacingOccurrences(of: "'", with: "''") @@ -578,6 +580,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func switchDatabase(to database: String) async throws { let escaped = database.replacingOccurrences(of: "`", with: "``") _ = try await execute(query: "USE `\(escaped)`") + _activeDatabase = database } // MARK: - Query Timeout diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 2eed601ac..3c1dc99a1 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -334,12 +334,22 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { // MARK: - Table Operations - func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { - pluginDriver.truncateTableStatements(table: table, schema: schema, cascade: cascade) + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] { + if let stmts = pluginDriver.truncateTableStatements(table: table, schema: schema, cascade: cascade) { + return stmts + } + let name = qualifiedName(table, schema: schema) + let cascadeSuffix = cascade ? " CASCADE" : "" + return ["TRUNCATE TABLE \(name)\(cascadeSuffix)"] } - func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { - pluginDriver.dropObjectStatement(name: name, objectType: objectType, schema: schema, cascade: cascade) + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String { + if let stmt = pluginDriver.dropObjectStatement(name: name, objectType: objectType, schema: schema, cascade: cascade) { + return stmt + } + let qualName = qualifiedName(name, schema: schema) + let cascadeSuffix = cascade ? " CASCADE" : "" + return "DROP \(objectType) \(qualName)\(cascadeSuffix)" } func foreignKeyDisableStatements() -> [String]? { @@ -386,6 +396,14 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.escapeStringLiteral(value) } + // MARK: - Private Helpers + + private func qualifiedName(_ name: String, schema: String?) -> String { + let quoted = pluginDriver.quoteIdentifier(name) + guard let schema, !schema.isEmpty else { return quoted } + return "\(pluginDriver.quoteIdentifier(schema)).\(quoted)" + } + // MARK: - Result Mapping private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 7ee801055..0b21bcf39 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -105,13 +105,10 @@ extension MainContentCoordinator { private func truncateStatements( tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType ) -> [String] { - guard let adapter = currentPluginDriverAdapter, - let stmts = adapter.truncateTableStatements( - table: tableName, schema: nil, cascade: options.cascade - ) else { - return [] - } - return stmts + guard let adapter = currentPluginDriverAdapter else { return [] } + return adapter.truncateTableStatements( + table: tableName, schema: nil, cascade: options.cascade + ) } private func dropTableStatement( @@ -119,12 +116,9 @@ extension MainContentCoordinator { options: TableOperationOptions, dbType: DatabaseType ) -> String { let keyword = isView ? "VIEW" : "TABLE" - guard let adapter = currentPluginDriverAdapter, - let stmt = adapter.dropObjectStatement( - name: tableName, objectType: keyword, schema: nil, cascade: options.cascade - ) else { - return "" - } - return stmt + guard let adapter = currentPluginDriverAdapter else { return "" } + return adapter.dropObjectStatement( + name: tableName, objectType: keyword, schema: nil, cascade: options.cascade + ) } } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift new file mode 100644 index 000000000..500465ea6 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift @@ -0,0 +1,137 @@ +// +// PluginDriverAdapterTableOpsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class StubTableOpsDriver: PluginDatabaseDriver { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + var truncateOverride: ((String, String?, Bool) -> [String]?)? + var dropOverride: ((String, String, String?, Bool) -> String?)? + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + truncateOverride?(table, schema, cascade) + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + dropOverride?(name, objectType, schema, cascade) + } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchRowCount(query: String) async throws -> Int { 0 } + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("PluginDriverAdapter table operations") +struct PluginDriverAdapterTableOpsTests { + private func makeAdapter(driver: StubTableOpsDriver) -> PluginDriverAdapter { + let connection = DatabaseConnection(name: "Test", type: .postgresql) + return PluginDriverAdapter(connection: connection, pluginDriver: driver) + } + + // MARK: - dropObjectStatement + + @Test("Fallback produces DROP TABLE with quoted name") + func dropTableFallback() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement(name: "users", objectType: "TABLE", schema: nil, cascade: false) + #expect(result == "DROP TABLE \"users\"") + } + + @Test("Fallback produces DROP VIEW for views") + func dropViewFallback() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement(name: "active_users", objectType: "VIEW", schema: nil, cascade: false) + #expect(result == "DROP VIEW \"active_users\"") + } + + @Test("Fallback appends CASCADE when requested") + func dropWithCascade() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement(name: "orders", objectType: "TABLE", schema: nil, cascade: true) + #expect(result == "DROP TABLE \"orders\" CASCADE") + } + + @Test("Fallback includes schema qualification") + func dropWithSchema() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement(name: "users", objectType: "TABLE", schema: "public", cascade: false) + #expect(result == "DROP TABLE \"public\".\"users\"") + } + + @Test("Plugin override is returned when non-nil") + func dropPluginOverride() { + let driver = StubTableOpsDriver() + driver.dropOverride = { name, objectType, _, _ in + "DROP \(objectType) IF EXISTS `\(name)`" + } + let adapter = makeAdapter(driver: driver) + let result = adapter.dropObjectStatement(name: "users", objectType: "TABLE", schema: nil, cascade: false) + #expect(result == "DROP TABLE IF EXISTS `users`") + } + + // MARK: - truncateTableStatements + + @Test("Fallback produces TRUNCATE TABLE with quoted name") + func truncateFallback() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.truncateTableStatements(table: "users", schema: nil, cascade: false) + #expect(result == ["TRUNCATE TABLE \"users\""]) + } + + @Test("Fallback appends CASCADE when requested") + func truncateWithCascade() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.truncateTableStatements(table: "orders", schema: nil, cascade: true) + #expect(result == ["TRUNCATE TABLE \"orders\" CASCADE"]) + } + + @Test("Fallback includes schema qualification") + func truncateWithSchema() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.truncateTableStatements(table: "users", schema: "public", cascade: false) + #expect(result == ["TRUNCATE TABLE \"public\".\"users\""]) + } + + @Test("Plugin override is returned when non-nil") + func truncatePluginOverride() { + let driver = StubTableOpsDriver() + driver.truncateOverride = { table, _, _ in + ["DELETE FROM `\(table)`", "ALTER TABLE `\(table)` AUTO_INCREMENT = 1"] + } + let adapter = makeAdapter(driver: driver) + let result = adapter.truncateTableStatements(table: "users", schema: nil, cascade: false) + #expect(result == ["DELETE FROM `users`", "ALTER TABLE `users` AUTO_INCREMENT = 1"]) + } +} From c71a9abda436e586f7948b5f9d658a1e4d2a41bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 10:54:24 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20add?= =?UTF-8?q?=20MySQL=20FK=20changelog=20entry,=20remove=20unused=20quotedNa?= =?UTF-8?q?me=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../MainContentCoordinator+TableOperations.swift | 16 +++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6ea15d5..67f5a7341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Test connection success indicator not resetting after field changes - SSH port field accepting invalid values - DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers +- Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL ## [0.19.1] - 2026-03-16 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 0b21bcf39..6e13f63c9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -32,9 +32,6 @@ extension MainContentCoordinator { ) -> [String] { var statements: [String] = [] let dbType = connection.type - let driver = DatabaseManager.shared.driver(for: connectionId) - let quote: (String) -> String = driver?.quoteIdentifier - ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: dbType)) // Sort tables for consistent execution order let sortedTruncates = truncates.sorted() @@ -50,10 +47,9 @@ extension MainContentCoordinator { } for tableName in sortedTruncates { - let quotedName = quote(tableName) let tableOptions = options[tableName] ?? TableOperationOptions() statements.append(contentsOf: truncateStatements( - tableName: tableName, quotedName: quotedName, options: tableOptions, dbType: dbType + tableName: tableName, options: tableOptions )) } @@ -63,11 +59,10 @@ extension MainContentCoordinator { }() for tableName in sortedDeletes { - let quotedName = quote(tableName) let tableOptions = options[tableName] ?? TableOperationOptions() let stmt = dropTableStatement( - tableName: tableName, quotedName: quotedName, - isView: viewNames.contains(tableName), options: tableOptions, dbType: dbType + tableName: tableName, + isView: viewNames.contains(tableName), options: tableOptions ) if !stmt.isEmpty { statements.append(stmt) @@ -103,7 +98,7 @@ extension MainContentCoordinator { // MARK: - Private SQL Builders private func truncateStatements( - tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType + tableName: String, options: TableOperationOptions ) -> [String] { guard let adapter = currentPluginDriverAdapter else { return [] } return adapter.truncateTableStatements( @@ -112,8 +107,7 @@ extension MainContentCoordinator { } private func dropTableStatement( - tableName: String, quotedName: String, isView: Bool, - options: TableOperationOptions, dbType: DatabaseType + tableName: String, isView: Bool, options: TableOperationOptions ) -> String { let keyword = isView ? "VIEW" : "TABLE" guard let adapter = currentPluginDriverAdapter else { return "" } From 1ae79ef2d926cadb7ccd907b88af517e22d1ce1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 11:03:15 +0700 Subject: [PATCH 3/3] fix: use _activeDatabase in connect() so reconnect preserves switched database --- Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index b94123a2d..a02b7fdbc 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -63,7 +63,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { port: config.port, user: config.username, password: config.password, - database: config.database, + database: _activeDatabase, sslConfig: sslConfig )