Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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
- Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL

## [0.19.1] - 2026-03-16

Expand Down
13 changes: 8 additions & 5 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

init(config: DriverConnectionConfig) {
self.config = config
self._activeDatabase = config.database
}

// MARK: - Connection
Expand All @@ -61,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
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: "''")

Expand Down Expand Up @@ -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 = """
Expand Down Expand Up @@ -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: "''")

Expand Down Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]? {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
))
}

Expand All @@ -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)
Expand Down Expand Up @@ -103,28 +98,21 @@ 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,
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(
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,
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
)
}
}
137 changes: 137 additions & 0 deletions TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading