From 55f7f7f95d4d653899a6dfe64fab1edd04a12397 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Jul 2026 19:43:17 +0700 Subject: [PATCH] fix(coordinator): drop materialized views and foreign tables with the correct DROP statement (#1800) --- CHANGELOG.md | 1 + .../ClickHousePlugin.swift | 1 + .../ClickHousePluginDriver+Schema.swift | 2 +- ...ickHousePluginDriver+TableOperations.swift | 13 ++ .../ClickHouseTableOperations.swift | 23 ++++ TablePro.xcodeproj/project.pbxproj | 18 ++- .../Database/TableOperationSQLBuilder.swift | 50 +++++--- ...inContentCoordinator+TableOperations.swift | 6 +- .../TableOperationSQLBuilderTests.swift | 119 ++++++++++++++++++ .../PluginDriverAdapterTableOpsTests.swift | 18 +++ .../ClickHouseTableOperationsTests.swift | 53 ++++++++ docs/databases/clickhouse.mdx | 2 +- 12 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift create mode 100644 Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift create mode 100644 TableProTests/Core/Database/TableOperationSQLBuilderTests.swift create mode 100644 TableProTests/Plugins/ClickHouseTableOperationsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b8acffcd9..e01a8de8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 772dd89ba..a9839bc3a 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -162,6 +162,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { .parameterizedQueries, .alterTableDDL, .cancelQuery, + .materializedViews, ] } diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift index 32a916680..7dbd7bc4d 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift @@ -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) } } diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift new file mode 100644 index 000000000..baf7168ad --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift @@ -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) + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift new file mode 100644 index 000000000..67bf5978d --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift @@ -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)`" +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 2e7e81f07..ae93e9ead 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -346,6 +346,8 @@ 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeDDLGenerator.swift; sourceTree = ""; }; 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = ""; }; 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; }; @@ -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 = ""; }; 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; }; @@ -405,7 +406,6 @@ 5E1A500200000000000000A6 /* ElasticsearchPluginDriver+Execution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ElasticsearchPluginDriver+Execution.swift"; sourceTree = ""; }; 5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchQueryBuilder.swift; sourceTree = ""; }; 5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchStatementGenerator.swift; sourceTree = ""; }; - 5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = ElasticsearchDriverPlugin.tableplugin; path = /Users/ngoquocdat/Workspaces/TablePro/build/Debug/ElasticsearchDriverPlugin.tableplugin; sourceTree = ""; }; 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePlugin.swift; sourceTree = ""; }; 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = ""; }; @@ -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 = ( @@ -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 = ""; @@ -1173,6 +1181,8 @@ 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */, 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */, 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */, + 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */, + 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -1581,7 +1591,7 @@ ); name = DuckDBDriver; productName = DuckDBDriver; - productReference = 5A869000100000000 /* DuckDBDriver.tableplugin */; + productReference = 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */; productType = "com.apple.product-type.bundle"; }; 5A86A000000000000 /* CSVExport */ = { @@ -1921,7 +1931,7 @@ packageProductDependencies = ( ); productName = ElasticsearchDriverPlugin; - productReference = 5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */; + productReference = 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; }; FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = { diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index d65f0e756..a62ae1704 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -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 + let tableInfoProvider: () -> [String: TableInfo] let adapterProvider: () -> PluginDriverAdapter? init( connectionId: UUID, databaseType: DatabaseType, - viewNamesProvider: @escaping () -> Set, + tableInfoProvider: @escaping () -> [String: TableInfo], adapterProvider: @escaping () -> PluginDriverAdapter? ) { self.connectionId = connectionId self.databaseType = databaseType - self.viewNamesProvider = viewNamesProvider + self.tableInfoProvider = tableInfoProvider self.adapterProvider = adapterProvider } @@ -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) @@ -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" + } + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 4595ac060..439474995 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -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 }) }, adapterProvider: { DatabaseManager.shared.driver(for: self.connectionId) as? PluginDriverAdapter diff --git a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift new file mode 100644 index 000000000..43362c4be --- /dev/null +++ b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift @@ -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\""]) + } +} diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift index 177f93507..6666af87f 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift @@ -71,6 +71,24 @@ struct PluginDriverAdapterTableOpsTests { #expect(result == "DROP VIEW \"active_users\"") } + @Test("Fallback produces DROP MATERIALIZED VIEW for materialized views") + func dropMaterializedViewFallback() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement( + name: "daily_sales", objectType: "MATERIALIZED VIEW", schema: "public", cascade: false + ) + #expect(result == "DROP MATERIALIZED VIEW \"public\".\"daily_sales\"") + } + + @Test("Fallback produces DROP FOREIGN TABLE for foreign tables") + func dropForeignTableFallback() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let result = adapter.dropObjectStatement( + name: "remote_orders", objectType: "FOREIGN TABLE", schema: nil, cascade: false + ) + #expect(result == "DROP FOREIGN TABLE \"remote_orders\"") + } + @Test("Fallback appends CASCADE when requested") func dropWithCascade() { let adapter = makeAdapter(driver: StubTableOpsDriver()) diff --git a/TableProTests/Plugins/ClickHouseTableOperationsTests.swift b/TableProTests/Plugins/ClickHouseTableOperationsTests.swift new file mode 100644 index 000000000..e340a6915 --- /dev/null +++ b/TableProTests/Plugins/ClickHouseTableOperationsTests.swift @@ -0,0 +1,53 @@ +// +// ClickHouseTableOperationsTests.swift +// TableProTests +// + +import Testing + +@Suite("ClickHouse Table Operations") +struct ClickHouseTableOperationsTests { + @Test("MergeTree engine classifies as TABLE") + func mergeTreeIsTable() { + #expect(clickHouseTableType(forEngine: "MergeTree") == "TABLE") + } + + @Test("View engine classifies as VIEW") + func viewIsView() { + #expect(clickHouseTableType(forEngine: "View") == "VIEW") + } + + @Test("MaterializedView engine classifies as MATERIALIZED VIEW") + func materializedViewIsDistinctFromView() { + #expect(clickHouseTableType(forEngine: "MaterializedView") == "MATERIALIZED VIEW") + } + + @Test("LiveView and WindowView engines classify as VIEW") + func experimentalViewEnginesAreViews() { + #expect(clickHouseTableType(forEngine: "LiveView") == "VIEW") + #expect(clickHouseTableType(forEngine: "WindowView") == "VIEW") + } + + @Test("Nil engine classifies as TABLE") + func nilEngineIsTable() { + #expect(clickHouseTableType(forEngine: nil) == "TABLE") + } + + @Test("Materialized view drops via DROP VIEW") + func dropMaterializedViewUsesViewKeyword() { + let stmt = clickHouseDropObjectStatement(name: "daily_sales", objectType: "MATERIALIZED VIEW") + #expect(stmt == "DROP VIEW `daily_sales`") + } + + @Test("Backticks in the name are escaped") + func escapesBackticks() { + let stmt = clickHouseDropObjectStatement(name: "weird`name", objectType: "MATERIALIZED VIEW") + #expect(stmt == "DROP VIEW `weird``name`") + } + + @Test("Other object types fall through to the default statement") + func otherTypesReturnNil() { + #expect(clickHouseDropObjectStatement(name: "orders", objectType: "TABLE") == nil) + #expect(clickHouseDropObjectStatement(name: "active_users", objectType: "VIEW") == nil) + } +} diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index d92907992..62606ca97 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -43,7 +43,7 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. ### Database Browsing -After connecting, the sidebar lists all databases on the server. Expand a database to see its tables and views. Press **Cmd+K** or click the database name in the toolbar to switch. +After connecting, the sidebar lists all databases on the server. Expand a database to see its tables, views, and materialized views. Press **Cmd+K** or click the database name in the toolbar to switch. ### Table Browsing