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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796)
- Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800)

## [0.54.0] - 2026-06-30

Expand Down
1 change: 1 addition & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
.parameterizedQueries,
.alterTableDDL,
.cancelQuery,
.materializedViews,
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension ClickHousePluginDriver {
return result.rows.compactMap { row -> PluginTableInfo? in
guard let name = row[safe: 0]?.asText else { return nil }
let engine = row[safe: 1]?.asText
let tableType = (engine?.contains("View") == true) ? "VIEW" : "TABLE"
let tableType = clickHouseTableType(forEngine: engine)
return PluginTableInfo(name: name, type: tableType)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// ClickHousePluginDriver+TableOperations.swift
// ClickHouseDriverPlugin
//

import Foundation
import TableProPluginKit

extension ClickHousePluginDriver {
func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? {
clickHouseDropObjectStatement(name: name, objectType: objectType)
}
}
23 changes: 23 additions & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// ClickHouseTableOperations.swift
// ClickHouseDriverPlugin
//

import Foundation

func clickHouseTableType(forEngine engine: String?) -> String {
switch engine {
case "MaterializedView":
return "MATERIALIZED VIEW"
case "View", "LiveView", "WindowView":
return "VIEW"
default:
return "TABLE"
}
}

func clickHouseDropObjectStatement(name: String, objectType: String) -> String? {
guard objectType == "MATERIALIZED VIEW" else { return nil }
let escaped = name.replacingOccurrences(of: "`", with: "``")
return "DROP VIEW `\(escaped)`"
}
18 changes: 14 additions & 4 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@
53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeDDLGenerator.swift; sourceTree = "<group>"; };
55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = "<group>"; };
5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; };
5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DuckDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ElasticsearchDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tablepro-mcp"; sourceTree = BUILT_PRODUCTS_DIR; };
5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibSQLDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A860000100000000 /* TableProPluginKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TableProPluginKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -357,7 +359,6 @@
5A866000100000000 /* MongoDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MongoDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A867000100000000 /* RedisDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RedisDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A868000100000000 /* PostgreSQLDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostgreSQLDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A869000100000000 /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = DuckDBDriver.tableplugin; path = /Users/ngoquocdat/Workspaces/TablePro/build/Debug/DuckDBDriver.tableplugin; sourceTree = "<absolute>"; };
5A86A000100000000 /* CSVExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A86B000100000000 /* JSONExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A86C000100000000 /* SQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -405,7 +406,6 @@
5E1A500200000000000000A6 /* ElasticsearchPluginDriver+Execution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ElasticsearchPluginDriver+Execution.swift"; sourceTree = "<group>"; };
5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchQueryBuilder.swift; sourceTree = "<group>"; };
5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchStatementGenerator.swift; sourceTree = "<group>"; };
5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = ElasticsearchDriverPlugin.tableplugin; path = /Users/ngoquocdat/Workspaces/TablePro/build/Debug/ElasticsearchDriverPlugin.tableplugin; sourceTree = "<absolute>"; };
73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePlugin.swift; sourceTree = "<group>"; };
7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = "<group>"; };
84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -478,6 +478,13 @@
);
target = 5A863000000000000 /* ClickHouseDriver */;
};
5A863000E00000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "TableProTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ClickHouseTableOperations.swift,
);
target = 5ABCC5A62F43856700EAF3FC /* TableProTests */;
};
5A864000900000000 /* Exceptions for "Plugins/MSSQLDriverPlugin" folder in "MSSQLDriver" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Expand Down Expand Up @@ -682,6 +689,7 @@
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
5A863000900000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "ClickHouseDriver" target */,
5A863000E00000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "TableProTests" target */,
);
path = Plugins/ClickHouseDriverPlugin;
sourceTree = "<group>";
Expand Down Expand Up @@ -1173,6 +1181,8 @@
5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */,
5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */,
48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */,
5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */,
5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */,
);
name = Products;
sourceTree = "<group>";
Expand Down Expand Up @@ -1581,7 +1591,7 @@
);
name = DuckDBDriver;
productName = DuckDBDriver;
productReference = 5A869000100000000 /* DuckDBDriver.tableplugin */;
productReference = 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */;
productType = "com.apple.product-type.bundle";
};
5A86A000000000000 /* CSVExport */ = {
Expand Down Expand Up @@ -1921,7 +1931,7 @@
packageProductDependencies = (
);
productName = ElasticsearchDriverPlugin;
productReference = 5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */;
productReference = 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */;
productType = "com.apple.product-type.bundle";
};
FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = {
Expand Down
50 changes: 35 additions & 15 deletions TablePro/Core/Database/TableOperationSQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@
//

import Foundation
import os

@MainActor
struct TableOperationSQLBuilder {
private static let logger = Logger(subsystem: "com.TablePro", category: "TableOperationSQLBuilder")

let connectionId: UUID
let databaseType: DatabaseType
let viewNamesProvider: () -> Set<String>
let tableInfoProvider: () -> [String: TableInfo]
let adapterProvider: () -> PluginDriverAdapter?

init(
connectionId: UUID,
databaseType: DatabaseType,
viewNamesProvider: @escaping () -> Set<String>,
tableInfoProvider: @escaping () -> [String: TableInfo],
adapterProvider: @escaping () -> PluginDriverAdapter?
) {
self.connectionId = connectionId
self.databaseType = databaseType
self.viewNamesProvider = viewNamesProvider
self.tableInfoProvider = tableInfoProvider
self.adapterProvider = adapterProvider
}

Expand All @@ -42,20 +45,19 @@ struct TableOperationSQLBuilder {
statements.append(contentsOf: foreignKeyDisableStatements())
}

let tableLookup = tableInfoProvider()

for tableName in sortedTruncates {
let tableOptions = options[tableName] ?? TableOperationOptions()
statements.append(contentsOf: truncateStatements(
tableName: tableName, options: tableOptions
tableName: tableName, schema: tableLookup[tableName]?.schema, options: tableOptions
))
}

let viewNames = viewNamesProvider()

for tableName in sortedDeletes {
let tableOptions = options[tableName] ?? TableOperationOptions()
let stmt = dropTableStatement(
tableName: tableName,
isView: viewNames.contains(tableName), options: tableOptions
let stmt = dropObjectStatement(
tableName: tableName, tableInfo: tableLookup[tableName], options: tableOptions
)
if !stmt.isEmpty {
statements.append(stmt)
Expand All @@ -78,21 +80,39 @@ struct TableOperationSQLBuilder {
}

private func truncateStatements(
tableName: String, options: TableOperationOptions
tableName: String, schema: String?, options: TableOperationOptions
) -> [String] {
guard let adapter = adapterProvider() else { return [] }
return adapter.truncateTableStatements(
table: tableName, schema: nil, cascade: options.cascade
table: tableName, schema: schema, cascade: options.cascade
)
}

private func dropTableStatement(
tableName: String, isView: Bool, options: TableOperationOptions
private func dropObjectStatement(
tableName: String, tableInfo: TableInfo?, options: TableOperationOptions
) -> String {
let keyword = isView ? "VIEW" : "TABLE"
guard let adapter = adapterProvider() else { return "" }
if tableInfo == nil {
Self.logger.warning("No cached TableInfo for \(tableName, privacy: .public); dropping as TABLE")
}
return adapter.dropObjectStatement(
name: tableName, objectType: keyword, schema: nil, cascade: options.cascade
name: tableName,
objectType: Self.dropKeyword(for: tableInfo?.type),
schema: tableInfo?.schema,
cascade: options.cascade
)
}

private static func dropKeyword(for type: TableInfo.TableType?) -> String {
switch type {
case .view:
return "VIEW"
case .materializedView:
return "MATERIALIZED VIEW"
case .foreignTable:
return "FOREIGN TABLE"
case .table, .systemTable, .none:
return "TABLE"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ extension MainContentCoordinator {
TableOperationSQLBuilder(
connectionId: connectionId,
databaseType: connection.type,
viewNamesProvider: {
guard let session = DatabaseManager.shared.session(for: self.connectionId) else { return [] }
return Set(session.tables.filter { $0.type == .view }.map(\.name))
tableInfoProvider: {
guard let session = DatabaseManager.shared.session(for: self.connectionId) else { return [:] }
return Dictionary(session.tables.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve schema when resolving pending drops

When the sidebar/database tree has objects with the same name in different schemas, the pending operation still carries only the bare table name, but this new lookup collapses session.tables by name and keeps an arbitrary/first TableInfo. Because the builder now uses that TableInfo.schema to qualify DROP/TRUNCATE, selecting sales.orders can preview or execute against public.orders if that entry is first or the current schema cache is stale after a schema switch. Please carry the selected schema (or the full TableInfo) through the pending operation instead of resolving destructive SQL by bare name.

Useful? React with 👍 / 👎.

},
adapterProvider: {
DatabaseManager.shared.driver(for: self.connectionId) as? PluginDriverAdapter
Expand Down
119 changes: 119 additions & 0 deletions TableProTests/Core/Database/TableOperationSQLBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// TableOperationSQLBuilderTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

private final class StubDropDriver: PluginDatabaseDriver {
var supportsSchemas: Bool { false }
var supportsTransactions: Bool { false }
var currentSchema: String? { nil }
var serverVersion: String? { nil }

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 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("TableOperationSQLBuilder")
@MainActor
struct TableOperationSQLBuilderTests {
private func makeBuilder(tables: [TableInfo]) -> TableOperationSQLBuilder {
let connection = DatabaseConnection(name: "Test", type: .postgresql)
let adapter = PluginDriverAdapter(connection: connection, pluginDriver: StubDropDriver())
return TableOperationSQLBuilder(
connectionId: connection.id,
databaseType: .postgresql,
tableInfoProvider: { Dictionary(uniqueKeysWithValues: tables.map { ($0.name, $0) }) },
adapterProvider: { adapter }
)
}

@Test("Materialized view drops with DROP MATERIALIZED VIEW")
func dropsMaterializedView() {
let builder = makeBuilder(tables: [
TableInfo(name: "daily_sales", type: .materializedView, rowCount: nil, schema: "public")
])
let stmts = builder.generate(truncates: [], deletes: ["daily_sales"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP MATERIALIZED VIEW \"public\".\"daily_sales\""])
}

@Test("View drops with DROP VIEW")
func dropsView() {
let builder = makeBuilder(tables: [TableInfo(name: "active_users", type: .view, rowCount: nil)])
let stmts = builder.generate(truncates: [], deletes: ["active_users"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP VIEW \"active_users\""])
}

@Test("Foreign table drops with DROP FOREIGN TABLE")
func dropsForeignTable() {
let builder = makeBuilder(tables: [TableInfo(name: "remote_orders", type: .foreignTable, rowCount: nil)])
let stmts = builder.generate(truncates: [], deletes: ["remote_orders"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP FOREIGN TABLE \"remote_orders\""])
}

@Test("Plain table drops with DROP TABLE")
func dropsTable() {
let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil)])
let stmts = builder.generate(truncates: [], deletes: ["orders"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP TABLE \"orders\""])
}

@Test("System table drops with DROP TABLE")
func dropsSystemTable() {
let builder = makeBuilder(tables: [TableInfo(name: "pg_stats", type: .systemTable, rowCount: nil)])
let stmts = builder.generate(truncates: [], deletes: ["pg_stats"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP TABLE \"pg_stats\""])
}

@Test("Unresolvable name falls back to DROP TABLE")
func fallsBackWhenLookupMisses() {
let builder = makeBuilder(tables: [])
let stmts = builder.generate(truncates: [], deletes: ["ghost"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP TABLE \"ghost\""])
}

@Test("Cascade applies to materialized view drops")
func cascadeAppliesToMaterializedView() {
let builder = makeBuilder(tables: [TableInfo(name: "daily_sales", type: .materializedView, rowCount: nil)])
let options = ["daily_sales": TableOperationOptions(cascade: true)]
let stmts = builder.generate(truncates: [], deletes: ["daily_sales"], options: options, includeFKHandling: false)
#expect(stmts == ["DROP MATERIALIZED VIEW \"daily_sales\" CASCADE"])
}

@Test("Drop qualifies schema when TableInfo carries one")
func qualifiesSchema() {
let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil, schema: "sales")])
let stmts = builder.generate(truncates: [], deletes: ["orders"], options: [:], includeFKHandling: false)
#expect(stmts == ["DROP TABLE \"sales\".\"orders\""])
}

@Test("Truncate qualifies schema when TableInfo carries one")
func truncateQualifiesSchema() {
let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil, schema: "sales")])
let stmts = builder.generate(truncates: ["orders"], deletes: [], options: [:], includeFKHandling: false)
#expect(stmts == ["TRUNCATE TABLE \"sales\".\"orders\""])
}
}
Loading
Loading