diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf55037..053955bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved string literal escaping into plugin drivers via `escapeStringLiteral` on `PluginDatabaseDriver` and `DatabaseDriver` protocols; `SQLEscaping.escapeStringLiteral` now uses ANSI SQL escaping only (doubles single quotes, strips null bytes) - SQL autocomplete data types and CREATE TABLE options now use plugin-provided dialect data instead of hardcoded per-database switches - `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` ### Added - `SQLDialectDescriptor` in TableProPluginKit: plugins can now self-describe their SQL dialect (keywords, functions, data types, identifier quoting), with `SQLDialectFactory` preferring plugin-provided dialect info over built-in structs - DDL schema generation protocol in TableProPluginKit: plugins can now optionally provide database-specific ALTER TABLE syntax (ADD/MODIFY/DROP COLUMN, ADD/DROP INDEX, ADD/DROP FK, MODIFY PK) via `PluginDatabaseDriver`, with `SchemaStatementGenerator` trying plugin methods first before falling back to built-in logic - Plugin-provided table operations: `truncateTableStatements`, `dropObjectStatement`, `foreignKeyDisableStatements`, `foreignKeyEnableStatements` in `PluginDatabaseDriver` protocol, allowing plugins to override TRUNCATE, DROP, and FK handling SQL +- `CompletionEntry` struct and `statementCompletions` on `DriverPlugin` for plugin-provided autocomplete entries (MongoDB MQL methods, Redis commands) +- `offsetFetchOrderBy` property on `SQLDialectDescriptor` for plugin-controlled ORDER BY in OFFSET/FETCH pagination +- `createViewTemplate()`, `editViewFallbackTemplate(viewName:)`, and `castColumnToText(_:)` on `PluginDatabaseDriver` for plugin-provided view DDL templates and column casting - `buildExplainQuery` method in `PluginDatabaseDriver` protocol: plugins can now provide database-specific EXPLAIN syntax, with coordinator falling back to built-in logic when plugin returns nil - `SettablePlugin` protocol in TableProPluginKit SDK: unified settings pattern for all plugins with automatic persistence via `loadSettings()`/`saveSettings()`, replacing duplicated boilerplate across export/import/driver plugins - Plugin UI/capability metadata: each driver plugin now self-declares brand color, connection mode, supported features, column types, URL schemes, and grouping strategy via the `DriverPlugin` protocol diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 70e7f2b3..08f61556 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -724,6 +724,17 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "CREATE OR REPLACE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + // MARK: - Kill Query private func killQuery(queryId: String) { diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 55ebb847..85fa639b 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -821,6 +821,17 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "CREATE OR REPLACE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + // MARK: - Private Helpers nonisolated private func setInterruptHandle(_ handle: duckdb_connection?) { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 475a1ce2..4016956c 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -428,6 +428,21 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "[\(escaped)]" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE OR ALTER VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "CREATE OR ALTER VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + + func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS NVARCHAR(MAX))" + } + init(config: DriverConnectionConfig) { self.config = config self._currentSchema = config.additionalFields["mssqlSchema"]?.isEmpty == false diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index cea27542..1dcee803 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -68,6 +68,33 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let sqlDialect: SQLDialectDescriptor? = nil + static var statementCompletions: [CompletionEntry] { + [ + CompletionEntry(label: "db.", insertText: "db."), + CompletionEntry(label: "db.runCommand", insertText: "db.runCommand"), + CompletionEntry(label: "db.adminCommand", insertText: "db.adminCommand"), + CompletionEntry(label: "db.createView", insertText: "db.createView"), + CompletionEntry(label: "db.createCollection", insertText: "db.createCollection"), + CompletionEntry(label: "show dbs", insertText: "show dbs"), + CompletionEntry(label: "show collections", insertText: "show collections"), + CompletionEntry(label: ".find", insertText: ".find"), + CompletionEntry(label: ".findOne", insertText: ".findOne"), + CompletionEntry(label: ".aggregate", insertText: ".aggregate"), + CompletionEntry(label: ".insertOne", insertText: ".insertOne"), + CompletionEntry(label: ".insertMany", insertText: ".insertMany"), + CompletionEntry(label: ".updateOne", insertText: ".updateOne"), + CompletionEntry(label: ".updateMany", insertText: ".updateMany"), + CompletionEntry(label: ".deleteOne", insertText: ".deleteOne"), + CompletionEntry(label: ".deleteMany", insertText: ".deleteMany"), + CompletionEntry(label: ".replaceOne", insertText: ".replaceOne"), + CompletionEntry(label: ".findOneAndUpdate", insertText: ".findOneAndUpdate"), + CompletionEntry(label: ".findOneAndReplace", insertText: ".findOneAndReplace"), + CompletionEntry(label: ".findOneAndDelete", insertText: ".findOneAndDelete"), + CompletionEntry(label: ".countDocuments", insertText: ".countDocuments"), + CompletionEntry(label: ".createIndex", insertText: ".createIndex") + ] + } + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MongoDBPluginDriver(config: config) } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 89a2ed65..75ef5618 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -499,6 +499,17 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { } } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "db.createView(\"view_name\", \"source_collection\", [\n {\"$match\": {}},\n {\"$project\": {\"_id\": 1}}\n])" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let escaped = viewName.replacingOccurrences(of: "\"", with: "\\\"") + return "db.runCommand({\"collMod\": \"\(escaped)\", \"viewOn\": \"source_collection\", \"pipeline\": [{\"$match\": {}}]})" + } + // MARK: - Query Building func buildBrowseQuery( diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index fd4d0db4..b41c3f84 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -602,6 +602,31 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "ALTER VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + + func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS CHAR)" + } + + // MARK: - Foreign Key Checks + + func foreignKeyDisableStatements() -> [String]? { + ["SET FOREIGN_KEY_CHECKS=0"] + } + + func foreignKeyEnableStatements() -> [String]? { + ["SET FOREIGN_KEY_CHECKS=1"] + } + // MARK: - Private Helpers private func extractTableName(from query: String) -> String? { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 52519b0a..51f3775c 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -86,7 +86,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { regexSyntax: .regexpLike, booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, - paginationStyle: .offsetFetch + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY 1" ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { @@ -111,6 +112,17 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { self.config = config } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "CREATE OR REPLACE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + // MARK: - Connection func connect() async throws { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 60746992..6c37aaf9 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -166,6 +166,21 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "CREATE OR REPLACE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + + func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS TEXT)" + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index cae2c1f7..0a9e776a 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -58,6 +58,49 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let sqlDialect: SQLDialectDescriptor? = nil + static var statementCompletions: [CompletionEntry] { + [ + CompletionEntry(label: "GET", insertText: "GET"), + CompletionEntry(label: "SET", insertText: "SET"), + CompletionEntry(label: "DEL", insertText: "DEL"), + CompletionEntry(label: "EXISTS", insertText: "EXISTS"), + CompletionEntry(label: "KEYS", insertText: "KEYS"), + CompletionEntry(label: "HGET", insertText: "HGET"), + CompletionEntry(label: "HSET", insertText: "HSET"), + CompletionEntry(label: "HGETALL", insertText: "HGETALL"), + CompletionEntry(label: "HDEL", insertText: "HDEL"), + CompletionEntry(label: "LPUSH", insertText: "LPUSH"), + CompletionEntry(label: "RPUSH", insertText: "RPUSH"), + CompletionEntry(label: "LRANGE", insertText: "LRANGE"), + CompletionEntry(label: "LLEN", insertText: "LLEN"), + CompletionEntry(label: "SADD", insertText: "SADD"), + CompletionEntry(label: "SMEMBERS", insertText: "SMEMBERS"), + CompletionEntry(label: "SREM", insertText: "SREM"), + CompletionEntry(label: "SCARD", insertText: "SCARD"), + CompletionEntry(label: "ZADD", insertText: "ZADD"), + CompletionEntry(label: "ZRANGE", insertText: "ZRANGE"), + CompletionEntry(label: "ZREM", insertText: "ZREM"), + CompletionEntry(label: "ZSCORE", insertText: "ZSCORE"), + CompletionEntry(label: "EXPIRE", insertText: "EXPIRE"), + CompletionEntry(label: "TTL", insertText: "TTL"), + CompletionEntry(label: "PERSIST", insertText: "PERSIST"), + CompletionEntry(label: "TYPE", insertText: "TYPE"), + CompletionEntry(label: "SCAN", insertText: "SCAN"), + CompletionEntry(label: "HSCAN", insertText: "HSCAN"), + CompletionEntry(label: "SSCAN", insertText: "SSCAN"), + CompletionEntry(label: "ZSCAN", insertText: "ZSCAN"), + CompletionEntry(label: "INFO", insertText: "INFO"), + CompletionEntry(label: "DBSIZE", insertText: "DBSIZE"), + CompletionEntry(label: "FLUSHDB", insertText: "FLUSHDB"), + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "INCR", insertText: "INCR"), + CompletionEntry(label: "DECR", insertText: "DECR"), + CompletionEntry(label: "APPEND", insertText: "APPEND"), + CompletionEntry(label: "MGET", insertText: "MGET"), + CompletionEntry(label: "MSET", insertText: "MSET") + ] + } + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { RedisPluginDriver(config: config) } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 3578c082..3a16f46a 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -399,6 +399,16 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "DEBUG OBJECT \(key)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "-- Redis does not support views" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + "-- Redis does not support views" + } + // MARK: - Query Building func buildBrowseQuery( diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index a7f59246..70bb06a2 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -405,6 +405,27 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN QUERY PLAN \(sql)" } + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE VIEW IF NOT EXISTS view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "DROP VIEW IF EXISTS \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + + // MARK: - Foreign Key Checks + + func foreignKeyDisableStatements() -> [String]? { + ["PRAGMA foreign_keys = OFF"] + } + + func foreignKeyEnableStatements() -> [String]? { + ["PRAGMA foreign_keys = ON"] + } + // MARK: - Pagination func fetchRowCount(query: String) async throws -> Int { diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 101b0078..d6af06d9 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -35,6 +35,7 @@ public protocol DriverPlugin: TableProPlugin { static var defaultGroupName: String { get } static var columnTypesByCategory: [String: [String]] { get } static var sqlDialect: SQLDialectDescriptor? { get } + static var statementCompletions: [CompletionEntry] { get } } public extension DriverPlugin { @@ -74,4 +75,5 @@ public extension DriverPlugin { ] } static var sqlDialect: SQLDialectDescriptor? { nil } + static var statementCompletions: [CompletionEntry] { [] } } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 82f31863..ccd03904 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -116,6 +116,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // String escaping func escapeStringLiteral(_ value: String) -> String + + func createViewTemplate() -> String? + func editViewFallbackTemplate(viewName: String) -> String? + func castColumnToText(_ column: String) -> String } public extension PluginDatabaseDriver { @@ -224,6 +228,10 @@ public extension PluginDatabaseDriver { func buildExplainQuery(_ sql: String) -> String? { nil } + func createViewTemplate() -> String? { nil } + func editViewFallbackTemplate(viewName: String) -> String? { nil } + func castColumnToText(_ column: String) -> String { column } + func quoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" diff --git a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index 5deecdb5..c9b8306f 100644 --- a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -1,5 +1,14 @@ import Foundation +public struct CompletionEntry: Sendable { + public let label: String + public let insertText: String + public init(label: String, insertText: String) { + self.label = label + self.insertText = insertText + } +} + public struct SQLDialectDescriptor: Sendable { public let identifierQuote: String public let keywords: Set @@ -12,6 +21,7 @@ public struct SQLDialectDescriptor: Sendable { public let booleanLiteralStyle: BooleanLiteralStyle public let likeEscapeStyle: LikeEscapeStyle public let paginationStyle: PaginationStyle + public let offsetFetchOrderBy: String public enum RegexSyntax: String, Sendable { case regexp // MySQL: column REGEXP 'pattern' @@ -46,7 +56,8 @@ public struct SQLDialectDescriptor: Sendable { regexSyntax: RegexSyntax = .unsupported, booleanLiteralStyle: BooleanLiteralStyle = .numeric, likeEscapeStyle: LikeEscapeStyle = .explicit, - paginationStyle: PaginationStyle = .limit + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)" ) { self.identifierQuote = identifierQuote self.keywords = keywords @@ -57,5 +68,6 @@ public struct SQLDialectDescriptor: Sendable { self.booleanLiteralStyle = booleanLiteralStyle self.likeEscapeStyle = likeEscapeStyle self.paginationStyle = paginationStyle + self.offsetFetchOrderBy = offsetFetchOrderBy } } diff --git a/TablePro/Core/AI/AISchemaContext.swift b/TablePro/Core/AI/AISchemaContext.swift index 098c463d..458ad384 100644 --- a/TablePro/Core/AI/AISchemaContext.swift +++ b/TablePro/Core/AI/AISchemaContext.swift @@ -26,7 +26,8 @@ struct AISchemaContext { foreignKeys: [String: [ForeignKeyInfo]], currentQuery: String?, queryResults: String?, - settings: AISettings + settings: AISettings, + identifierQuote: String = "\"" ) -> String { var parts: [String] = [] @@ -44,7 +45,7 @@ struct AISchemaContext { columnsByTable: columnsByTable, foreignKeys: foreignKeys, maxTables: settings.maxSchemaTables, - databaseType: databaseType + identifierQuote: identifierQuote ) if !schemaContext.isEmpty { parts.append("\n## Database Schema\n\(schemaContext)") @@ -104,13 +105,13 @@ struct AISchemaContext { columnsByTable: [String: [ColumnInfo]], foreignKeys: [String: [ForeignKeyInfo]], maxTables: Int, - databaseType: DatabaseType + identifierQuote: String ) -> String { let selectedTables = Array(tables.prefix(maxTables)) guard !selectedTables.isEmpty else { return "" } var lines: [String] = [] - let q = databaseType.identifierQuote + let q = identifierQuote for table in selectedTables { var tableLine = "- \(q)\(table.name)\(q)" diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index faabc78a..e0f736b5 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -30,8 +30,18 @@ final class CompletionEngine { // MARK: - Initialization - init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil) { - self.provider = SQLCompletionProvider(schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect) + init( + schemaProvider: SQLSchemaProvider, + databaseType: DatabaseType? = nil, + dialect: SQLDialectDescriptor? = nil, + statementCompletions: [CompletionEntry] = [] + ) { + self.provider = SQLCompletionProvider( + schemaProvider: schemaProvider, + databaseType: databaseType, + dialect: dialect, + statementCompletions: statementCompletions + ) } // MARK: - Public API diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 0ed797c0..246170fe 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -16,6 +16,7 @@ final class SQLCompletionProvider { private let schemaProvider: SQLSchemaProvider private var databaseType: DatabaseType? private var cachedDialect: SQLDialectDescriptor? + private var cachedStatementCompletions: [CompletionEntry] = [] /// Minimum prefix length to trigger suggestions private let minPrefixLength = 1 @@ -25,16 +26,19 @@ final class SQLCompletionProvider { // MARK: - Init - init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil) { + init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType? = nil, + dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = []) { self.schemaProvider = schemaProvider self.databaseType = databaseType self.cachedDialect = dialect + self.cachedStatementCompletions = statementCompletions } /// Update the database type for context-aware completions - func setDatabaseType(_ type: DatabaseType, dialect: SQLDialectDescriptor? = nil) { + func setDatabaseType(_ type: DatabaseType, dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = []) { self.databaseType = type self.cachedDialect = dialect + self.cachedStatementCompletions = statementCompletions } // MARK: - Public API @@ -385,45 +389,12 @@ final class SQLCompletionProvider { items += await schemaProvider.tableCompletionItems() case .unknown: - // Start of query - suggest statement keywords and tables - if databaseType == .redis { - // Redis: command completions - items = [ - "GET", "SET", "DEL", "EXISTS", "KEYS", - "HGET", "HSET", "HGETALL", "HDEL", - "LPUSH", "RPUSH", "LRANGE", "LLEN", - "SADD", "SMEMBERS", "SREM", "SCARD", - "ZADD", "ZRANGE", "ZREM", "ZSCORE", - "EXPIRE", "TTL", "PERSIST", "TYPE", - "SCAN", "HSCAN", "SSCAN", "ZSCAN", - "INFO", "DBSIZE", "FLUSHDB", "SELECT", - "INCR", "DECR", "APPEND", "MGET", "MSET", - ].map { cmd in + if !cachedStatementCompletions.isEmpty { + items = cachedStatementCompletions.map { entry in SQLCompletionItem( - label: cmd, + label: entry.label, kind: .keyword, - insertText: cmd - ) - } - } else if databaseType == .mongodb { - // MongoDB: only MQL method completions, no SQL keywords - items = [ - "db.", "db.runCommand", "db.adminCommand", - "db.createView", "db.createCollection", - "show dbs", "show collections", - ".find", ".findOne", ".aggregate", - ".insertOne", ".insertMany", - ".updateOne", ".updateMany", - ".deleteOne", ".deleteMany", - ".replaceOne", - ".findOneAndUpdate", ".findOneAndReplace", ".findOneAndDelete", - ".countDocuments", ".count", - ".createIndex", ".dropIndex", ".drop", - ].map { mql in - SQLCompletionItem( - label: mql, - kind: .keyword, - insertText: mql + insertText: entry.insertText ) } } else { @@ -454,28 +425,6 @@ final class SQLCompletionProvider { /// so they sort before generic constraint keywords in CREATE TABLE context. /// Uses plugin-provided dialect data when available; falls back to common SQL types. private func dataTypeKeywords() -> [SQLCompletionItem] { - // MongoDB and Redis use case-sensitive, non-SQL types - if databaseType == .mongodb { - return [ - "ObjectId", "String", "Int32", "Int64", "Double", "Decimal128", - "Boolean", "Date", "Timestamp", "BinData", "Array", "Object", - "Null", "Regex", "UUID" - ].map { typeName in - var item = SQLCompletionItem(label: typeName, kind: .keyword, insertText: typeName) - item.sortPriority = 380 - return item - } - } - if databaseType == .redis { - return [ - "String", "List", "Set", "Sorted Set", "Hash", "Stream" - ].map { typeName in - var item = SQLCompletionItem(label: typeName, kind: .keyword, insertText: typeName) - item.sortPriority = 380 - return item - } - } - if let descriptor = cachedDialect, !descriptor.dataTypes.isEmpty { return descriptor.dataTypes.sorted().map { typeName in var item = SQLCompletionItem(label: typeName, kind: .keyword, insertText: typeName) diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 0abbe213..b15ca164 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit /// Provides cached database schema information for autocomplete actor SQLSchemaProvider { @@ -139,6 +140,11 @@ actor SQLSchemaProvider { } } + let dbType = connection.type + let idQuote = await MainActor.run { + PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" + } + return AISchemaContext.buildSystemPrompt( databaseType: connection.type, databaseName: connection.database, @@ -147,7 +153,8 @@ actor SQLSchemaProvider { foreignKeys: [:], currentQuery: nil, queryResults: nil, - settings: settings + settings: settings, + identifierQuote: idQuote ) } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 6c89eccf..8195c534 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -33,6 +33,7 @@ struct SQLStatementGenerator { primaryKeyColumn: String?, databaseType: DatabaseType, parameterStyle: ParameterStyle? = nil, + dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil ) { self.tableName = tableName @@ -40,7 +41,7 @@ struct SQLStatementGenerator { self.primaryKeyColumn = primaryKeyColumn self.databaseType = databaseType self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType) - self.quoteIdentifierFn = quoteIdentifier ?? databaseType.quoteIdentifier + self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) } private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index e5be8aa6..9d4dacb9 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -146,6 +146,13 @@ protocol DatabaseDriver: AnyObject { /// Escape a string value for safe use in SQL string literals func escapeStringLiteral(_ value: String) -> String + + func createViewTemplate() -> String? + func editViewFallbackTemplate(viewName: String) -> String? + func castColumnToText(_ column: String) -> String + + func foreignKeyDisableStatements() -> [String]? + func foreignKeyEnableStatements() -> [String]? } // MARK: - Schema Switching @@ -179,6 +186,13 @@ extension DatabaseDriver { return result } + func createViewTemplate() -> String? { nil } + func editViewFallbackTemplate(viewName: String) -> String? { nil } + func castColumnToText(_ column: String) -> String { column } + + func foreignKeyDisableStatements() -> [String]? { nil } + func foreignKeyEnableStatements() -> [String]? { nil } + func testConnection() async throws -> Bool { try await connect() disconnect() diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index c740d844..64e30a65 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -10,73 +10,15 @@ import TableProPluginKit /// Generates SQL WHERE clauses from filter definitions struct FilterSQLGenerator { - let databaseType: DatabaseType private let dialect: SQLDialectDescriptor private let quoteIdentifierFn: (String) -> String init( - databaseType: DatabaseType, - dialect: SQLDialectDescriptor? = nil, + dialect: SQLDialectDescriptor, quoteIdentifier: ((String) -> String)? = nil ) { - self.databaseType = databaseType - self.dialect = dialect ?? Self.fallbackDialect(for: databaseType) - self.quoteIdentifierFn = quoteIdentifier ?? databaseType.quoteIdentifier - } - - /// Fallback dialect properties when no plugin-provided descriptor is available. - /// Preserves pre-existing behavior for each database type. - private static func fallbackDialect(for databaseType: DatabaseType) -> SQLDialectDescriptor { - switch databaseType { - case .mysql, .mariadb: - return SQLDialectDescriptor( - identifierQuote: "`", keywords: [], functions: [], dataTypes: [], - regexSyntax: .regexp, booleanLiteralStyle: .numeric, - likeEscapeStyle: .implicit, paginationStyle: .limit - ) - case .postgresql, .redshift: - return SQLDialectDescriptor( - identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], - regexSyntax: .tilde, booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, paginationStyle: .limit - ) - case .sqlite: - return SQLDialectDescriptor( - identifierQuote: "`", keywords: [], functions: [], dataTypes: [], - regexSyntax: .unsupported, booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, paginationStyle: .limit - ) - case .clickhouse: - return SQLDialectDescriptor( - identifierQuote: "`", keywords: [], functions: [], dataTypes: [], - regexSyntax: .match, booleanLiteralStyle: .numeric, - likeEscapeStyle: .implicit, paginationStyle: .limit - ) - case .mssql: - return SQLDialectDescriptor( - identifierQuote: "[", keywords: [], functions: [], dataTypes: [], - regexSyntax: .unsupported, booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, paginationStyle: .offsetFetch - ) - case .oracle: - return SQLDialectDescriptor( - identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], - regexSyntax: .regexpLike, booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, paginationStyle: .offsetFetch - ) - case .duckdb: - return SQLDialectDescriptor( - identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], - regexSyntax: .regexpMatches, booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, paginationStyle: .limit - ) - case .mongodb, .redis: - return SQLDialectDescriptor( - identifierQuote: "`", keywords: [], functions: [], dataTypes: [], - regexSyntax: .unsupported, booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, paginationStyle: .limit - ) - } + self.dialect = dialect + self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) } // MARK: - Public API @@ -169,8 +111,6 @@ struct FilterSQLGenerator { return "\(quotedColumn) BETWEEN \(escapeValue(filter.value)) AND \(escapeValue(secondValue))" case .regex: - // MongoDB/Redis filters are handled natively by their query builders - if databaseType == .mongodb || databaseType == .redis { return nil } let syntax = dialect.regexSyntax if syntax == .unsupported { let escaped = escapeSQLQuote(filter.value) @@ -340,7 +280,7 @@ extension FilterSQLGenerator { } if dialect.paginationStyle == .offsetFetch { - let orderBy = databaseType == .oracle ? "ORDER BY 1" : "ORDER BY (SELECT NULL)" + let orderBy = dialect.offsetFetchOrderBy sql += "\n\(orderBy) OFFSET 0 ROWS FETCH NEXT \(limit) ROWS ONLY" } else { sql += "\nLIMIT \(limit)" diff --git a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index a0e485b1..f4ccd3c6 100644 --- a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -10,13 +10,11 @@ import TableProPluginKit final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { let databaseTypeId: String private let driver: DatabaseDriver - private let dbType: DatabaseType private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDataSinkAdapter") init(driver: DatabaseDriver, databaseType: DatabaseType) { self.driver = driver - self.dbType = databaseType self.databaseTypeId = databaseType.rawValue } @@ -37,42 +35,16 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { } func disableForeignKeyChecks() async throws { - for stmt in fkDisableStatements() { + guard let statements = driver.foreignKeyDisableStatements() else { return } + for stmt in statements { _ = try await driver.execute(query: stmt) } } func enableForeignKeyChecks() async throws { - for stmt in fkEnableStatements() { + guard let statements = driver.foreignKeyEnableStatements() else { return } + for stmt in statements { _ = try await driver.execute(query: stmt) } } - - // MARK: - FK Statements - - private func fkDisableStatements() -> [String] { - switch dbType { - case .mysql, .mariadb: - return ["SET FOREIGN_KEY_CHECKS=0"] - case .postgresql, .redshift, .mssql, .oracle: - return [] - case .sqlite: - return ["PRAGMA foreign_keys = OFF"] - case .mongodb, .redis, .clickhouse, .duckdb: - return [] - } - } - - private func fkEnableStatements() -> [String] { - switch dbType { - case .mysql, .mariadb: - return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .mssql, .oracle: - return [] - case .sqlite: - return ["PRAGMA foreign_keys = ON"] - case .mongodb, .redis, .clickhouse, .duckdb: - return [] - } - } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index be5102d3..899b943b 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -356,6 +356,20 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.buildExplainQuery(sql) } + // MARK: - View Templates + + func createViewTemplate() -> String? { + pluginDriver.createViewTemplate() + } + + func editViewFallbackTemplate(viewName: String) -> String? { + pluginDriver.editViewFallbackTemplate(viewName: viewName) + } + + func castColumnToText(_ column: String) -> String { + pluginDriver.castColumnToText(column) + } + // MARK: - Identifier Quoting func quoteIdentifier(_ name: String) -> String { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index c209de0f..0c493843 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -337,6 +337,12 @@ final class PluginManager { return Swift.type(of: plugin).sqlDialect } + func statementCompletions(for databaseType: DatabaseType) -> [CompletionEntry] { + loadPendingPlugins() + guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return [] } + return Swift.type(of: plugin).statementCompletions + } + func additionalConnectionFields(for databaseType: DatabaseType) -> [ConnectionField] { loadPendingPlugins() guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return [] } diff --git a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift new file mode 100644 index 00000000..69eb3ccf --- /dev/null +++ b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift @@ -0,0 +1,26 @@ +// +// DialectQuoteHelper.swift +// TablePro +// +// Builds an identifier-quoting closure from a SQL dialect descriptor. +// + +import Foundation +import TableProPluginKit + +/// Build an identifier-quoting closure from a dialect descriptor. +/// NoSQL databases (nil dialect) use identity (return name as-is). +func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor?) -> (String) -> String { + guard let dialect else { return { $0 } } + let q = dialect.identifierQuote + if q == "[" { + return { name in + let escaped = name.replacingOccurrences(of: "]", with: "]]") + return "[\(escaped)]" + } + } + return { name in + let escaped = name.replacingOccurrences(of: q, with: q + q) + return "\(q)\(escaped)\(q)" + } +} diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 8bfb009e..a4c6ba47 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -3,6 +3,7 @@ // TablePro import Foundation +import TableProPluginKit internal struct SQLRowToStatementConverter { internal let tableName: String @@ -17,6 +18,7 @@ internal struct SQLRowToStatementConverter { columns: [String], primaryKeyColumn: String?, databaseType: DatabaseType, + dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil, escapeStringLiteral: ((String) -> String)? = nil ) { @@ -24,7 +26,7 @@ internal struct SQLRowToStatementConverter { self.columns = columns self.primaryKeyColumn = primaryKeyColumn self.databaseType = databaseType - self.quoteIdentifierFn = quoteIdentifier ?? databaseType.quoteIdentifier + self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(for: databaseType) } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 931d656d..e47b41d9 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -311,33 +311,6 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { } } - /// Quote character for identifiers (table/column names) - /// MySQL/MariaDB/SQLite use backticks, PostgreSQL uses double quotes - var identifierQuote: String { - switch self { - case .mysql, .mariadb, .sqlite, .clickhouse: - return "`" - case .postgresql, .redshift, .mongodb, .redis, .oracle, .duckdb: - return "\"" - case .mssql: - return "[" - } - } - - /// Quote an identifier (table or column name) for this database type. - /// Escapes embedded quote characters to prevent SQL injection. - func quoteIdentifier(_ name: String) -> String { - switch self { - case .mongodb, .redis: - return name - case .mssql: - return "[\(name.replacingOccurrences(of: "]", with: "]]"))]" - default: - let q = identifierQuote - let escaped = name.replacingOccurrences(of: q, with: q + q) - return "\(q)\(escaped)\(q)" - } - } } // MARK: - Connection Color diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 220f6140..b5e64df1 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -436,7 +436,7 @@ struct QueryTab: Identifiable, Equatable { databaseType: DatabaseType, quoteIdentifier: ((String) -> String)? = nil ) -> String { - let quote = quoteIdentifier ?? databaseType.quoteIdentifier + let quote = quoteIdentifier ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: databaseType)) let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize if databaseType == .mongodb { let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 155bf2f0..52a4a7a0 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -356,8 +356,10 @@ final class FilterStateManager { /// Generate preview SQL for the "SQL" button /// Uses selected filters if any are selected, otherwise uses all valid filters func generatePreviewSQL(databaseType: DatabaseType) -> String { - let dialect = PluginManager.shared.sqlDialect(for: databaseType) - let generator = FilterSQLGenerator(databaseType: databaseType, dialect: dialect) + guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { + return "-- Filters are applied natively" + } + let generator = FilterSQLGenerator(dialect: dialect) let filtersToPreview = getFiltersForPreview() // If no valid filters but filters exist, show helpful message diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 9733571f..9a294bba 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Observation import os +import TableProPluginKit /// View model for the AI chat panel @MainActor @Observable @@ -412,6 +413,7 @@ final class AIChatViewModel { private func buildSystemPrompt(settings: AISettings) -> String? { guard let connection else { return nil } + let idQuote = PluginManager.shared.sqlDialect(for: connection.type)?.identifierQuote ?? "\"" return AISchemaContext.buildSystemPrompt( databaseType: connection.type, databaseName: connection.database, @@ -420,7 +422,8 @@ final class AIChatViewModel { foreignKeys: foreignKeysByTable, currentQuery: settings.includeCurrentQuery ? currentQuery : nil, queryResults: settings.includeQueryResults ? queryResults : nil, - settings: settings + settings: settings, + identifierQuote: idQuote ) } } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index 2519e699..dd9b5ec8 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -26,14 +26,22 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil) { if let provider = schemaProvider { let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - self.completionEngine = CompletionEngine(schemaProvider: provider, databaseType: databaseType, dialect: dialect) + let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + self.completionEngine = CompletionEngine( + schemaProvider: provider, databaseType: databaseType, + dialect: dialect, statementCompletions: completions + ) } } /// Update the schema provider (e.g. when connection changes) func updateSchemaProvider(_ provider: SQLSchemaProvider, databaseType: DatabaseType? = nil) { let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - self.completionEngine = CompletionEngine(schemaProvider: provider, databaseType: databaseType, dialect: dialect) + let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + self.completionEngine = CompletionEngine( + schemaProvider: provider, databaseType: databaseType, + dialect: dialect, statementCompletions: completions + ) } // MARK: - CodeSuggestionDelegate diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index fd6d7f93..7ad42b2f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -15,23 +15,9 @@ extension MainContentCoordinator { func createView() { guard !connection.safeModeLevel.blocksAllWrites else { return } - let template: String - switch connection.type { - case .postgresql, .redshift, .duckdb: - template = "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - case .mysql, .mariadb, .clickhouse: - template = "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - case .sqlite: - template = "CREATE VIEW IF NOT EXISTS view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - case .mssql: - template = "CREATE OR ALTER VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - case .oracle: - template = "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - case .mongodb: - template = "db.createView(\"view_name\", \"source_collection\", [\n {\"$match\": {}},\n {\"$project\": {\"_id\": 1}}\n])" - case .redis: - template = "-- Redis does not support views" - } + let driver = DatabaseManager.shared.driver(for: connection.id) + let template = driver?.createViewTemplate() + ?? "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" let payload = EditorTabPayload( connectionId: connection.id, @@ -55,23 +41,10 @@ extension MainContentCoordinator { ) WindowOpener.shared.openNativeTab(payload) } catch { - let fallbackSQL: String - switch connection.type { - case .postgresql, .redshift, .duckdb: - fallbackSQL = "CREATE OR REPLACE VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" - case .mysql, .mariadb, .clickhouse: - fallbackSQL = "ALTER VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" - case .sqlite: - fallbackSQL = "-- SQLite does not support ALTER VIEW. Drop and recreate:\nDROP VIEW IF EXISTS \(viewName);\nCREATE VIEW \(viewName) AS\nSELECT * FROM table_name;" - case .mssql: - fallbackSQL = "CREATE OR ALTER VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" - case .oracle: - fallbackSQL = "CREATE OR REPLACE VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" - case .mongodb: - fallbackSQL = "db.runCommand({\"collMod\": \"\(viewName)\", \"viewOn\": \"source_collection\", \"pipeline\": [{\"$match\": {}}]})" - case .redis: - fallbackSQL = "-- Redis does not support views" - } + let driver = DatabaseManager.shared.driver(for: self.connection.id) + let template = driver?.editViewFallbackTemplate(viewName: viewName) + ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" + let fallbackSQL = "-- Could not fetch view definition: \(error.localizedDescription)\n\(template)" let payload = EditorTabPayload( connectionId: connection.id, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 293cc984..7ee80105 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -33,7 +33,8 @@ extension MainContentCoordinator { var statements: [String] = [] let dbType = connection.type let driver = DatabaseManager.shared.driver(for: connectionId) - let quote: (String) -> String = driver?.quoteIdentifier ?? dbType.quoteIdentifier + let quote: (String) -> String = driver?.quoteIdentifier + ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: dbType)) // Sort tables for consistent execution order let sortedTruncates = truncates.sorted() diff --git a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift index 43281da1..e5b73df3 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift @@ -6,12 +6,19 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @Suite("Filter SQL Generator MSSQL") struct FilterSQLGeneratorMSSQLTests { - private let generator = FilterSQLGenerator(databaseType: .mssql) + private static let mssqlDialect = SQLDialectDescriptor( + identifierQuote: "[", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .offsetFetch + ) + + private let generator = FilterSQLGenerator(dialect: Self.mssqlDialect) // MARK: - Helpers diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 093b0413..6146c6fe 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -6,17 +6,55 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @Suite("Filter SQL Generator") struct FilterSQLGeneratorTests { + private static let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexp, booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + + private static let postgresqlDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .tilde, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static let sqliteDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static let clickhouseDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .match, booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + + private static let duckdbDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpMatches, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static let oracleDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpLike, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY 1" + ) + // MARK: - Per-Operator Tests (MySQL) @Test("Equal operator generates correct condition") func testEqualOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -33,7 +71,7 @@ struct FilterSQLGeneratorTests { @Test("Not equal operator generates correct condition") func testNotEqualOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -50,7 +88,7 @@ struct FilterSQLGeneratorTests { @Test("Contains operator generates correct LIKE condition") func testContainsOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -67,7 +105,7 @@ struct FilterSQLGeneratorTests { @Test("Not contains operator generates correct NOT LIKE condition") func testNotContainsOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -84,7 +122,7 @@ struct FilterSQLGeneratorTests { @Test("Starts with operator generates correct LIKE condition") func testStartsWithOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -101,7 +139,7 @@ struct FilterSQLGeneratorTests { @Test("Ends with operator generates correct LIKE condition") func testEndsWithOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -118,7 +156,7 @@ struct FilterSQLGeneratorTests { @Test("Greater than operator generates correct condition") func testGreaterThanOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -135,7 +173,7 @@ struct FilterSQLGeneratorTests { @Test("Greater or equal operator generates correct condition") func testGreaterOrEqualOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -152,7 +190,7 @@ struct FilterSQLGeneratorTests { @Test("Less than operator generates correct condition") func testLessThanOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -169,7 +207,7 @@ struct FilterSQLGeneratorTests { @Test("Less or equal operator generates correct condition") func testLessOrEqualOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -186,7 +224,7 @@ struct FilterSQLGeneratorTests { @Test("Is null operator generates correct condition") func testIsNullOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -203,7 +241,7 @@ struct FilterSQLGeneratorTests { @Test("Is not null operator generates correct condition") func testIsNotNullOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -220,7 +258,7 @@ struct FilterSQLGeneratorTests { @Test("Is empty operator generates correct condition") func testIsEmptyOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -237,7 +275,7 @@ struct FilterSQLGeneratorTests { @Test("Is not empty operator generates correct condition") func testIsNotEmptyOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -254,7 +292,7 @@ struct FilterSQLGeneratorTests { @Test("In list operator generates correct IN condition") func testInListOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", @@ -271,7 +309,7 @@ struct FilterSQLGeneratorTests { @Test("Not in list operator generates correct NOT IN condition") func testNotInListOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", @@ -288,7 +326,7 @@ struct FilterSQLGeneratorTests { @Test("Between operator generates correct BETWEEN condition") func testBetweenOperator() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -305,7 +343,7 @@ struct FilterSQLGeneratorTests { @Test("Regex operator generates correct REGEXP condition for MySQL") func testRegexOperatorMySQL() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "email", @@ -324,7 +362,7 @@ struct FilterSQLGeneratorTests { @Test("NULL literal generates unquoted NULL") func testNullLiteral() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -341,7 +379,7 @@ struct FilterSQLGeneratorTests { @Test("TRUE literal generates 1 for MySQL") func testTrueLiteralMySQL() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -358,7 +396,7 @@ struct FilterSQLGeneratorTests { @Test("FALSE literal generates 0 for MySQL") func testFalseLiteralMySQL() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -375,7 +413,7 @@ struct FilterSQLGeneratorTests { @Test("Numeric value generates unquoted number") func testNumericValue() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -392,7 +430,7 @@ struct FilterSQLGeneratorTests { @Test("String value generates quoted string") func testStringValue() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -411,7 +449,7 @@ struct FilterSQLGeneratorTests { @Test("AND mode with 2 filters generates AND clause") func testAndMode() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters = [ TableFilter( id: UUID(), @@ -440,7 +478,7 @@ struct FilterSQLGeneratorTests { @Test("OR mode with 2 filters generates OR clause") func testOrMode() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters = [ TableFilter( id: UUID(), @@ -469,7 +507,7 @@ struct FilterSQLGeneratorTests { @Test("Empty filters generates empty string") func testEmptyFilters() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters: [TableFilter] = [] let result = generator.generateWhereClause(from: filters, logicMode: .and) #expect(result == "") @@ -477,7 +515,7 @@ struct FilterSQLGeneratorTests { @Test("Single filter generates no AND/OR") func testSingleFilter() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters = [ TableFilter( id: UUID(), @@ -496,7 +534,7 @@ struct FilterSQLGeneratorTests { @Test("Invalid filter is skipped") func testInvalidFilterSkipped() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters = [ TableFilter( id: UUID(), @@ -527,7 +565,7 @@ struct FilterSQLGeneratorTests { @Test("Single quote in value is escaped") func testSingleQuoteEscaping() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -544,7 +582,7 @@ struct FilterSQLGeneratorTests { @Test("Column with special chars is quoted properly") func testColumnQuoting() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "user name", @@ -561,7 +599,7 @@ struct FilterSQLGeneratorTests { @Test("Raw SQL mode generates condition from rawSQL") func testRawSQLMode() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "__RAW__", @@ -580,7 +618,7 @@ struct FilterSQLGeneratorTests { @Test("MySQL uses backtick quoting") func testMySQLQuoting() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -597,7 +635,7 @@ struct FilterSQLGeneratorTests { @Test("PostgreSQL uses double quote quoting") func testPostgreSQLQuoting() { - let generator = FilterSQLGenerator(databaseType: .postgresql) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -614,7 +652,7 @@ struct FilterSQLGeneratorTests { @Test("SQLite uses backtick quoting") func testSQLiteQuoting() { - let generator = FilterSQLGenerator(databaseType: .sqlite) + let generator = FilterSQLGenerator(dialect: Self.sqliteDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -631,7 +669,7 @@ struct FilterSQLGeneratorTests { @Test("MariaDB uses backtick quoting") func testMariaDBQuoting() { - let generator = FilterSQLGenerator(databaseType: .mariadb) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -650,7 +688,7 @@ struct FilterSQLGeneratorTests { @Test("Contains with percent in value escapes percent") func testPercentEscaping() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -667,7 +705,7 @@ struct FilterSQLGeneratorTests { @Test("Contains with underscore in value escapes underscore") func testUnderscoreEscaping() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -684,7 +722,7 @@ struct FilterSQLGeneratorTests { @Test("Starts with escapes special chars") func testStartsWithSpecialChars() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -703,7 +741,7 @@ struct FilterSQLGeneratorTests { @Test("MySQL regex uses REGEXP") func testMySQLRegex() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "email", @@ -720,7 +758,7 @@ struct FilterSQLGeneratorTests { @Test("PostgreSQL regex uses tilde operator") func testPostgreSQLRegex() { - let generator = FilterSQLGenerator(databaseType: .postgresql) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "email", @@ -737,7 +775,7 @@ struct FilterSQLGeneratorTests { @Test("SQLite regex falls back to LIKE") func testSQLiteRegex() { - let generator = FilterSQLGenerator(databaseType: .sqlite) + let generator = FilterSQLGenerator(dialect: Self.sqliteDialect) let filter = TableFilter( id: UUID(), columnName: "email", @@ -756,7 +794,7 @@ struct FilterSQLGeneratorTests { @Test("Preview SQL includes SELECT FROM WHERE LIMIT") func testPreviewSQL() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters = [ TableFilter( id: UUID(), @@ -778,7 +816,7 @@ struct FilterSQLGeneratorTests { @Test("Preview SQL without filters has no WHERE") func testPreviewSQLNoFilters() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filters: [TableFilter] = [] let result = generator.generatePreviewSQL(tableName: "users", filters: filters, limit: 1000) #expect(result.contains("SELECT * FROM")) @@ -791,7 +829,7 @@ struct FilterSQLGeneratorTests { @Test("Between with missing secondValue returns nil") func testBetweenMissingSecondValue() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "age", @@ -808,7 +846,7 @@ struct FilterSQLGeneratorTests { @Test("InList with empty value returns nil") func testInListEmptyValue() { - let generator = FilterSQLGenerator(databaseType: .mysql) + let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", @@ -825,7 +863,7 @@ struct FilterSQLGeneratorTests { @Test("TRUE literal generates TRUE for PostgreSQL") func testTrueLiteralPostgreSQL() { - let generator = FilterSQLGenerator(databaseType: .postgresql) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -842,7 +880,7 @@ struct FilterSQLGeneratorTests { @Test("FALSE literal generates FALSE for PostgreSQL") func testFalseLiteralPostgreSQL() { - let generator = FilterSQLGenerator(databaseType: .postgresql) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -861,7 +899,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift uses double-quote identifier quoting") func testRedshiftQuoting() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -878,7 +916,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift regex uses tilde operator") func testRedshiftRegex() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "email", @@ -895,7 +933,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift TRUE literal generates TRUE") func testTrueLiteralRedshift() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -912,7 +950,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift FALSE literal generates FALSE") func testFalseLiteralRedshift() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "active", @@ -929,7 +967,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift LIKE uses ESCAPE clause") func testRedshiftLikeEscape() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", @@ -946,7 +984,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift AND mode with 2 filters generates AND clause") func testRedshiftAndMode() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filters = [ TableFilter( id: UUID(), @@ -975,7 +1013,7 @@ struct FilterSQLGeneratorTests { @Test("Redshift OR mode with 2 filters generates OR clause") func testRedshiftOrMode() { - let generator = FilterSQLGenerator(databaseType: .redshift) + let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) let filters = [ TableFilter( id: UUID(), diff --git a/TableProTests/Models/DatabaseTypeMSSQLTests.swift b/TableProTests/Models/DatabaseTypeMSSQLTests.swift index 9dc776c2..7ba6f668 100644 --- a/TableProTests/Models/DatabaseTypeMSSQLTests.swift +++ b/TableProTests/Models/DatabaseTypeMSSQLTests.swift @@ -23,11 +23,6 @@ struct DatabaseTypeMSSQLTests { #expect(DatabaseType.mssql.rawValue == "SQL Server") } - @Test("identifierQuote is open bracket") - func identifierQuote() { - #expect(DatabaseType.mssql.identifierQuote == "[") - } - @Test("requiresAuthentication is true") func requiresAuthentication() { #expect(DatabaseType.mssql.requiresAuthentication == true) @@ -48,33 +43,6 @@ struct DatabaseTypeMSSQLTests { #expect(DatabaseType.mssql.iconName == "mssql-icon") } - // MARK: - quoteIdentifier Tests - - @Test("quoteIdentifier wraps simple name with brackets") - func quoteIdentifierSimple() { - #expect(DatabaseType.mssql.quoteIdentifier("users") == "[users]") - } - - @Test("quoteIdentifier handles name with spaces") - func quoteIdentifierWithSpaces() { - #expect(DatabaseType.mssql.quoteIdentifier("my table") == "[my table]") - } - - @Test("quoteIdentifier escapes embedded closing bracket") - func quoteIdentifierWithEmbeddedBracket() { - #expect(DatabaseType.mssql.quoteIdentifier("user]s") == "[user]]s]") - } - - @Test("quoteIdentifier handles empty name") - func quoteIdentifierEmpty() { - #expect(DatabaseType.mssql.quoteIdentifier("") == "[]") - } - - @Test("quoteIdentifier escapes multiple embedded closing brackets") - func quoteIdentifierMultipleBrackets() { - #expect(DatabaseType.mssql.quoteIdentifier("a]b]c") == "[a]]b]]c]") - } - // MARK: - allCases Tests @Test("allCases contains mssql") diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift index 20185326..c2a3dad1 100644 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ b/TableProTests/Models/DatabaseTypeRedisTests.swift @@ -28,16 +28,6 @@ struct DatabaseTypeRedisTests { #expect(DatabaseType.redis.supportsSchemaEditing == false) } - @Test("Identifier quote is double quote") - func identifierQuote() { - #expect(DatabaseType.redis.identifierQuote == "\"") - } - - @Test("quoteIdentifier returns name unchanged") - func quoteIdentifier() { - #expect(DatabaseType.redis.quoteIdentifier("mykey") == "mykey") - } - @Test("Raw value is Redis") func rawValue() { #expect(DatabaseType.redis.rawValue == "Redis") diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 09710a26..53b1bad7 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -37,43 +37,9 @@ struct DatabaseTypeTests { #expect(DatabaseType.mongodb.defaultPort == 27_017) } - @Test("MySQL identifier quote is backtick") - func testMySQLIdentifierQuote() { - #expect(DatabaseType.mysql.identifierQuote == "`") - } - - @Test("PostgreSQL identifier quote is double quote") - func testPostgreSQLIdentifierQuote() { - #expect(DatabaseType.postgresql.identifierQuote == "\"") - } - - @Test("Quote identifier simple name for MySQL") - func testQuoteIdentifierSimpleNameMySQL() { - let result = DatabaseType.mysql.quoteIdentifier("users") - #expect(result == "`users`") - } - - @Test("Quote identifier simple name for PostgreSQL") - func testQuoteIdentifierSimpleNamePostgreSQL() { - let result = DatabaseType.postgresql.quoteIdentifier("users") - #expect(result == "\"users\"") - } - - @Test("Quote identifier with embedded backtick for MySQL") - func testQuoteIdentifierWithEmbeddedBacktickMySQL() { - let result = DatabaseType.mysql.quoteIdentifier("user`s") - #expect(result == "`user``s`") - } - - @Test("Quote identifier with embedded double quote for PostgreSQL") - func testQuoteIdentifierWithEmbeddedDoubleQuotePostgreSQL() { - let result = DatabaseType.postgresql.quoteIdentifier("user\"s") - #expect(result == "\"user\"\"s\"") - } - - @Test("CaseIterable count is 10") + @Test("CaseIterable count is 11") func testCaseIterableCount() { - #expect(DatabaseType.allCases.count == 10) + #expect(DatabaseType.allCases.count == 11) } @Test("Raw value matches display name", arguments: [ @@ -86,7 +52,8 @@ struct DatabaseTypeTests { (DatabaseType.redshift, "Redshift"), (DatabaseType.mssql, "SQL Server"), (DatabaseType.oracle, "Oracle"), - (DatabaseType.clickhouse, "ClickHouse") + (DatabaseType.clickhouse, "ClickHouse"), + (DatabaseType.duckdb, "DuckDB") ]) func testRawValueMatchesDisplayName(dbType: DatabaseType, expectedRawValue: String) { #expect(dbType.rawValue == expectedRawValue) @@ -99,11 +66,6 @@ struct DatabaseTypeTests { #expect(DatabaseType.clickhouse.defaultPort == 8_123) } - @Test("ClickHouse identifier quote is backtick") - func testClickHouseIdentifierQuote() { - #expect(DatabaseType.clickhouse.identifierQuote == "`") - } - @Test("ClickHouse requires authentication") func testClickHouseRequiresAuth() { #expect(DatabaseType.clickhouse.requiresAuthentication == true)