From 4c1b2b8c01e05a0203df255d7a521cb526a913a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 12:53:10 +0700 Subject: [PATCH] feat: replace hardcoded DatabaseType switches with dynamic plugin metadata lookups (#307) --- CHANGELOG.md | 1 + .../ClickHousePlugin.swift | 3 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 1 + .../MongoDBDriverPlugin/MongoDBPlugin.swift | 2 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 3 +- .../PostgreSQLPlugin.swift | 1 + Plugins/RedisDriverPlugin/RedisPlugin.swift | 1 + Plugins/TableProPluginKit/DriverPlugin.swift | 6 ++ .../SQLDialectDescriptor.swift | 5 +- .../ChangeTracking/DataChangeManager.swift | 14 +-- TablePro/Core/Plugins/PluginManager.swift | 33 +++++++ .../Core/Services/Export/ExportService.swift | 2 +- .../Infrastructure/SafeModeGuard.swift | 2 +- .../Infrastructure/SessionStateFactory.swift | 2 +- .../SQL/SQLRowToStatementConverter.swift | 19 ++-- .../Connection/ConnectionToolbarState.swift | 13 ++- TablePro/Models/Query/QueryTab.swift | 22 ++--- .../Components/HighlightedSQLTextView.swift | 7 +- .../DatabaseSwitcherSheet.swift | 7 +- TablePro/Views/Export/ExportDialog.swift | 88 ++++--------------- .../Extensions/DataGridView+Click.swift | 6 +- .../Extensions/DataGridView+Editing.swift | 6 +- .../ForeignKeyPopoverContentView.swift | 7 +- .../Services/SQLFormatterServiceTests.swift | 1 + .../SQLRowToStatementConverterTests.swift | 63 ++++++++++--- 25 files changed, 166 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f401d80..4f819866a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `FilterSQLGenerator` now uses `SQLDialectDescriptor` data (regex syntax, boolean literals, LIKE escape style, pagination style) instead of `DatabaseType` switch statements - Moved identifier quoting, autocomplete statement completions, view templates, and FK disable/enable into plugin system - Removed `DatabaseType` switches from `FilterSQLGenerator`, `SQLCompletionProvider`, `ImportDataSinkAdapter`, and `MainContentCoordinator+SidebarActions` +- Replaced hardcoded `DatabaseType` switches in ExportDialog, DataChangeManager, SafeModeGuard, ExportService, DataGridView, HighlightedSQLTextView, ForeignKeyPopoverContentView, QueryTab, SQLRowToStatementConverter, SessionStateFactory, ConnectionToolbarState, and DatabaseSwitcherSheet with dynamic plugin lookups (`databaseGroupingStrategy`, `immutableColumns`, `supportsReadOnlyMode`, `paginationStyle`, `editorLanguage`, `connectionMode`, `supportsSchemaSwitching`) ### Added diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 08f61556a..a94c6e1bd 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -91,7 +91,8 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { regexSyntax: .match, booleanLiteralStyle: .numeric, likeEscapeStyle: .implicit, - paginationStyle: .limit + paginationStyle: .limit, + requiresBackslashEscaping: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 4016956c3..00c90cf6f 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -26,6 +26,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#E34517" static let systemDatabaseNames: [String] = ["master", "tempdb", "model", "msdb"] + static let defaultSchemaName = "dbo" static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index c3a8f18d8..578d77ed6 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -55,6 +55,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let systemDatabaseNames: [String] = ["admin", "local", "config"] static let tableEntityName = "Collections" static let supportsForeignKeyDisable = false + static let immutableColumns: [String] = ["_id"] + static let supportsReadOnlyMode = false static let databaseGroupingStrategy: GroupingStrategy = .flat static let columnTypesByCategory: [String: [String]] = [ "String": ["string", "objectId", "regex"], diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 2de3921ef..b5f1cefcf 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -81,7 +81,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { regexSyntax: .regexp, booleanLiteralStyle: .numeric, likeEscapeStyle: .implicit, - paginationStyle: .limit + paginationStyle: .limit, + requiresBackslashEscaping: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index ef25b9b3f..21da8fad4 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -29,6 +29,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let urlSchemes: [String] = ["postgresql", "postgres"] static let brandColorHex = "#336791" static let systemDatabaseNames: [String] = ["postgres", "template0", "template1"] + static let supportsSchemaSwitching = true static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index c6230c22c..0a7e0aa34 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -44,6 +44,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let tableEntityName = "Keys" static let supportsForeignKeyDisable = false + static let supportsReadOnlyMode = false static let databaseGroupingStrategy: GroupingStrategy = .flat static let defaultGroupName = "db0" static let columnTypesByCategory: [String: [String]] = [ diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index df9659f7f..5900e2920 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -39,6 +39,9 @@ public protocol DriverPlugin: TableProPlugin { static var tableEntityName: String { get } static var supportsCascadeDrop: Bool { get } static var supportsForeignKeyDisable: Bool { get } + static var immutableColumns: [String] { get } + static var supportsReadOnlyMode: Bool { get } + static var defaultSchemaName: String { get } } public extension DriverPlugin { @@ -82,4 +85,7 @@ public extension DriverPlugin { static var tableEntityName: String { "Tables" } static var supportsCascadeDrop: Bool { false } static var supportsForeignKeyDisable: Bool { true } + static var immutableColumns: [String] { [] } + static var supportsReadOnlyMode: Bool { true } + static var defaultSchemaName: String { "public" } } diff --git a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index c9b8306fb..a3cc9d067 100644 --- a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -22,6 +22,7 @@ public struct SQLDialectDescriptor: Sendable { public let likeEscapeStyle: LikeEscapeStyle public let paginationStyle: PaginationStyle public let offsetFetchOrderBy: String + public let requiresBackslashEscaping: Bool public enum RegexSyntax: String, Sendable { case regexp // MySQL: column REGEXP 'pattern' @@ -57,7 +58,8 @@ public struct SQLDialectDescriptor: Sendable { booleanLiteralStyle: BooleanLiteralStyle = .numeric, likeEscapeStyle: LikeEscapeStyle = .explicit, paginationStyle: PaginationStyle = .limit, - offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)" + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false ) { self.identifierQuote = identifierQuote self.keywords = keywords @@ -69,5 +71,6 @@ public struct SQLDialectDescriptor: Sendable { self.likeEscapeStyle = likeEscapeStyle self.paginationStyle = paginationStyle self.offsetFetchOrderBy = offsetFetchOrderBy + self.requiresBackslashEscaping = requiresBackslashEscaping } } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 09f6f0a56..3e40b47b3 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -647,24 +647,12 @@ final class DataChangeManager { deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices ) { - // Validate MongoDB _id requirement - if databaseType == .mongodb { - let expectedUpdates = changes.count(where: { $0.type == .update }) - let actualUpdates = statements.count(where: { $0.statement.contains("updateOne(") || $0.statement.contains("updateMany(") }) - - if expectedUpdates > 0 && actualUpdates < expectedUpdates { - throw DatabaseError.queryFailed( - "Cannot save UPDATE changes to collection '\(tableName)' without an _id field. " + - "Please ensure the collection has _id values." - ) - } - } return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters) } } } // Safety: prevent SQL generation for NoSQL databases if plugin driver is unavailable - if databaseType == .mongodb || databaseType == .redis { + if PluginManager.shared.editorLanguage(for: databaseType) != .sql { throw DatabaseError.queryFailed( "Cannot generate statements for \(databaseType.rawValue) — plugin driver not initialized" ) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 8e3669bcc..d7ab8c8be 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -457,6 +457,39 @@ final class PluginManager { return Swift.type(of: plugin).supportsForeignKeyDisable } + func immutableColumns(for databaseType: DatabaseType) -> [String] { + guard let plugin = driverPlugin(for: databaseType) else { return [] } + return Swift.type(of: plugin).immutableColumns + } + + func supportsReadOnlyMode(for databaseType: DatabaseType) -> Bool { + guard let plugin = driverPlugin(for: databaseType) else { return true } + return Swift.type(of: plugin).supportsReadOnlyMode + } + + func defaultSchemaName(for databaseType: DatabaseType) -> String { + guard let plugin = driverPlugin(for: databaseType) else { return "public" } + return Swift.type(of: plugin).defaultSchemaName + } + + func paginationStyle(for databaseType: DatabaseType) -> SQLDialectDescriptor.PaginationStyle { + sqlDialect(for: databaseType)?.paginationStyle ?? .limit + } + + func offsetFetchOrderBy(for databaseType: DatabaseType) -> String { + sqlDialect(for: databaseType)?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" + } + + func databaseGroupingStrategy(for databaseType: DatabaseType) -> GroupingStrategy { + guard let plugin = driverPlugin(for: databaseType) else { return .byDatabase } + return Swift.type(of: plugin).databaseGroupingStrategy + } + + func defaultGroupName(for databaseType: DatabaseType) -> String { + guard let plugin = driverPlugin(for: databaseType) else { return "main" } + return Swift.type(of: plugin).defaultGroupName + } + /// All file extensions across all loaded plugins. var allRegisteredFileExtensions: [String: DatabaseType] { loadPendingPlugins() diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index f6c46eae8..898ec470d 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -191,7 +191,7 @@ final class ExportService { var total = 0 var failedCount = 0 - if databaseType == .mongodb || databaseType == .redis { + if PluginManager.shared.editorLanguage(for: databaseType) != .sql { for table in tables { do { if let count = try await driver.fetchApproximateRowCount(table: table.name) { diff --git a/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift index b3ccdfa88..12a98d5af 100644 --- a/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift +++ b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift @@ -25,7 +25,7 @@ internal final class SafeModeGuard { databaseType: DatabaseType? = nil ) async -> Permission { let effectiveIsWrite: Bool - if let dbType = databaseType, dbType == .mongodb || dbType == .redis { + if let dbType = databaseType, !PluginManager.shared.supportsReadOnlyMode(for: dbType) { effectiveIsWrite = true } else { effectiveIsWrite = isWriteOperation diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 7675b3b4d..47106a1b9 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -42,7 +42,7 @@ enum SessionStateFactory { toolbarSt.hasCompletedSetup = true // Redis: set initial database name eagerly to avoid toolbar flash - if connection.type == .redis { + if connection.type.pluginTypeId == "Redis" { let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 toolbarSt.databaseName = String(dbIndex) } diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index a4c6ba47e..025b93473 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -27,16 +27,15 @@ internal struct SQLRowToStatementConverter { self.primaryKeyColumn = primaryKeyColumn self.databaseType = databaseType self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) - self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(for: databaseType) + self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: dialect) } private static let maxRows = 50_000 /// Fallback escape function when no plugin driver is available. - /// MySQL/MariaDB/ClickHouse need backslash escaping; others use ANSI SQL. - private static func defaultEscapeFunction(for databaseType: DatabaseType) -> (String) -> String { - switch databaseType { - case .mysql, .mariadb, .clickhouse: + /// Dialects with `requiresBackslashEscaping` get backslash escaping; others use ANSI SQL. + private static func defaultEscapeFunction(dialect: SQLDialectDescriptor?) -> (String) -> String { + if dialect?.requiresBackslashEscaping == true { return { value in var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") @@ -44,9 +43,8 @@ internal struct SQLRowToStatementConverter { result = result.replacingOccurrences(of: "\0", with: "\\0") return result } - default: - return SQLEscaping.escapeStringLiteral } + return SQLEscaping.escapeStringLiteral } internal func generateInserts(rows: [[String?]]) -> String { @@ -109,12 +107,7 @@ internal struct SQLRowToStatementConverter { whereClause = whereParts.joined(separator: " AND ") } - switch databaseType { - case .clickhouse: - return "ALTER TABLE \(quotedTable) UPDATE \(setClause) WHERE \(whereClause);" - default: - return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);" - } + return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);" } private func formatValue(_ value: String?) -> String { diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 45d2a7d9c..f3b89ea5a 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -9,6 +9,7 @@ import AppKit import Observation import SwiftUI +import TableProPluginKit // MARK: - Connection Environment @@ -233,15 +234,11 @@ final class ConnectionToolbarState { /// Update state from a DatabaseConnection model func update(from connection: DatabaseConnection) { connectionName = connection.name - if connection.type == .sqlite { + if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { databaseName = (connection.database as NSString).lastPathComponent - } else if connection.type == .postgresql { - if let session = DatabaseManager.shared.session(for: connection.id), - let database = session.currentDatabase { - databaseName = database - } else { - databaseName = connection.database - } + } else if let session = DatabaseManager.shared.session(for: connection.id), + let database = session.currentDatabase { + databaseName = database } else { databaseName = connection.database } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 8165834c1..87e8723b3 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -7,6 +7,7 @@ import Foundation import Observation +import TableProPluginKit /// Type of tab enum TabType: Equatable, Codable, Hashable { @@ -439,20 +440,21 @@ struct QueryTab: Identifiable, Equatable { ) -> String { let quote = quoteIdentifier ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: databaseType)) let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize - if databaseType == .mongodb { + switch PluginManager.shared.editorLanguage(for: databaseType) { + case .javascript: let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") return "db[\"\(escaped)\"].find({}).limit(\(pageSize))" - } else if databaseType == .redis { + case .bash: return "SCAN 0 MATCH * COUNT \(pageSize)" - } else if databaseType == .mssql { + default: let quotedName = quote(tableName) - return "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" - } else if databaseType == .oracle { - let quotedName = quote(tableName) - return "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" - } else { - let quotedName = quote(tableName) - return "SELECT * FROM \(quotedName) LIMIT \(pageSize);" + switch PluginManager.shared.paginationStyle(for: databaseType) { + case .offsetFetch: + let orderBy = PluginManager.shared.offsetFetchOrderBy(for: databaseType) + return "SELECT * FROM \(quotedName) \(orderBy) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" + case .limit: + return "SELECT * FROM \(quotedName) LIMIT \(pageSize);" + } } } diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index 268c21320..75514ba5c 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProPluginKit /// Read-only text view that applies SQL/MQL syntax highlighting via regex struct HighlightedSQLTextView: NSViewRepresentable { @@ -172,11 +173,9 @@ struct HighlightedSQLTextView: NSViewRepresentable { // Apply pre-compiled patterns let activePatterns: [(regex: NSRegularExpression, color: NSColor)] - switch databaseType { - case .mongodb: + switch PluginManager.shared.editorLanguage(for: databaseType) { + case .javascript: activePatterns = Self.mqlPatterns - case .redis: - activePatterns = Self.syntaxPatterns default: activePatterns = Self.syntaxPatterns } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 4404574cc..2de881e9d 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProPluginKit struct DatabaseSwitcherSheet: View { @Binding var isPresented: Bool @@ -62,7 +63,7 @@ struct DatabaseSwitcherSheet: View { .padding(.vertical, 12) // Databases / Schemas toggle (PostgreSQL only) - if databaseType == .postgresql { + if PluginManager.shared.supportsSchemaSwitching(for: databaseType) { Picker("", selection: $viewModel.mode) { Text(String(localized: "Databases")) .tag(DatabaseSwitcherViewModel.Mode.database) @@ -90,7 +91,7 @@ struct DatabaseSwitcherSheet: View { loadingView } else if let error = viewModel.errorMessage { errorView(error) - } else if databaseType == .sqlite { + } else if PluginManager.shared.connectionMode(for: databaseType) == .fileBased { sqliteEmptyState } else if viewModel.filteredDatabases.isEmpty { emptyState @@ -434,7 +435,7 @@ struct DatabaseSwitcherSheet: View { viewModel.trackAccess(database: database) // Call appropriate callback - if viewModel.isSchemaMode, databaseType == .postgresql, let onSelectSchema { + if viewModel.isSchemaMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType), let onSelectSchema { onSelectSchema(database) } else { onSelect(database) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 268f2e359..179c01c3f 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -435,89 +435,43 @@ struct ExportDialog: View { var items: [ExportDatabaseItem] = [] let dbType = connection.type - if dbType == .postgresql || dbType == .redshift { - // PostgreSQL: fetch schemas within current database (can't query across databases) - let schemas = try await fetchPostgreSQLSchemas(driver: driver) + let grouping = PluginManager.shared.databaseGroupingStrategy(for: dbType) + switch grouping { + case .bySchema: + let schemas = try await driver.fetchSchemas() + let defaultSchema = PluginManager.shared.defaultSchemaName(for: dbType) for schema in schemas { let tables = try await fetchTablesForSchema(schema, driver: driver) let tableItems = tables.map { table in ExportTableItem( name: table.name, - databaseName: schema, // schema name for PostgreSQL + databaseName: schema, type: table.type, - isSelected: schema == "public" && preselectedTables.contains(table.name) + isSelected: schema.caseInsensitiveCompare(defaultSchema) == .orderedSame + && preselectedTables.contains(table.name) ) } if !tableItems.isEmpty { items.append(ExportDatabaseItem( name: schema, tables: tableItems, - isExpanded: schema == "public" + isExpanded: schema.caseInsensitiveCompare(defaultSchema) == .orderedSame )) } } - // Sort: public schema first items.sort { item1, item2 in - if item1.name == "public" { return true } - if item2.name == "public" { return false } + if item1.name.caseInsensitiveCompare(defaultSchema) == .orderedSame { return true } + if item2.name.caseInsensitiveCompare(defaultSchema) == .orderedSame { return false } return item1.name < item2.name } - } else if dbType == .sqlite || dbType == .mongodb || dbType == .redis || dbType == .duckdb { - let fallbackName = dbType == .redis ? "db0" : "main" + case .flat: + let fallbackName = PluginManager.shared.defaultGroupName(for: dbType) let dbItem = try await buildFlatDatabaseItem( driver: driver, name: connection.database.isEmpty ? fallbackName : connection.database ) if let dbItem { items.append(dbItem) } - } else if dbType == .mssql { - // MSSQL: fetch schemas within current database - let schemas = try await driver.fetchSchemas() - for schema in schemas { - let tables = try await fetchTablesForSchema(schema, driver: driver) - let tableItems = tables.map { table in - ExportTableItem( - name: table.name, - databaseName: schema, - type: table.type, - isSelected: schema == "dbo" && preselectedTables.contains(table.name) - ) - } - if !tableItems.isEmpty { - items.append(ExportDatabaseItem( - name: schema, - tables: tableItems, - isExpanded: schema == "dbo" - )) - } - } - items.sort { item1, item2 in - if item1.name == "dbo" { return true } - if item2.name == "dbo" { return false } - return item1.name < item2.name - } - } else if dbType == .oracle { - // Oracle: fetch schemas (users) and their tables - let schemas = try await driver.fetchSchemas() - for schema in schemas { - let tables = try await fetchTablesForSchema(schema, driver: driver) - let tableItems = tables.map { table in - ExportTableItem( - name: table.name, - databaseName: schema, - type: table.type, - isSelected: preselectedTables.contains(table.name) - ) - } - if !tableItems.isEmpty { - items.append(ExportDatabaseItem( - name: schema, - tables: tableItems, - isExpanded: schema == connection.username.uppercased() - )) - } - } - } else { - // MySQL/MariaDB/ClickHouse and other types: fetch all databases and their tables + case .byDatabase: let databases = try await driver.fetchDatabases() for dbName in databases { let tables = try await fetchTablesForDatabase(dbName, driver: driver) @@ -537,7 +491,6 @@ struct ExportDialog: View { )) } } - // Sort: current database first items.sort { item1, item2 in if item1.name == connection.database { return true } if item2.name == connection.database { return false } @@ -564,17 +517,6 @@ struct ExportDialog: View { } } - private func fetchPostgreSQLSchemas(driver: DatabaseDriver) async throws -> [String] { - let query = """ - SELECT schema_name - FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY schema_name - """ - let result = try await driver.execute(query: query) - return result.rows.compactMap { $0[0] } - } - private func buildFlatDatabaseItem( driver: DatabaseDriver, name: String @@ -594,7 +536,7 @@ struct ExportDialog: View { private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { // Oracle does not have information_schema — use ALL_TABLES/ALL_VIEWS - if connection.type == .oracle { + if connection.type.pluginTypeId == "Oracle" { let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") let query = """ SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 46b4facfc..8cd0ad4b2 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -51,10 +51,10 @@ extension TableViewCoordinator { let columnIndex = column - 1 guard !changeManager.isRowDeleted(row) else { return } - // MongoDB _id is immutable — block editing - if databaseType == .mongodb, + let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] + if !immutable.isEmpty, columnIndex < rowProvider.columns.count, - rowProvider.columns[columnIndex] == "_id" { + immutable.contains(rowProvider.columns[columnIndex]) { return } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 272cb5fdb..f1aef3a97 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -15,12 +15,12 @@ extension TableViewCoordinator { guard columnId != "__rowNumber__", !changeManager.isRowDeleted(row) else { return false } - // MongoDB _id is immutable — block editing - if databaseType == .mongodb, + let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] + if !immutable.isEmpty, columnId.hasPrefix("col_"), let columnIndex = Int(columnId.dropFirst(4)), columnIndex < rowProvider.columns.count, - rowProvider.columns[columnIndex] == "_id" { + immutable.contains(rowProvider.columns[columnIndex]) { return false } diff --git a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift index 763ed882d..b1a2a355e 100644 --- a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift +++ b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift @@ -7,6 +7,7 @@ import os import SwiftUI +import TableProPluginKit struct ForeignKeyPopoverContentView: View { let currentValue: String? @@ -133,10 +134,10 @@ struct ForeignKeyPopoverContentView: View { let query: String let limitSuffix: String - switch databaseType { - case .oracle, .mssql: + switch PluginManager.shared.paginationStyle(for: databaseType) { + case .offsetFetch: limitSuffix = "OFFSET 0 ROWS FETCH NEXT \(Self.maxFetchRows) ROWS ONLY" - default: + case .limit: limitSuffix = "LIMIT \(Self.maxFetchRows)" } if let displayCol = displayColumn { diff --git a/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/TableProTests/Core/Services/SQLFormatterServiceTests.swift index 8901985c2..eef5bf66a 100644 --- a/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -10,6 +10,7 @@ import Testing @testable import TablePro @Suite("SQL Formatter Service") +@MainActor struct SQLFormatterServiceTests { let formatter = SQLFormatterService() diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 3af0c62d3..57516383b 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -5,23 +5,66 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("SQL Row To Statement Converter") struct SQLRowToStatementConverterTests { + // MARK: - Test Dialect Helpers + + private static let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [], + functions: [], + dataTypes: [], + requiresBackslashEscaping: true + ) + + private static let postgresDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [] + ) + + private static let mssqlDialect = SQLDialectDescriptor( + identifierQuote: "[", + keywords: [], + functions: [], + dataTypes: [], + paginationStyle: .offsetFetch + ) + + private static let clickhouseDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [], + functions: [], + dataTypes: [], + requiresBackslashEscaping: true + ) + + private static let duckdbDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [] + ) + // MARK: - Factory private func makeConverter( tableName: String = "users", columns: [String] = ["id", "name", "email"], primaryKeyColumn: String? = "id", - databaseType: DatabaseType = .mysql + databaseType: DatabaseType = .mysql, + dialect: SQLDialectDescriptor? = Self.mysqlDialect ) -> SQLRowToStatementConverter { SQLRowToStatementConverter( tableName: tableName, columns: columns, primaryKeyColumn: primaryKeyColumn, - databaseType: databaseType + databaseType: databaseType, + dialect: dialect ) } @@ -101,23 +144,23 @@ struct SQLRowToStatementConverterTests { // MARK: - Database-Specific Quoting - @Test("ClickHouse uses ALTER TABLE ... UPDATE syntax") - func clickhouseUsesAlterTableUpdate() { - let converter = makeConverter(databaseType: .clickhouse) + @Test("ClickHouse fallback uses standard UPDATE syntax (plugin handles ALTER TABLE at runtime)") + func clickhouseFallbackUsesStandardUpdate() { + let converter = makeConverter(databaseType: .clickhouse, dialect: Self.clickhouseDialect) let result = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) - #expect(result == "ALTER TABLE `users` UPDATE `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") + #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") } @Test("MSSQL uses bracket quoting") func mssqlUsesBracketQuoting() { - let converter = makeConverter(databaseType: .mssql) + let converter = makeConverter(databaseType: .mssql, dialect: Self.mssqlDialect) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES ('1', 'Alice', 'alice@example.com');") } @Test("PostgreSQL uses double-quote quoting") func postgresqlUsesDoubleQuoteQuoting() { - let converter = makeConverter(databaseType: .postgresql) + let converter = makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") } @@ -131,7 +174,7 @@ struct SQLRowToStatementConverterTests { @Test("DuckDB uses double-quote quoting and standard UPDATE syntax") func duckdbUsesDoubleQuoteAndStandardUpdate() { - let converter = makeConverter(databaseType: .duckdb) + let converter = makeConverter(databaseType: .duckdb, dialect: Self.duckdbDialect) let insert = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) #expect(insert == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'Alice', 'alice@example.com');") let update = converter.generateUpdates(rows: [["1", "Alice", "alice@example.com"]]) @@ -147,7 +190,7 @@ struct SQLRowToStatementConverterTests { @Test("PostgreSQL does not escape backslashes") func postgresqlNoBackslashEscaping() { - let converter = makeConverter(databaseType: .postgresql) + let converter = makeConverter(databaseType: .postgresql, dialect: Self.postgresDialect) let result = converter.generateInserts(rows: [["1", "C:\\Users\\test", "a@b.com"]]) #expect(result == "INSERT INTO \"users\" (\"id\", \"name\", \"email\") VALUES ('1', 'C:\\Users\\test', 'a@b.com');") }