From 5d0521ef0a6e03d3c75c89bf7d31f64e74fe1d64 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 15:32:06 +0700 Subject: [PATCH 01/15] feat: extend ConnectionField with number, toggle, and stepper field types --- CHANGELOG.md | 2 +- Plugins/RedisDriverPlugin/RedisPlugin.swift | 9 +- .../TableProPluginKit/ConnectionField.swift | 20 ++++ TablePro/Core/Database/DatabaseDriver.swift | 2 - .../Views/Connection/ConnectionFieldRow.swift | 24 ++++ .../Views/Connection/ConnectionFormView.swift | 30 ++--- .../Core/Plugins/ConnectionFieldTests.swift | 103 ++++++++++++++++++ 7 files changed, 167 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7791aadc..4cf3af79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel -- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, and dropdown field types +- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, dropdown, number, toggle, and stepper field types - Configurable plugin registry URL via `defaults write com.TablePro com.TablePro.customRegistryURL ` for enterprise/private registries - SQL import options (wrap in transaction, disable FK checks) now persist across launches - `needsRestart` banner persists across app quit/relaunch after plugin uninstall diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 1f83993c..34c217ab 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -21,7 +21,14 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "Redis" static let iconName = "cylinder.fill" static let defaultPort = 6379 - static let additionalConnectionFields: [ConnectionField] = [] + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "redisDatabase", + label: String(localized: "Database Index"), + defaultValue: "0", + fieldType: .stepper(range: ConnectionField.IntRange(0...15)) + ), + ] static let additionalDatabaseTypeIds: [String] = [] // MARK: - UI/Capability Metadata diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index b8d38246..6398957d 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -1,10 +1,30 @@ import Foundation public struct ConnectionField: Codable, Sendable { + public struct IntRange: Codable, Sendable, Equatable { + public let lowerBound: Int + public let upperBound: Int + + public init(_ range: ClosedRange) { + self.lowerBound = range.lowerBound + self.upperBound = range.upperBound + } + + public init(lowerBound: Int, upperBound: Int) { + self.lowerBound = lowerBound + self.upperBound = upperBound + } + + public var closedRange: ClosedRange { lowerBound...upperBound } + } + public enum FieldType: Codable, Sendable, Equatable { case text case secure case dropdown(options: [DropdownOption]) + case number + case toggle + case stepper(range: IntRange) } public struct DropdownOption: Codable, Sendable, Equatable { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index fd1e45f2..90f693a8 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -359,8 +359,6 @@ enum DatabaseDriverFactory { switch connection.type { case .mongodb: fields["sslCACertPath"] = ssl.caCertificatePath - case .redis: - fields["redisDatabase"] = String(connection.redisDatabase ?? 0) default: break } diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index a867734b..fd7abb5f 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -30,6 +30,30 @@ struct ConnectionFieldRow: View { Text(option.label).tag(option.value) } } + case .number: + TextField( + field.label, + text: $value, + prompt: field.placeholder.isEmpty ? nil : Text(field.placeholder) + ) + case .toggle: + Toggle( + field.label, + isOn: Binding( + get: { value == "true" }, + set: { value = $0 ? "true" : "false" } + ) + ) + case .stepper(let range): + Stepper( + value: Binding( + get: { Int(value) ?? range.lowerBound }, + set: { value = String($0) } + ), + in: range.closedRange + ) { + Text("\(field.label): \(Int(value) ?? range.lowerBound)") + } } } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index fcdc31a0..f6e7dfb6 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -650,20 +650,6 @@ struct ConnectionFormView: View { } } - if type == .redis { - Section("Redis") { - Stepper( - value: Binding( - get: { Int(database) ?? 0 }, - set: { database = String($0) } - ), - in: 0...15 - ) { - Text(String(localized: "Database Index: \(Int(database) ?? 0)")) - } - } - } - Section(String(localized: "Startup Commands")) { StartupCommandsEditor(text: $startupCommands) .frame(height: 80) @@ -883,9 +869,11 @@ struct ConnectionFormView: View { } } - // Load Redis settings (special case) - if existing.type == .redis, let rdb = existing.redisDatabase { - database = String(rdb) + // Migrate legacy Redis database index into additionalFieldValues + if existing.type == .redis, + additionalFieldValues["redisDatabase"] == nil, + let rdb = existing.redisDatabase { + additionalFieldValues["redisDatabase"] = String(rdb) } // Load startup commands @@ -965,7 +953,9 @@ struct ConnectionFormView: View { groupId: selectedGroupId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - redisDatabase: type == .redis ? (Int(database) ?? 0) : nil, + redisDatabase: type == .redis + ? Int(additionalFieldValues["redisDatabase"] ?? "0") + : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields @@ -1108,7 +1098,9 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - redisDatabase: type == .redis ? (Int(database) ?? 0) : nil, + redisDatabase: type == .redis + ? Int(additionalFieldValues["redisDatabase"] ?? "0") + : nil, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields diff --git a/TableProTests/Core/Plugins/ConnectionFieldTests.swift b/TableProTests/Core/Plugins/ConnectionFieldTests.swift index 642c0836..39a9c2eb 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldTests.swift @@ -153,4 +153,107 @@ struct ConnectionFieldTests { #expect(decoded.id == field.id) #expect(decoded.fieldType == .text) } + + // MARK: - IntRange + + @Test("IntRange init from ClosedRange") + func intRangeFromClosedRange() { + let range = ConnectionField.IntRange(0...15) + #expect(range.lowerBound == 0) + #expect(range.upperBound == 15) + } + + @Test("IntRange closedRange round-trip") + func intRangeClosedRangeRoundTrip() { + let range = ConnectionField.IntRange(3...42) + #expect(range.closedRange == 3...42) + } + + @Test("IntRange init from bounds") + func intRangeFromBounds() { + let range = ConnectionField.IntRange(lowerBound: 1, upperBound: 100) + #expect(range.lowerBound == 1) + #expect(range.upperBound == 100) + #expect(range.closedRange == 1...100) + } + + // MARK: - isSecure for new types + + @Test("isSecure is false for .number") + func isSecureForNumber() { + let field = ConnectionField(id: "port", label: "Port", fieldType: .number) + #expect(field.isSecure == false) + } + + @Test("isSecure is false for .toggle") + func isSecureForToggle() { + let field = ConnectionField(id: "flag", label: "Flag", fieldType: .toggle) + #expect(field.isSecure == false) + } + + @Test("isSecure is false for .stepper") + func isSecureForStepper() { + let range = ConnectionField.IntRange(0...15) + let field = ConnectionField(id: "db", label: "DB", fieldType: .stepper(range: range)) + #expect(field.isSecure == false) + } + + // MARK: - Codable round-trips for new types + + @Test("Codable round-trip for .number field") + func codableNumber() throws { + let field = ConnectionField( + id: "port", + label: "Port", + placeholder: "3306", + defaultValue: "3306", + fieldType: .number + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.placeholder == field.placeholder) + #expect(decoded.defaultValue == field.defaultValue) + #expect(decoded.fieldType == .number) + } + + @Test("Codable round-trip for .toggle field") + func codableToggle() throws { + let field = ConnectionField( + id: "compress", + label: "Compress", + defaultValue: "false", + fieldType: .toggle + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.defaultValue == "false") + #expect(decoded.fieldType == .toggle) + } + + @Test("Codable round-trip for .stepper field with IntRange") + func codableStepper() throws { + let range = ConnectionField.IntRange(0...15) + let field = ConnectionField( + id: "redisDatabase", + label: "Database Index", + defaultValue: "0", + fieldType: .stepper(range: range) + ) + + let data = try JSONEncoder().encode(field) + let decoded = try JSONDecoder().decode(ConnectionField.self, from: data) + + #expect(decoded.id == field.id) + #expect(decoded.label == field.label) + #expect(decoded.defaultValue == "0") + #expect(decoded.fieldType == .stepper(range: range)) + } } From d3244441ba174ee58193df66959da9cef4515b9e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 15:43:00 +0700 Subject: [PATCH 02/15] fix: address PR review feedback for ConnectionField types --- .../TableProPluginKit/ConnectionField.swift | 27 +++++++++++++++++++ .../Views/Connection/ConnectionFieldRow.swift | 9 ++++++- .../Views/Connection/ConnectionFormView.swift | 16 ++++++----- .../Core/Plugins/ConnectionFieldTests.swift | 9 +++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index 6398957d..ac57fba4 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -11,11 +11,38 @@ public struct ConnectionField: Codable, Sendable { } public init(lowerBound: Int, upperBound: Int) { + precondition(lowerBound <= upperBound, "IntRange: lowerBound must be <= upperBound") self.lowerBound = lowerBound self.upperBound = upperBound } public var closedRange: ClosedRange { lowerBound...upperBound } + + private enum CodingKeys: String, CodingKey { + case lowerBound, upperBound + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lower = try container.decode(Int.self, forKey: .lowerBound) + let upper = try container.decode(Int.self, forKey: .upperBound) + guard lower <= upper else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "IntRange lowerBound (\(lower)) must be <= upperBound (\(upper))" + ) + ) + } + self.lowerBound = lower + self.upperBound = upper + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(lowerBound, forKey: .lowerBound) + try container.encode(upperBound, forKey: .upperBound) + } } public enum FieldType: Codable, Sendable, Equatable { diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index fd7abb5f..17b56956 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -33,7 +33,14 @@ struct ConnectionFieldRow: View { case .number: TextField( field.label, - text: $value, + text: Binding( + get: { value }, + set: { newValue in + value = String(newValue.unicodeScalars.filter { + CharacterSet.decimalDigits.contains($0) || $0 == "-" || $0 == "." + }) + } + ), prompt: field.placeholder.isEmpty ? nil : Text(field.placeholder) ) case .toggle: diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index f6e7dfb6..b6abb4b4 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -863,19 +863,20 @@ struct ConnectionFormView: View { // Load additional fields from connection additionalFieldValues = existing.additionalFields - for field in PluginManager.shared.additionalConnectionFields(for: existing.type) { - if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue { - additionalFieldValues[field.id] = defaultValue - } - } - // Migrate legacy Redis database index into additionalFieldValues + // Migrate legacy Redis database index before default seeding if existing.type == .redis, additionalFieldValues["redisDatabase"] == nil, let rdb = existing.redisDatabase { additionalFieldValues["redisDatabase"] = String(rdb) } + for field in PluginManager.shared.additionalConnectionFields(for: existing.type) { + if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue { + additionalFieldValues[field.id] = defaultValue + } + } + // Load startup commands startupCommands = existing.startupCommands ?? "" usePgpass = existing.usePgpass @@ -1240,6 +1241,9 @@ struct ConnectionFormView: View { if let authSourceValue = parsed.authSource, !authSourceValue.isEmpty { additionalFieldValues["mongoAuthSource"] = authSourceValue } + if parsed.type == .redis, !parsed.database.isEmpty { + additionalFieldValues["redisDatabase"] = parsed.database + } if let connectionName = parsed.connectionName, !connectionName.isEmpty { name = connectionName } else if name.isEmpty { diff --git a/TableProTests/Core/Plugins/ConnectionFieldTests.swift b/TableProTests/Core/Plugins/ConnectionFieldTests.swift index 39a9c2eb..03f23fb1 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldTests.swift @@ -177,6 +177,15 @@ struct ConnectionFieldTests { #expect(range.closedRange == 1...100) } + @Test("IntRange decoding rejects invalid bounds") + func intRangeDecodingRejectsInvalidBounds() throws { + let json = #"{"lowerBound":10,"upperBound":0}"# + let data = Data(json.utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ConnectionField.IntRange.self, from: data) + } + } + // MARK: - isSecure for new types @Test("isSecure is false for .number") From abf00caeb112d0e19f578ee84064706403209b37 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:14 +0700 Subject: [PATCH 03/15] feat: add SQLDialectDescriptor to PluginKit for plugin-provided SQL dialects --- CHANGELOG.md | 1 + .../ClickHousePlugin.swift | 45 ++++++++ Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 46 ++++++++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 43 +++++++ .../MongoDBDriverPlugin/MongoDBPlugin.swift | 2 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 35 ++++++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 44 ++++++++ .../PostgreSQLPlugin.swift | 37 ++++++ Plugins/RedisDriverPlugin/RedisPlugin.swift | 2 + Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 37 ++++++ Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../SQLDialectDescriptor.swift | 20 ++++ TablePro/Core/Plugins/PluginManager.swift | 6 + .../Services/Query/SQLDialectProvider.swift | 34 +++++- .../Plugins/SQLDialectDescriptorTests.swift | 105 ++++++++++++++++++ 15 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 Plugins/TableProPluginKit/SQLDialectDescriptor.swift create mode 100644 TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..455dffbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 - `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 - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 378a9ba5..e6b76b5b 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -42,6 +42,51 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { "Geo": ["Point", "Ring", "Polygon", "MultiPolygon"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", + "UNION", "INTERSECT", "EXCEPT", + "FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS", + "OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC", + "MATERIALIZED", "WITH" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + "NOW", "TODAY", "YESTERDAY", + "CAST", + "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", + "TOSTRING", "TOINT32", "FORMATDATETIME", + "IF", "MULTIIF", + "ARRAYMAP", "ARRAYJOIN", + "MATCH", "CURRENTDATABASE", "VERSION", + "QUANTILE", "TOPK" + ], + dataTypes: [ + "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", + "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256", + "FLOAT32", "FLOAT64", + "DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256", + "STRING", "FIXEDSTRING", "UUID", + "DATE", "DATE32", "DATETIME", "DATETIME64", + "ARRAY", "TUPLE", "MAP", + "NULLABLE", "LOWCARDINALITY", + "ENUM8", "ENUM16", + "IPV4", "IPV6", + "JSON", "BOOL" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { ClickHousePluginDriver(config: config) } diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 21cc35ad..9155c1a8 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -45,6 +45,52 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { "Enum": ["ENUM"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT", + "QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING", + "INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH", + "EXPORT", "IMPORT", + "WITH", "RECURSIVE", "MATERIALIZED", + "EXPLAIN", "ANALYZE", + "WINDOW", "OVER", "PARTITION" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "LIST_AGG", "STRING_AGG", "ARRAY_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + "EPOCH_MS", + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + "CAST", + "REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON", + "GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST", + "GENERATE_SERIES", "RANGE" + ], + dataTypes: [ + "INTEGER", "BIGINT", "HUGEINT", "UHUGEINT", + "DOUBLE", "FLOAT", "DECIMAL", + "VARCHAR", "TEXT", "BLOB", + "BOOLEAN", + "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL", + "UUID", "JSON", + "LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { DuckDBPluginDriver(config: config) } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 899914cf..4c47e652 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -40,6 +40,49 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { "Other": ["SQL_VARIANT", "TIMESTAMP", "ROWVERSION", "CURSOR", "TABLE", "HIERARCHYID"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "[", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "TOP", "OFFSET", "FETCH", "NEXT", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", "EXEC", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "IDENTITY", "NOLOCK", "WITH", "ROWCOUNT", "NEWID", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "IIF", + "UNION", "INTERSECT", "EXCEPT", + "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", + "PRINT", "GO", "EXECUTE", + "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "RETURNING", "OUTPUT", "INSERTED", "DELETED" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LEN", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "CHARINDEX", "PATINDEX", + "STUFF", "FORMAT", + "GETDATE", "GETUTCDATE", "SYSDATETIME", "CURRENT_TIMESTAMP", + "DATEADD", "DATEDIFF", "DATENAME", "DATEPART", + "CONVERT", "CAST", + "ROUND", "CEILING", "FLOOR", "ABS", "POWER", "SQRT", "RAND", + "ISNULL", "ISNUMERIC", "ISDATE", "COALESCE", "NEWID", + "OBJECT_ID", "OBJECT_NAME", "SCHEMA_NAME", "DB_NAME", + "SCOPE_IDENTITY", "@@IDENTITY", "@@ROWCOUNT" + ], + dataTypes: [ + "INT", "INTEGER", "TINYINT", "SMALLINT", "BIGINT", + "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", + "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT", + "BINARY", "VARBINARY", "IMAGE", + "DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", + "BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", + "ROWVERSION", "TIMESTAMP", "HIERARCHYID" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MSSQLPluginDriver(config: config) } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index f4fb5fb9..cea27542 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -66,6 +66,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { "Other": ["javascript", "minKey", "maxKey"] ] + static let sqlDialect: SQLDialectDescriptor? = nil + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MongoDBPluginDriver(config: config) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 912d9b08..4818855d 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -41,6 +41,41 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { "Spatial": ["GEOMETRY", "POINT", "LINESTRING", "POLYGON"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", + "UNION", "INTERSECT", "EXCEPT", + "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", + "SHOW", "DESCRIBE", "EXPLAIN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", + "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", + "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", + "CAST", "CONVERT" + ], + dataTypes: [ + "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", + "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", + "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", + "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MySQLPluginDriver(config: config) } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 142c6c5c..4aeb1db3 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -38,6 +38,50 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { "Other": ["ROWID", "UROWID"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "FETCH", "FIRST", "ROWS", "ONLY", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "DECODE", + "UNION", "INTERSECT", "MINUS", + "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", + "EXECUTE", "IMMEDIATE", + "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "RETURNING", "CONNECT", "LEVEL", "START", "WITH", "PRIOR", + "ROWNUM", "ROWID", "DUAL", "SYSDATE", "SYSTIMESTAMP" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", + "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", + "INITCAP", "TRANSLATE", + "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", + "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", + "EXTRACT", "TO_DATE", "TO_CHAR", "TO_NUMBER", "TO_TIMESTAMP", + "TRUNC", "ROUND", + "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", "SIGN", + "NVL", "NVL2", "DECODE", "COALESCE", "NULLIF", + "GREATEST", "LEAST", "CAST", + "SYS_GUID", "DBMS_RANDOM.VALUE", "USER", "SYS_CONTEXT" + ], + dataTypes: [ + "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", + "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", + "BLOB", "RAW", "LONG RAW", "BFILE", + "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", + "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { OraclePluginDriver(config: config) } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 6568a704..bc6f3664 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -47,6 +47,43 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { "XML": ["XML"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "RETURNING", "WITH", "RECURSIVE", "MATERIALIZED", + "EXPLAIN", "ANALYZE", "VERBOSE", + "WINDOW", "OVER", "PARTITION", + "LATERAL", "ORDINALITY" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + "CAST", "TO_NUMBER", "TO_TIMESTAMP", + "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" + ], + dataTypes: [ + "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", + "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", + "CHAR", "CHARACTER", "VARCHAR", "TEXT", + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" + ] + ) + static func driverVariant(for databaseTypeId: String) -> String? { switch databaseTypeId { case "PostgreSQL": return "PostgreSQL" diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 34c217ab..cae2c1f7 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -56,6 +56,8 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { "Geospatial": ["geo"] ] + static let sqlDialect: SQLDialectDescriptor? = nil + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { RedisPluginDriver(config: config) } diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3d0b7e6c..63c90c96 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -37,6 +37,43 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { "Boolean": ["BOOLEAN"] ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ] + ) + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { SQLitePluginDriver(config: config) } diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index d3223af3..101b0078 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -34,6 +34,7 @@ public protocol DriverPlugin: TableProPlugin { static var databaseGroupingStrategy: GroupingStrategy { get } static var defaultGroupName: String { get } static var columnTypesByCategory: [String: [String]] { get } + static var sqlDialect: SQLDialectDescriptor? { get } } public extension DriverPlugin { @@ -72,4 +73,5 @@ public extension DriverPlugin { "JSON": ["JSON"] ] } + static var sqlDialect: SQLDialectDescriptor? { nil } } diff --git a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift new file mode 100644 index 00000000..f5b81746 --- /dev/null +++ b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct SQLDialectDescriptor: Sendable { + public let identifierQuote: String + public let keywords: Set + public let functions: Set + public let dataTypes: Set + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set + ) { + self.identifierQuote = identifierQuote + self.keywords = keywords + self.functions = functions + self.dataTypes = dataTypes + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 8dbe3bca..c209de0f 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -331,6 +331,12 @@ final class PluginManager { driverPlugins[databaseType.pluginTypeId] != nil } + func sqlDialect(for databaseType: DatabaseType) -> SQLDialectDescriptor? { + loadPendingPlugins() + guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return nil } + return Swift.type(of: plugin).sqlDialect + } + func additionalConnectionFields(for databaseType: DatabaseType) -> [ConnectionField] { loadPendingPlugins() guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return [] } diff --git a/TablePro/Core/Services/Query/SQLDialectProvider.swift b/TablePro/Core/Services/Query/SQLDialectProvider.swift index 7330f923..07b0b01d 100644 --- a/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit // MARK: - MySQL/MariaDB Dialect @@ -473,11 +474,42 @@ struct DuckDBDialect: SQLDialectProvider { ] } +// MARK: - Plugin Dialect Adapter + +struct PluginDialectAdapter: SQLDialectProvider { + let identifierQuote: String + let keywords: Set + let functions: Set + let dataTypes: Set + + init(descriptor: SQLDialectDescriptor) { + self.identifierQuote = descriptor.identifierQuote + self.keywords = descriptor.keywords + self.functions = descriptor.functions + self.dataTypes = descriptor.dataTypes + } +} + // MARK: - Dialect Factory struct SQLDialectFactory { - /// Create a dialect provider for the given database type + /// Create a dialect provider for the given database type. + /// Prefers plugin-provided dialect info, falling back to built-in dialect structs. static func createDialect(for databaseType: DatabaseType) -> SQLDialectProvider { + if Thread.isMainThread { + return MainActor.assumeIsolated { + if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { + return PluginDialectAdapter(descriptor: descriptor) + } + return builtInDialect(for: databaseType) + } + } + + return builtInDialect(for: databaseType) + } + + /// Built-in fallback dialects when plugins are unavailable or off the main actor + static func builtInDialect(for databaseType: DatabaseType) -> SQLDialectProvider { switch databaseType { case .mysql, .mariadb: return MySQLDialect() diff --git a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift new file mode 100644 index 00000000..0d2e0baa --- /dev/null +++ b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift @@ -0,0 +1,105 @@ +// +// SQLDialectDescriptorTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class SQLDialectDescriptorTests: XCTestCase { + // MARK: - SQLDialectDescriptor Creation + + func testDescriptorCreation() { + let descriptor = SQLDialectDescriptor( + identifierQuote: "`", + keywords: ["SELECT", "FROM", "WHERE"], + functions: ["COUNT", "SUM"], + dataTypes: ["INT", "VARCHAR"] + ) + + XCTAssertEqual(descriptor.identifierQuote, "`") + XCTAssertEqual(descriptor.keywords, ["SELECT", "FROM", "WHERE"]) + XCTAssertEqual(descriptor.functions, ["COUNT", "SUM"]) + XCTAssertEqual(descriptor.dataTypes, ["INT", "VARCHAR"]) + } + + func testDescriptorWithEmptySets() { + let descriptor = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [] + ) + + XCTAssertEqual(descriptor.identifierQuote, "\"") + XCTAssertTrue(descriptor.keywords.isEmpty) + XCTAssertTrue(descriptor.functions.isEmpty) + XCTAssertTrue(descriptor.dataTypes.isEmpty) + } + + // MARK: - PluginDialectAdapter + + @MainActor + func testPluginDialectAdapterWrapsDescriptor() { + let descriptor = SQLDialectDescriptor( + identifierQuote: "[", + keywords: ["SELECT", "TOP", "NOLOCK"], + functions: ["LEN", "GETDATE"], + dataTypes: ["NVARCHAR", "BIT"] + ) + + let adapter = PluginDialectAdapter(descriptor: descriptor) + + XCTAssertEqual(adapter.identifierQuote, "[") + XCTAssertEqual(adapter.keywords, ["SELECT", "TOP", "NOLOCK"]) + XCTAssertEqual(adapter.functions, ["LEN", "GETDATE"]) + XCTAssertEqual(adapter.dataTypes, ["NVARCHAR", "BIT"]) + } + + @MainActor + func testPluginDialectAdapterConformsToSQLDialectProvider() { + let descriptor = SQLDialectDescriptor( + identifierQuote: "`", + keywords: ["SELECT", "FROM"], + functions: ["COUNT", "SUM"], + dataTypes: ["INT", "TEXT"] + ) + + let adapter: SQLDialectProvider = PluginDialectAdapter(descriptor: descriptor) + + XCTAssertTrue(adapter.isKeyword("SELECT")) + XCTAssertTrue(adapter.isKeyword("select")) + XCTAssertFalse(adapter.isKeyword("NONEXISTENT")) + + XCTAssertTrue(adapter.isFunction("COUNT")) + XCTAssertTrue(adapter.isFunction("count")) + XCTAssertFalse(adapter.isFunction("NONEXISTENT")) + + XCTAssertTrue(adapter.isDataType("INT")) + XCTAssertTrue(adapter.isDataType("int")) + XCTAssertFalse(adapter.isDataType("NONEXISTENT")) + } + + // MARK: - Built-in Dialect Fallback + + @MainActor + func testBuiltInDialectFallback() { + let mysqlDialect = SQLDialectFactory.builtInDialect(for: .mysql) + XCTAssertEqual(mysqlDialect.identifierQuote, "`") + XCTAssertFalse(mysqlDialect.keywords.isEmpty) + XCTAssertFalse(mysqlDialect.functions.isEmpty) + XCTAssertFalse(mysqlDialect.dataTypes.isEmpty) + + let pgDialect = SQLDialectFactory.builtInDialect(for: .postgresql) + XCTAssertEqual(pgDialect.identifierQuote, "\"") + XCTAssertTrue(pgDialect.keywords.contains("ILIKE")) + + let mssqlDialect = SQLDialectFactory.builtInDialect(for: .mssql) + XCTAssertEqual(mssqlDialect.identifierQuote, "[") + + let oracleDialect = SQLDialectFactory.builtInDialect(for: .oracle) + XCTAssertEqual(oracleDialect.identifierQuote, "\"") + XCTAssertTrue(oracleDialect.keywords.contains("ROWNUM")) + } +} From b3ec79d4e133be1e446782632c352df78d565876 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:27 +0700 Subject: [PATCH 04/15] feat: add ParameterStyle to PluginKit and DML generation in ClickHouse/MSSQL/Oracle plugins --- CHANGELOG.md | 2 + .../ClickHousePlugin.swift | 122 +++++++++ Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 1 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 181 ++++++++++++++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 127 ++++++++++ .../PostgreSQLPluginDriver.swift | 1 + .../RedshiftPluginDriver.swift | 1 + .../PluginDatabaseDriver.swift | 8 + .../SQLStatementGenerator.swift | 37 ++- .../Core/Plugins/PluginDriverAdapter.swift | 18 ++ ...tatementGeneratorParameterStyleTests.swift | 232 ++++++++++++++++++ 11 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..0fc2f991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support - `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form - Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass) +- `ParameterStyle` enum in TableProPluginKit: plugins declare `?` or `$1` placeholder style via `parameterStyle` property on `PluginDatabaseDriver` +- DML statement generation in ClickHouse, MSSQL, and Oracle plugins via `generateStatements()` for database-specific UPDATE/DELETE syntax ### Fixed diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 378a9ba5..000faa1f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -499,6 +499,128 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "CREATE DATABASE `\(escapedName)`") } + // MARK: - DML Statement Generation + + func generateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? { + var statements: [(statement: String, parameters: [String?])] = [] + + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { continue } + if let values = insertedRowData[change.rowIndex] { + if let stmt = generateClickHouseInsert(table: table, columns: columns, values: values) { + statements.append(stmt) + } + } + case .update: + if let stmt = generateClickHouseUpdate(table: table, columns: columns, change: change) { + statements.append(stmt) + } + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + if let stmt = generateClickHouseDelete(table: table, columns: columns, change: change) { + statements.append(stmt) + } + } + } + + return statements.isEmpty ? nil : statements + } + + private func generateClickHouseInsert( + table: String, + columns: [String], + values: [String?] + ) -> (statement: String, parameters: [String?])? { + var nonDefaultColumns: [String] = [] + var parameters: [String?] = [] + + for (index, value) in values.enumerated() { + if value == "__DEFAULT__" { continue } + guard index < columns.count else { continue } + nonDefaultColumns.append("`\(columns[index].replacingOccurrences(of: "`", with: "``"))`") + parameters.append(value) + } + + guard !nonDefaultColumns.isEmpty else { return nil } + + let columnList = nonDefaultColumns.joined(separator: ", ") + let placeholders = parameters.map { _ in "?" }.joined(separator: ", ") + let sql = "INSERT INTO `\(table.replacingOccurrences(of: "`", with: "``"))` (\(columnList)) VALUES (\(placeholders))" + return (statement: sql, parameters: parameters) + } + + private func generateClickHouseUpdate( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + guard !change.cellChanges.isEmpty else { return nil } + + let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" + var parameters: [String?] = [] + + let setClauses = change.cellChanges.map { cellChange -> String in + let col = "`\(cellChange.columnName.replacingOccurrences(of: "`", with: "``"))`" + parameters.append(cellChange.newValue) + return "\(col) = ?" + }.joined(separator: ", ") + + guard let whereClause = buildWhereClause( + columns: columns, change: change, parameters: ¶meters + ) else { return nil } + + let sql = "ALTER TABLE \(escapedTable) UPDATE \(setClauses) WHERE \(whereClause)" + return (statement: sql, parameters: parameters) + } + + private func generateClickHouseDelete( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" + var parameters: [String?] = [] + + guard let whereClause = buildWhereClause( + columns: columns, change: change, parameters: ¶meters + ) else { return nil } + + let sql = "ALTER TABLE \(escapedTable) DELETE WHERE \(whereClause)" + return (statement: sql, parameters: parameters) + } + + private func buildWhereClause( + columns: [String], + change: PluginRowChange, + parameters: inout [String?] + ) -> String? { + guard let originalRow = change.originalRow else { return nil } + + var conditions: [String] = [] + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = "`\(columnName.replacingOccurrences(of: "`", with: "``"))`" + if let value = originalRow[index] { + parameters.append(value) + conditions.append("\(col) = ?") + } else { + conditions.append("\(col) IS NULL") + } + } + + guard !conditions.isEmpty else { return nil } + return conditions.joined(separator: " AND ") + } + func cancelQuery() throws { let queryId: String? lock.lock() diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 21cc35ad..ec294326 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -324,6 +324,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var serverVersion: String? { String(cString: duckdb_library_version()) } var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } + var parameterStyle: ParameterStyle { .dollar } init(config: DriverConnectionConfig) { self.config = config diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 899914cf..ae58d1a2 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -435,6 +435,187 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + // MARK: - DML Statement Generation + + func generateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? { + var statements: [(statement: String, parameters: [String?])] = [] + + var deleteChanges: [PluginRowChange] = [] + + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { continue } + if let values = insertedRowData[change.rowIndex] { + if let stmt = generateMssqlInsert(table: table, columns: columns, values: values) { + statements.append(stmt) + } + } + case .update: + if let stmt = generateMssqlUpdate(table: table, columns: columns, change: change) { + statements.append(stmt) + } + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + deleteChanges.append(change) + } + } + + if !deleteChanges.isEmpty { + let hasPk = deleteChanges.allSatisfy { change in + change.cellChanges.contains { $0.columnName == columns.first } + } + if hasPk, let batchStmt = generateMssqlBatchDelete(table: table, columns: columns, changes: deleteChanges) { + statements.append(batchStmt) + } else { + for change in deleteChanges { + if let stmt = generateMssqlDelete(table: table, columns: columns, change: change) { + statements.append(stmt) + } + } + } + } + + return statements.isEmpty ? nil : statements + } + + private func generateMssqlInsert( + table: String, + columns: [String], + values: [String?] + ) -> (statement: String, parameters: [String?])? { + var nonDefaultColumns: [String] = [] + var parameters: [String?] = [] + + for (index, value) in values.enumerated() { + if value == "__DEFAULT__" { continue } + guard index < columns.count else { continue } + nonDefaultColumns.append("[\(columns[index].replacingOccurrences(of: "]", with: "]]"))]") + parameters.append(value) + } + + guard !nonDefaultColumns.isEmpty else { return nil } + + let columnList = nonDefaultColumns.joined(separator: ", ") + let placeholders = parameters.map { _ in "?" }.joined(separator: ", ") + let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" + let sql = "INSERT INTO \(escapedTable) (\(columnList)) VALUES (\(placeholders))" + return (statement: sql, parameters: parameters) + } + + private func generateMssqlUpdate( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + guard !change.cellChanges.isEmpty else { return nil } + + let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" + var parameters: [String?] = [] + + let setClauses = change.cellChanges.map { cellChange -> String in + let col = "[\(cellChange.columnName.replacingOccurrences(of: "]", with: "]]"))]" + parameters.append(cellChange.newValue) + return "\(col) = ?" + }.joined(separator: ", ") + + // Check if we have original row data to identify by PK or all columns + guard let originalRow = change.originalRow else { return nil } + + // Use all columns as WHERE clause for safety + var conditions: [String] = [] + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" + if let value = originalRow[index] { + parameters.append(value) + conditions.append("\(col) = ?") + } else { + conditions.append("\(col) IS NULL") + } + } + + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " AND ") + + // Without a reliable PK, use UPDATE TOP (1) for safety + let sql = "UPDATE TOP (1) \(escapedTable) SET \(setClauses) WHERE \(whereClause)" + return (statement: sql, parameters: parameters) + } + + private func generateMssqlBatchDelete( + table: String, + columns: [String], + changes: [PluginRowChange] + ) -> (statement: String, parameters: [String?])? { + guard !changes.isEmpty else { return nil } + + let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" + var parameters: [String?] = [] + var conditions: [String] = [] + + for change in changes { + guard let originalRow = change.originalRow else { return nil } + var rowConditions: [String] = [] + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" + if let value = originalRow[index] { + parameters.append(value) + rowConditions.append("\(col) = ?") + } else { + rowConditions.append("\(col) IS NULL") + } + } + if !rowConditions.isEmpty { + conditions.append("(\(rowConditions.joined(separator: " AND ")))") + } + } + + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " OR ") + let sql = "DELETE FROM \(escapedTable) WHERE \(whereClause)" + return (statement: sql, parameters: parameters) + } + + private func generateMssqlDelete( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + guard let originalRow = change.originalRow else { return nil } + + let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" + var parameters: [String?] = [] + var conditions: [String] = [] + + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" + if let value = originalRow[index] { + parameters.append(value) + conditions.append("\(col) = ?") + } else { + conditions.append("\(col) IS NULL") + } + } + + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " AND ") + let sql = "DELETE TOP (1) FROM \(escapedTable) WHERE \(whereClause)" + return (statement: sql, parameters: parameters) + } + func cancelQuery() throws { freeTDSConn?.cancelCurrentQuery() } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 142c6c5c..ed7cd56b 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -541,6 +541,133 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginDatabaseMetadata(name: database) } + // MARK: - DML Statement Generation + + func generateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? { + var statements: [(statement: String, parameters: [String?])] = [] + + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { continue } + if let values = insertedRowData[change.rowIndex] { + if let stmt = generateOracleInsert(table: table, columns: columns, values: values) { + statements.append(stmt) + } + } + case .update: + if let stmt = generateOracleUpdate(table: table, columns: columns, change: change) { + statements.append(stmt) + } + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + if let stmt = generateOracleDelete(table: table, columns: columns, change: change) { + statements.append(stmt) + } + } + } + + return statements.isEmpty ? nil : statements + } + + private func escapeOracleIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private func generateOracleInsert( + table: String, + columns: [String], + values: [String?] + ) -> (statement: String, parameters: [String?])? { + var nonDefaultColumns: [String] = [] + var parameters: [String?] = [] + + for (index, value) in values.enumerated() { + if value == "__DEFAULT__" { continue } + guard index < columns.count else { continue } + nonDefaultColumns.append(escapeOracleIdentifier(columns[index])) + parameters.append(value) + } + + guard !nonDefaultColumns.isEmpty else { return nil } + + let columnList = nonDefaultColumns.joined(separator: ", ") + let placeholders = parameters.map { _ in "?" }.joined(separator: ", ") + let sql = "INSERT INTO \(escapeOracleIdentifier(table)) (\(columnList)) VALUES (\(placeholders))" + return (statement: sql, parameters: parameters) + } + + private func generateOracleUpdate( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + guard !change.cellChanges.isEmpty, let originalRow = change.originalRow else { return nil } + + let escapedTable = escapeOracleIdentifier(table) + var parameters: [String?] = [] + + let setClauses = change.cellChanges.map { cellChange -> String in + let col = escapeOracleIdentifier(cellChange.columnName) + parameters.append(cellChange.newValue) + return "\(col) = ?" + }.joined(separator: ", ") + + var conditions: [String] = [] + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = escapeOracleIdentifier(columnName) + if let value = originalRow[index] { + parameters.append(value) + conditions.append("\(col) = ?") + } else { + conditions.append("\(col) IS NULL") + } + } + + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " AND ") + let sql = "UPDATE \(escapedTable) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" + return (statement: sql, parameters: parameters) + } + + private func generateOracleDelete( + table: String, + columns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [String?])? { + guard let originalRow = change.originalRow else { return nil } + + let escapedTable = escapeOracleIdentifier(table) + var parameters: [String?] = [] + var conditions: [String] = [] + + for (index, columnName) in columns.enumerated() { + guard index < originalRow.count else { continue } + let col = escapeOracleIdentifier(columnName) + if let value = originalRow[index] { + parameters.append(value) + conditions.append("\(col) = ?") + } else { + conditions.append("\(col) IS NULL") + } + } + + guard !conditions.isEmpty else { return nil } + + let whereClause = conditions.joined(separator: " AND ") + let sql = "DELETE FROM \(escapedTable) WHERE \(whereClause) AND ROWNUM = 1" + return (statement: sql, parameters: parameters) + } + // MARK: - Schema Switching func switchSchema(to schema: String) async throws { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 35994236..fb5d3657 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -23,6 +23,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } var serverVersion: String? { libpqConnection?.serverVersion() } + var parameterStyle: ParameterStyle { .dollar } init(config: DriverConnectionConfig) { self.config = config diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 48c3d54c..302961f5 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -23,6 +23,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } var serverVersion: String? { libpqConnection?.serverVersion() } + var parameterStyle: ParameterStyle { .dollar } init(config: DriverConnectionConfig) { self.config = config diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d0a9492d..78cf0f99 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -1,5 +1,10 @@ import Foundation +public enum ParameterStyle: String, Sendable { + case questionMark // ? + case dollar // $1, $2 +} + public struct PluginRowChange: Sendable { public enum ChangeType: Sendable { case insert @@ -63,6 +68,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func cancelQuery() throws func applyQueryTimeout(_ seconds: Int) async throws var serverVersion: String? { get } + var parameterStyle: ParameterStyle { get } // Batch operations func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? @@ -120,6 +126,8 @@ public extension PluginDatabaseDriver { var serverVersion: String? { nil } + var parameterStyle: ParameterStyle { .questionMark } + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { nil } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index b659dbb1..2fe179ed 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -8,6 +8,7 @@ import Foundation import os +import TableProPluginKit /// A parameterized SQL statement with placeholders and bound values struct ParameterizedStatement { @@ -23,6 +24,30 @@ struct SQLStatementGenerator { let columns: [String] let primaryKeyColumn: String? let databaseType: DatabaseType + let parameterStyle: ParameterStyle + + init( + tableName: String, + columns: [String], + primaryKeyColumn: String?, + databaseType: DatabaseType, + parameterStyle: ParameterStyle? = nil + ) { + self.tableName = tableName + self.columns = columns + self.primaryKeyColumn = primaryKeyColumn + self.databaseType = databaseType + self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType) + } + + private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle { + switch databaseType { + case .postgresql, .redshift, .duckdb: + return .dollar + case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: + return .questionMark + } + } // MARK: - Public API @@ -95,13 +120,13 @@ struct SQLStatementGenerator { return statements } - /// Get placeholder syntax for the database type + /// Get placeholder syntax based on the driver's parameter style private func placeholder(at index: Int) -> String { - switch databaseType { - case .postgresql, .redshift, .duckdb: - return "$\(index + 1)" // PostgreSQL/DuckDB uses $1, $2, etc. - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: - return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, Oracle, and ClickHouse use ? + switch parameterStyle { + case .dollar: + return "$\(index + 1)" + case .questionMark: + return "?" } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 31ef4ade..3ac6bf0b 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -13,6 +13,24 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private let pluginDriver: any PluginDatabaseDriver var serverVersion: String? { pluginDriver.serverVersion } + var parameterStyle: ParameterStyle { pluginDriver.parameterStyle } + + func pluginGenerateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? { + pluginDriver.generateStatements( + table: table, columns: columns, changes: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + var noSqlPluginDriver: (any PluginDatabaseDriver)? { // Only expose plugin driver for NoSQL dispatch if it actually handles query building. // SQL drivers (MySQL, PostgreSQL, etc.) return nil from buildBrowseQuery and should diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift new file mode 100644 index 00000000..1f8c4364 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift @@ -0,0 +1,232 @@ +// +// SQLStatementGeneratorParameterStyleTests.swift +// TableProTests +// +// Tests for ParameterStyle integration in SQLStatementGenerator +// + +import Foundation +import Testing + +@testable import TablePro +@testable import TableProPluginKit + +@Suite("SQL Statement Generator - Parameter Style") +struct SQLStatementGeneratorParameterStyleTests { + // MARK: - Helper Methods + + private func makeGenerator( + tableName: String = "users", + columns: [String] = ["id", "name", "email"], + primaryKeyColumn: String? = "id", + databaseType: DatabaseType = .mysql, + parameterStyle: ParameterStyle? = nil + ) -> SQLStatementGenerator { + SQLStatementGenerator( + tableName: tableName, + columns: columns, + primaryKeyColumn: primaryKeyColumn, + databaseType: databaseType, + parameterStyle: parameterStyle + ) + } + + // MARK: - Default Parameter Style Tests + + @Test("PostgreSQL defaults to dollar style") + func testPostgreSQLDefaultsDollar() { + let generator = makeGenerator(databaseType: .postgresql) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("$1")) + #expect(statements[0].sql.contains("$2")) + #expect(statements[0].sql.contains("$3")) + #expect(!statements[0].sql.contains("?")) + } + + @Test("Redshift defaults to dollar style") + func testRedshiftDefaultsDollar() { + let generator = makeGenerator(databaseType: .redshift) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("$1")) + } + + @Test("DuckDB defaults to dollar style") + func testDuckDBDefaultsDollar() { + let generator = makeGenerator(databaseType: .duckdb) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("$1")) + } + + @Test("MySQL defaults to questionMark style") + func testMySQLDefaultsQuestionMark() { + let generator = makeGenerator(databaseType: .mysql) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("?")) + #expect(!statements[0].sql.contains("$1")) + } + + @Test("SQLite defaults to questionMark style") + func testSQLiteDefaultsQuestionMark() { + let generator = makeGenerator(databaseType: .sqlite) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("?")) + #expect(!statements[0].sql.contains("$1")) + } + + @Test("MSSQL defaults to questionMark style") + func testMSSQLDefaultsQuestionMark() { + let generator = makeGenerator(databaseType: .mssql) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + #expect(statements[0].sql.contains("?")) + #expect(!statements[0].sql.contains("$1")) + } + + // MARK: - Explicit Parameter Style Override + + @Test("Dollar style generates $1, $2 placeholders for INSERT") + func testDollarStyleInsert() { + let generator = makeGenerator(parameterStyle: .dollar) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + let sql = statements[0].sql + #expect(sql.contains("$1")) + #expect(sql.contains("$2")) + #expect(sql.contains("$3")) + } + + @Test("QuestionMark style generates ? placeholders for INSERT") + func testQuestionMarkStyleInsert() { + let generator = makeGenerator(parameterStyle: .questionMark) + let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: insertedRowData, + deletedRowIndices: [], insertedRowIndices: [0] + ) + + #expect(statements.count == 1) + let sql = statements[0].sql + #expect(sql.contains("?")) + #expect(!sql.contains("$1")) + } + + @Test("Dollar style generates $N placeholders for UPDATE with PK") + func testDollarStyleUpdate() { + let generator = makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) + let changes: [RowChange] = [ + RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane") + ], + originalRow: ["1", "John", "john@example.com"] + ) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: [:], + deletedRowIndices: [], insertedRowIndices: [] + ) + + #expect(statements.count == 1) + let sql = statements[0].sql + #expect(sql.contains("$1")) + #expect(sql.contains("$2")) + } + + @Test("Dollar style generates $N placeholders for DELETE with PK") + func testDollarStyleDelete() { + let generator = makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) + let changes: [RowChange] = [ + RowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: ["1", "John", "john@example.com"] + ) + ] + + let statements = generator.generateStatements( + from: changes, insertedRowData: [:], + deletedRowIndices: [0], insertedRowIndices: [] + ) + + #expect(statements.count == 1) + let sql = statements[0].sql + #expect(sql.contains("$1")) + #expect(!sql.contains("?")) + } +} From 663d850af4394c1867cfee506884cef0c74ae863 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:32 +0700 Subject: [PATCH 05/15] feat: add DDL schema generation methods to PluginDatabaseDriver protocol --- CHANGELOG.md | 1 + .../PluginDatabaseDriver.swift | 19 + Plugins/TableProPluginKit/SchemaTypes.swift | 89 +++++ TablePro/Core/Database/DatabaseManager.swift | 7 +- .../Core/Plugins/PluginDriverAdapter.swift | 41 ++ .../SchemaStatementGenerator.swift | 200 +++++++--- .../Views/Structure/TableStructureView.swift | 5 +- .../SchemaStatementGeneratorPluginTests.swift | 364 ++++++++++++++++++ 8 files changed, 680 insertions(+), 46 deletions(-) create mode 100644 Plugins/TableProPluginKit/SchemaTypes.swift create mode 100644 TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..f107260f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 - `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 - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d0a9492d..a09df6cc 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -85,6 +85,16 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws + + // DDL schema generation (optional, plugins return nil to use default fallback) + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? + func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? + func generateDropColumnSQL(table: String, columnName: String) -> String? + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? + func generateDropIndexSQL(table: String, indexName: String) -> String? + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? } public extension PluginDatabaseDriver { @@ -175,6 +185,15 @@ public extension PluginDatabaseDriver { func buildCombinedQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, searchText: String, searchColumns: [String], sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func generateStatements(table: String, columns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } + func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { nil } + func generateDropColumnSQL(table: String, columnName: String) -> String? { nil } + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { nil } + func generateDropIndexSQL(table: String, indexName: String) -> String? { nil } + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil } + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { nil } + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) diff --git a/Plugins/TableProPluginKit/SchemaTypes.swift b/Plugins/TableProPluginKit/SchemaTypes.swift new file mode 100644 index 00000000..d861b486 --- /dev/null +++ b/Plugins/TableProPluginKit/SchemaTypes.swift @@ -0,0 +1,89 @@ +// +// SchemaTypes.swift +// TableProPluginKit +// +// Transfer types for DDL schema operations. +// + +import Foundation + +/// Column definition for plugin DDL generation +public struct PluginColumnDefinition: Sendable { + public let name: String + public let dataType: String + public let isNullable: Bool + public let defaultValue: String? + public let isPrimaryKey: Bool + public let autoIncrement: Bool + public let comment: String? + public let unsigned: Bool + public let onUpdate: String? + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + defaultValue: String? = nil, + isPrimaryKey: Bool = false, + autoIncrement: Bool = false, + comment: String? = nil, + unsigned: Bool = false, + onUpdate: String? = nil + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isPrimaryKey = isPrimaryKey + self.autoIncrement = autoIncrement + self.comment = comment + self.unsigned = unsigned + self.onUpdate = onUpdate + } +} + +/// Index definition for plugin DDL generation +public struct PluginIndexDefinition: Sendable { + public let name: String + public let columns: [String] + public let isUnique: Bool + public let indexType: String? + + public init( + name: String, + columns: [String], + isUnique: Bool = false, + indexType: String? = nil + ) { + self.name = name + self.columns = columns + self.isUnique = isUnique + self.indexType = indexType + } +} + +/// Foreign key definition for plugin DDL generation +public struct PluginForeignKeyDefinition: Sendable { + public let name: String + public let columns: [String] + public let referencedTable: String + public let referencedColumns: [String] + public let onDelete: String + public let onUpdate: String + + public init( + name: String, + columns: [String], + referencedTable: String, + referencedColumns: [String], + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) { + self.name = name + self.columns = columns + self.referencedTable = referencedTable + self.referencedColumns = referencedColumns + self.onDelete = onDelete + self.onUpdate = onUpdate + } +} diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 614354c7..3d2cc8de 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -8,6 +8,7 @@ import Foundation import Observation import os +import TableProPluginKit /// Manages database connections and active drivers @MainActor @Observable @@ -686,11 +687,15 @@ final class DatabaseManager { driver: driver ) + // Extract plugin driver for DDL delegation (nil for non-plugin drivers) + let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver + // Generate SQL statements let generator = SchemaStatementGenerator( tableName: tableName, databaseType: databaseType, - primaryKeyConstraintName: pkConstraintName + primaryKeyConstraintName: pkConstraintName, + pluginDriver: resolvedPluginDriver ) let statements = try generator.generate(changes: changes) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 31ef4ade..f045687c 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -13,6 +13,9 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private let pluginDriver: any PluginDatabaseDriver var serverVersion: String? { pluginDriver.serverVersion } + /// The underlying plugin driver, exposed for DDL schema generation delegation. + var schemaPluginDriver: any PluginDatabaseDriver { pluginDriver } + var noSqlPluginDriver: (any PluginDatabaseDriver)? { // Only expose plugin driver for NoSQL dispatch if it actually handles query building. // SQL drivers (MySQL, PostgreSQL, etc.) return nil from buildBrowseQuery and should @@ -273,6 +276,44 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.switchDatabase(to: database) } + // MARK: - DDL Schema Generation + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + pluginDriver.generateAddColumnSQL(table: table, column: column) + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + pluginDriver.generateModifyColumnSQL(table: table, oldColumn: oldColumn, newColumn: newColumn) + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + pluginDriver.generateDropColumnSQL(table: table, columnName: columnName) + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + pluginDriver.generateAddIndexSQL(table: table, index: index) + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + pluginDriver.generateDropIndexSQL(table: table, indexName: indexName) + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + pluginDriver.generateAddForeignKeySQL(table: table, fk: fk) + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + pluginDriver.generateDropForeignKeySQL(table: table, constraintName: constraintName) + } + + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { + pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns) + } + // MARK: - Result Mapping private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 93972e7a..f10aece4 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit /// A schema SQL statement with metadata struct SchemaStatement { @@ -25,10 +26,20 @@ struct SchemaStatementGenerator { /// Falls back to `{table}_pkey` convention if nil. private let primaryKeyConstraintName: String? - init(tableName: String, databaseType: DatabaseType, primaryKeyConstraintName: String? = nil) { + /// Optional plugin driver for database-specific DDL generation. + /// When non-nil, plugin methods are tried first; nil results fall back to built-in logic. + private let pluginDriver: (any PluginDatabaseDriver)? + + init( + tableName: String, + databaseType: DatabaseType, + primaryKeyConstraintName: String? = nil, + pluginDriver: (any PluginDatabaseDriver)? = nil + ) { self.tableName = tableName self.databaseType = databaseType self.primaryKeyConstraintName = primaryKeyConstraintName + self.pluginDriver = pluginDriver } /// Generate all SQL statements from schema changes @@ -125,6 +136,11 @@ struct SchemaStatementGenerator { // MARK: - Column Operations private func generateAddColumn(_ column: EditableColumnDefinition) throws -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: toPluginColumnDefinition(column)) { + return SchemaStatement(sql: sql, description: "Add column '\(column.name)'", isDestructive: false) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let columnDef = try buildEditableColumnDefinition(column) @@ -138,6 +154,19 @@ struct SchemaStatementGenerator { } private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) throws -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateModifyColumnSQL( + table: tableName, + oldColumn: toPluginColumnDefinition(old), + newColumn: toPluginColumnDefinition(new) + ) { + return SchemaStatement( + sql: sql, + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType + ) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) switch databaseType { @@ -247,49 +276,7 @@ struct SchemaStatementGenerator { ) case .clickhouse: - // ClickHouse HTTP interface doesn't support multi-statement queries, - // so we combine changes into a single ALTER TABLE with comma-separated actions - var actions: [String] = [] - let newQuoted = databaseType.quoteIdentifier(new.name) - - if old.name != new.name { - let oldQuoted = databaseType.quoteIdentifier(old.name) - actions.append("RENAME COLUMN \(oldQuoted) TO \(newQuoted)") - } - - if old.dataType != new.dataType || old.isNullable != new.isNullable { - let nullableType = new.isNullable ? "Nullable(\(new.dataType))" : new.dataType - actions.append("MODIFY COLUMN \(newQuoted) \(nullableType)") - } - - if old.defaultValue != new.defaultValue { - if let defaultVal = new.defaultValue, !defaultVal.isEmpty { - actions.append("MODIFY COLUMN \(newQuoted) DEFAULT \(defaultVal)") - } else { - actions.append("MODIFY COLUMN \(newQuoted) REMOVE DEFAULT") - } - } - - if old.comment != new.comment { - if let comment = new.comment, !comment.isEmpty { - let escaped = comment.replacingOccurrences(of: "'", with: "''") - actions.append("COMMENT COLUMN \(newQuoted) '\(escaped)'") - } else { - actions.append("COMMENT COLUMN \(newQuoted) ''") - } - } - - guard !actions.isEmpty else { - return SchemaStatement(sql: "", description: "No changes", isDestructive: false) - } - - let sql = "ALTER TABLE \(tableQuoted) " + actions.joined(separator: ", ") - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - + return generateModifyColumnClickHouse(old: old, new: new, tableQuoted: tableQuoted) case .duckdb: // DuckDB: Multiple ALTER COLUMN statements (like PostgreSQL) @@ -332,7 +319,61 @@ struct SchemaStatementGenerator { } } + private func generateModifyColumnClickHouse( + old: EditableColumnDefinition, + new: EditableColumnDefinition, + tableQuoted: String + ) -> SchemaStatement { + // ClickHouse HTTP interface doesn't support multi-statement queries, + // so we combine changes into a single ALTER TABLE with comma-separated actions + var actions: [String] = [] + let newQuoted = databaseType.quoteIdentifier(new.name) + + if old.name != new.name { + let oldQuoted = databaseType.quoteIdentifier(old.name) + actions.append("RENAME COLUMN \(oldQuoted) TO \(newQuoted)") + } + + if old.dataType != new.dataType || old.isNullable != new.isNullable { + let nullableType = new.isNullable ? "Nullable(\(new.dataType))" : new.dataType + actions.append("MODIFY COLUMN \(newQuoted) \(nullableType)") + } + + if old.defaultValue != new.defaultValue { + if let defaultVal = new.defaultValue, !defaultVal.isEmpty { + actions.append("MODIFY COLUMN \(newQuoted) DEFAULT \(defaultVal)") + } else { + actions.append("MODIFY COLUMN \(newQuoted) REMOVE DEFAULT") + } + } + + if old.comment != new.comment { + if let comment = new.comment, !comment.isEmpty { + let escaped = comment.replacingOccurrences(of: "'", with: "''") + actions.append("COMMENT COLUMN \(newQuoted) '\(escaped)'") + } else { + actions.append("COMMENT COLUMN \(newQuoted) ''") + } + } + + guard !actions.isEmpty else { + return SchemaStatement(sql: "", description: "No changes", isDestructive: false) + } + + let sql = "ALTER TABLE \(tableQuoted) " + actions.joined(separator: ", ") + return SchemaStatement( + sql: sql, + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType + ) + } + private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateDropColumnSQL(table: tableName, columnName: column.name) { + return SchemaStatement(sql: sql, description: "Drop column '\(column.name)'", isDestructive: true) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let columnQuoted = databaseType.quoteIdentifier(column.name) @@ -422,6 +463,11 @@ struct SchemaStatementGenerator { // MARK: - Index Operations private func generateAddIndex(_ index: EditableIndexDefinition) throws -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(index)) { + return SchemaStatement(sql: sql, description: "Add index '\(index.name)'", isDestructive: false) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let indexQuoted = databaseType.quoteIdentifier(index.name) let columnsQuoted = index.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") @@ -463,6 +509,11 @@ struct SchemaStatementGenerator { } private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: index.name) { + return SchemaStatement(sql: sql, description: "Drop index '\(index.name)'", isDestructive: false) + } + let indexQuoted = databaseType.quoteIdentifier(index.name) let sql: String @@ -488,6 +539,14 @@ struct SchemaStatementGenerator { // MARK: - Foreign Key Operations private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateAddForeignKeySQL( + table: tableName, + fk: toPluginForeignKeyDefinition(fk) + ) { + return SchemaStatement(sql: sql, description: "Add foreign key '\(fk.name)'", isDestructive: false) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let fkQuoted = databaseType.quoteIdentifier(fk.name) let columnsQuoted = fk.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") @@ -524,6 +583,11 @@ struct SchemaStatementGenerator { } private func generateDeleteForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { + if let pluginDriver, + let sql = pluginDriver.generateDropForeignKeySQL(table: tableName, constraintName: fk.name) { + return SchemaStatement(sql: sql, description: "Drop foreign key '\(fk.name)'", isDestructive: false) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let fkQuoted = databaseType.quoteIdentifier(fk.name) @@ -547,6 +611,18 @@ struct SchemaStatementGenerator { // MARK: - Primary Key Operations private func generateModifyPrimaryKey(old: [String], new: [String]) throws -> SchemaStatement { + if let pluginDriver, + let sqls = pluginDriver.generateModifyPrimaryKeySQL( + table: tableName, oldColumns: old, newColumns: new + ) { + let joined = sqls.joined(separator: ";\n") + return SchemaStatement( + sql: joined, + description: "Modify primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]", + isDestructive: true + ) + } + let tableQuoted = databaseType.quoteIdentifier(tableName) let newColumnsQuoted = new.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") @@ -593,4 +669,40 @@ struct SchemaStatementGenerator { isDestructive: true ) } + + // MARK: - Plugin Type Converters + + private func toPluginColumnDefinition(_ col: EditableColumnDefinition) -> PluginColumnDefinition { + PluginColumnDefinition( + name: col.name, + dataType: col.dataType, + isNullable: col.isNullable, + defaultValue: col.defaultValue, + isPrimaryKey: col.isPrimaryKey, + autoIncrement: col.autoIncrement, + comment: col.comment, + unsigned: col.unsigned, + onUpdate: col.onUpdate + ) + } + + private func toPluginIndexDefinition(_ index: EditableIndexDefinition) -> PluginIndexDefinition { + PluginIndexDefinition( + name: index.name, + columns: index.columns, + isUnique: index.isUnique, + indexType: index.type.rawValue + ) + } + + private func toPluginForeignKeyDefinition(_ fk: EditableForeignKeyDefinition) -> PluginForeignKeyDefinition { + PluginForeignKeyDefinition( + name: fk.name, + columns: fk.columns, + referencedTable: fk.referencedTable, + referencedColumns: fk.referencedColumns, + onDelete: fk.onDelete.rawValue, + onUpdate: fk.onUpdate.rawValue + ) + } } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index f01e512c..4c801a6b 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -10,6 +10,7 @@ import AppKit import Combine import os import SwiftUI +import TableProPluginKit import UniformTypeIdentifiers /// View displaying table structure with DataGridView @@ -532,9 +533,11 @@ struct TableStructureView: View { return } + let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver let generator = SchemaStatementGenerator( tableName: tableName, - databaseType: getDatabaseType() + databaseType: getDatabaseType(), + pluginDriver: pluginDriver ) do { diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift new file mode 100644 index 00000000..feeb3302 --- /dev/null +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -0,0 +1,364 @@ +// +// SchemaStatementGeneratorPluginTests.swift +// TableProTests +// +// Tests for plugin-delegated DDL generation in SchemaStatementGenerator. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Mock plugin driver that returns custom DDL for specific operations. +/// Methods return nil by default (triggering fallback), unless overridden via closures. +private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + var addColumnHandler: ((String, PluginColumnDefinition) -> String?)? + var modifyColumnHandler: ((String, PluginColumnDefinition, PluginColumnDefinition) -> String?)? + var dropColumnHandler: ((String, String) -> String?)? + var addIndexHandler: ((String, PluginIndexDefinition) -> String?)? + var dropIndexHandler: ((String, String) -> String?)? + var addForeignKeyHandler: ((String, PluginForeignKeyDefinition) -> String?)? + var dropForeignKeyHandler: ((String, String) -> String?)? + var modifyPrimaryKeyHandler: ((String, [String], [String]) -> [String]?)? + + // MARK: - DDL Schema Generation + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + addColumnHandler?(table, column) + } + + func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { + modifyColumnHandler?(table, oldColumn, newColumn) + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + dropColumnHandler?(table, columnName) + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + addIndexHandler?(table, index) + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + dropIndexHandler?(table, indexName) + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + addForeignKeyHandler?(table, fk) + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + dropForeignKeyHandler?(table, constraintName) + } + + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { + modifyPrimaryKeyHandler?(table, oldColumns, newColumns) + } + + // MARK: - Required Protocol Stubs + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchRowCount(query: String) async throws -> Int { 0 } + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("Schema Statement Generator - Plugin Delegation") +struct SchemaStatementGeneratorPluginTests { + // MARK: - Helpers + + private func makeColumn( + name: String = "email", + dataType: String = "VARCHAR(255)", + isNullable: Bool = true, + isPrimaryKey: Bool = false, + defaultValue: String? = nil + ) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), + name: name, + dataType: dataType, + isNullable: isNullable, + defaultValue: defaultValue, + autoIncrement: false, + unsigned: false, + comment: nil, + collation: nil, + onUpdate: nil, + charset: nil, + extra: nil, + isPrimaryKey: isPrimaryKey + ) + } + + private func makeIndex( + name: String = "idx_email", + columns: [String] = ["email"], + isUnique: Bool = false + ) -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), + name: name, + columns: columns, + type: .btree, + isUnique: isUnique, + isPrimary: false, + comment: nil + ) + } + + private func makeForeignKey( + name: String = "fk_user_role", + columns: [String] = ["role_id"], + refTable: String = "roles", + refColumns: [String] = ["id"] + ) -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: columns, + referencedTable: refTable, + referencedColumns: refColumns, + onDelete: .cascade, + onUpdate: .noAction + ) + } + + // MARK: - Fallback Tests (plugin returns nil) + + @Test("Add column falls back to default when plugin returns nil") + func addColumnFallback() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let column = makeColumn() + let stmts = try generator.generate(changes: [.addColumn(column)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("ADD COLUMN")) + #expect(stmts[0].sql.contains("`email`")) + } + + @Test("Drop column falls back to default when plugin returns nil") + func dropColumnFallback() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let column = makeColumn() + let stmts = try generator.generate(changes: [.deleteColumn(column)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("DROP COLUMN")) + } + + @Test("No plugin driver uses default generation") + func noPluginDriverDefault() throws { + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .postgresql + ) + let index = makeIndex() + let stmts = try generator.generate(changes: [.addIndex(index)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("CREATE INDEX")) + } + + // MARK: - Plugin Override Tests + + @Test("Add column uses plugin SQL when provided") + func addColumnPluginOverride() throws { + let mock = MockPluginDriver() + mock.addColumnHandler = { table, col in + "ALTER TABLE \(table) ADD \(col.name) \(col.dataType) CUSTOM_SYNTAX" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let column = makeColumn() + let stmts = try generator.generate(changes: [.addColumn(column)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("CUSTOM_SYNTAX")) + #expect(!stmts[0].sql.contains("ADD COLUMN")) + } + + @Test("Modify column uses plugin SQL when provided") + func modifyColumnPluginOverride() throws { + let mock = MockPluginDriver() + mock.modifyColumnHandler = { _, oldCol, newCol in + "ALTER TABLE users CHANGE \(oldCol.name) TO \(newCol.name) PLUGIN_MODIFY" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let oldCol = makeColumn(name: "email") + let newCol = makeColumn(name: "email_address") + let stmts = try generator.generate(changes: [.modifyColumn(old: oldCol, new: newCol)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_MODIFY")) + } + + @Test("Drop column uses plugin SQL when provided") + func dropColumnPluginOverride() throws { + let mock = MockPluginDriver() + mock.dropColumnHandler = { table, colName in + "ALTER TABLE \(table) DROP \(colName) IF EXISTS" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let column = makeColumn() + let stmts = try generator.generate(changes: [.deleteColumn(column)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("IF EXISTS")) + } + + @Test("Add index uses plugin SQL when provided") + func addIndexPluginOverride() throws { + let mock = MockPluginDriver() + mock.addIndexHandler = { table, idx in + "CREATE INDEX \(idx.name) ON \(table) PLUGIN_INDEX" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let index = makeIndex() + let stmts = try generator.generate(changes: [.addIndex(index)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_INDEX")) + } + + @Test("Drop index uses plugin SQL when provided") + func dropIndexPluginOverride() throws { + let mock = MockPluginDriver() + mock.dropIndexHandler = { _, indexName in + "DROP INDEX IF EXISTS \(indexName)" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let index = makeIndex() + let stmts = try generator.generate(changes: [.deleteIndex(index)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("IF EXISTS")) + } + + @Test("Add foreign key uses plugin SQL when provided") + func addForeignKeyPluginOverride() throws { + let mock = MockPluginDriver() + mock.addForeignKeyHandler = { table, fk in + "ALTER TABLE \(table) ADD FK \(fk.name) PLUGIN_FK" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let fk = makeForeignKey() + let stmts = try generator.generate(changes: [.addForeignKey(fk)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_FK")) + } + + @Test("Drop foreign key uses plugin SQL when provided") + func dropForeignKeyPluginOverride() throws { + let mock = MockPluginDriver() + mock.dropForeignKeyHandler = { _, constraintName in + "ALTER TABLE users DROP CONSTRAINT \(constraintName) PLUGIN_DROP_FK" + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let fk = makeForeignKey() + let stmts = try generator.generate(changes: [.deleteForeignKey(fk)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_DROP_FK")) + } + + @Test("Modify primary key uses plugin SQL when provided") + func modifyPrimaryKeyPluginOverride() throws { + let mock = MockPluginDriver() + mock.modifyPrimaryKeyHandler = { table, _, newCols in + [ + "ALTER TABLE \(table) DROP PRIMARY KEY", + "ALTER TABLE \(table) ADD PRIMARY KEY (\(newCols.joined(separator: ", "))) PLUGIN_PK" + ] + } + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + let stmts = try generator.generate(changes: [.modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"])]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_PK")) + #expect(stmts[0].isDestructive) + } + + // MARK: - Mixed Override/Fallback + + @Test("Plugin overrides some operations while others fall back") + func mixedPluginAndFallback() throws { + let mock = MockPluginDriver() + mock.addColumnHandler = { _, col in + "PLUGIN_ADD_COL \(col.name)" + } + // dropColumnHandler is nil, so drop falls back to default + + let generator = SchemaStatementGenerator( + tableName: "users", databaseType: .mysql, pluginDriver: mock + ) + + let addCol = makeColumn(name: "age", dataType: "INT") + let dropCol = makeColumn(name: "old_field") + + let stmts = try generator.generate(changes: [ + .addColumn(addCol), + .deleteColumn(dropCol) + ]) + + #expect(stmts.count == 2) + + // Drop comes first due to dependency ordering + let dropStmt = stmts[0] + #expect(dropStmt.sql.contains("DROP COLUMN")) + #expect(!dropStmt.sql.contains("PLUGIN")) + + // Add uses plugin override + let addStmt = stmts[1] + #expect(addStmt.sql.contains("PLUGIN_ADD_COL")) + } +} From bb7c9037c8a56dc96b855b52568dc65c0f3d0fb7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:39 +0700 Subject: [PATCH 06/15] feat: add table operation methods to PluginDatabaseDriver protocol --- CHANGELOG.md | 1 + .../PluginDatabaseDriver.swift | 11 + .../Core/Plugins/PluginDriverAdapter.swift | 18 ++ ...inContentCoordinator+TableOperations.swift | 55 ++++- .../Main/TableOperationsPluginTests.swift | 204 ++++++++++++++++++ 5 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 TableProTests/Views/Main/TableOperationsPluginTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..28762d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Plugin-provided table operations: `truncateTableStatements`, `dropObjectStatement`, `foreignKeyDisableStatements`, `foreignKeyEnableStatements` in `PluginDatabaseDriver` protocol, allowing plugins to override TRUNCATE, DROP, and FK handling SQL - `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 - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d0a9492d..6b94217d 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -85,6 +85,12 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws + + // Table operations (optional — return nil to use app-level fallback) + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? + func foreignKeyDisableStatements() -> [String]? + func foreignKeyEnableStatements() -> [String]? } public extension PluginDatabaseDriver { @@ -175,6 +181,11 @@ public extension PluginDatabaseDriver { func buildCombinedQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, searchText: String, searchColumns: [String], sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func generateStatements(table: String, columns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } + func foreignKeyDisableStatements() -> [String]? { nil } + func foreignKeyEnableStatements() -> [String]? { nil } + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 31ef4ade..2010c294 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -273,6 +273,24 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.switchDatabase(to: database) } + // MARK: - Table Operations + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + pluginDriver.truncateTableStatements(table: table, schema: schema, cascade: cascade) + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + pluginDriver.dropObjectStatement(name: name, objectType: objectType, schema: schema, cascade: cascade) + } + + func foreignKeyDisableStatements() -> [String]? { + pluginDriver.foreignKeyDisableStatements() + } + + func foreignKeyEnableStatements() -> [String]? { + pluginDriver.foreignKeyEnableStatements() + } + // MARK: - Result Mapping private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index d4e01e3f..dc2a15be 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -8,6 +8,13 @@ import Foundation extension MainContentCoordinator { + // MARK: - Plugin Adapter Access + + /// Returns the current connection's PluginDriverAdapter, if available. + private var currentPluginDriverAdapter: PluginDriverAdapter? { + DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter + } + // MARK: - Table Operation SQL Generation /// Generates SQL statements for table truncate/drop operations. @@ -45,7 +52,9 @@ extension MainContentCoordinator { for tableName in sortedTruncates { let quotedName = dbType.quoteIdentifier(tableName) let tableOptions = options[tableName] ?? TableOperationOptions() - statements.append(contentsOf: truncateStatements(tableName: tableName, quotedName: quotedName, options: tableOptions, dbType: dbType)) + statements.append(contentsOf: truncateStatements( + tableName: tableName, quotedName: quotedName, options: tableOptions, dbType: dbType + )) } let viewNames: Set = { @@ -56,7 +65,10 @@ extension MainContentCoordinator { for tableName in sortedDeletes { let quotedName = dbType.quoteIdentifier(tableName) let tableOptions = options[tableName] ?? TableOperationOptions() - statements.append(dropTableStatement(tableName: tableName, quotedName: quotedName, isView: viewNames.contains(tableName), options: tableOptions, dbType: dbType)) + statements.append(dropTableStatement( + tableName: tableName, quotedName: quotedName, + isView: viewNames.contains(tableName), options: tableOptions, dbType: dbType + )) } // FK re-enable must be OUTSIDE transaction to ensure it runs even on rollback @@ -70,8 +82,13 @@ extension MainContentCoordinator { // MARK: - Foreign Key Handling /// Returns SQL statements to disable foreign key checks for the database type. + /// Tries plugin-provided statements first, falls back to built-in switch. /// - Note: PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead. func fkDisableStatements(for dbType: DatabaseType) -> [String] { + if let adapter = currentPluginDriverAdapter, + let stmts = adapter.foreignKeyDisableStatements() { + return stmts + } switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=0"] case .postgresql, .redshift, .clickhouse, .mongodb, .redis, .mssql, .oracle, .duckdb: return [] @@ -80,7 +97,12 @@ extension MainContentCoordinator { } /// Returns SQL statements to re-enable foreign key checks for the database type. + /// Tries plugin-provided statements first, falls back to built-in switch. func fkEnableStatements(for dbType: DatabaseType) -> [String] { + if let adapter = currentPluginDriverAdapter, + let stmts = adapter.foreignKeyEnableStatements() { + return stmts + } switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=1"] @@ -94,8 +116,17 @@ extension MainContentCoordinator { // MARK: - Private SQL Builders /// Generates TRUNCATE/DELETE statements for a table. + /// Tries plugin-provided statements first, falls back to built-in switch. /// - Note: SQLite uses DELETE and resets auto-increment via sqlite_sequence. - private func truncateStatements(tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType) -> [String] { + private func truncateStatements( + tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType + ) -> [String] { + if let adapter = currentPluginDriverAdapter, + let stmts = adapter.truncateTableStatements( + table: tableName, schema: nil, cascade: options.cascade + ) { + return stmts + } switch dbType { case .mysql, .mariadb, .clickhouse, .duckdb: return ["TRUNCATE TABLE \(quotedName)"] @@ -118,7 +149,8 @@ extension MainContentCoordinator { "DELETE FROM sqlite_sequence WHERE name = '\(escapedName)'" ] case .mongodb: - let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") return ["db[\"\(escaped)\"].deleteMany({})"] case .redis: return ["FLUSHDB"] @@ -126,15 +158,26 @@ extension MainContentCoordinator { } /// Generates DROP TABLE/VIEW statement with optional CASCADE. - private func dropTableStatement(tableName: String, quotedName: String, isView: Bool, options: TableOperationOptions, dbType: DatabaseType) -> String { + /// Tries plugin-provided statement first, falls back to built-in switch. + private func dropTableStatement( + tableName: String, quotedName: String, isView: Bool, + options: TableOperationOptions, dbType: DatabaseType + ) -> String { let keyword = isView ? "VIEW" : "TABLE" + if let adapter = currentPluginDriverAdapter, + let stmt = adapter.dropObjectStatement( + name: tableName, objectType: keyword, schema: nil, cascade: options.cascade + ) { + return stmt + } switch dbType { case .postgresql, .redshift: return "DROP \(keyword) \(quotedName)\(options.cascade ? " CASCADE" : "")" case .mysql, .mariadb, .clickhouse, .sqlite, .mssql, .oracle, .duckdb: return "DROP \(keyword) \(quotedName)" case .mongodb: - let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") return "db[\"\(escaped)\"].drop()" case .redis: return "DEL \(tableName)" diff --git a/TableProTests/Views/Main/TableOperationsPluginTests.swift b/TableProTests/Views/Main/TableOperationsPluginTests.swift new file mode 100644 index 00000000..eb1f371d --- /dev/null +++ b/TableProTests/Views/Main/TableOperationsPluginTests.swift @@ -0,0 +1,204 @@ +// +// TableOperationsPluginTests.swift +// TableProTests +// +// Tests for plugin-first table operation SQL generation in +// MainContentCoordinator+TableOperations. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("TableOperations Plugin Fallback") +@MainActor +struct TableOperationsPluginTests { + // When no plugin driver is connected, the coordinator falls back + // to built-in DatabaseType switches. These tests verify that fallback. + + private func makeCoordinator(type: DatabaseType = .mysql) -> MainContentCoordinator { + let connection = TestFixtures.makeConnection(database: "testdb", type: type) + let tabManager = QueryTabManager() + let changeManager = DataChangeManager() + let filterStateManager = FilterStateManager() + let toolbarState = ConnectionToolbarState() + + return MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: changeManager, + filterStateManager: filterStateManager, + toolbarState: toolbarState + ) + } + + // MARK: - FK Disable Fallback (no plugin) + + @Test("FK disable: MySQL returns SET FOREIGN_KEY_CHECKS=0") + func fkDisableMySQL() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.fkDisableStatements(for: .mysql) + #expect(stmts == ["SET FOREIGN_KEY_CHECKS=0"]) + } + + @Test("FK disable: MariaDB returns SET FOREIGN_KEY_CHECKS=0") + func fkDisableMariaDB() { + let coordinator = makeCoordinator(type: .mariadb) + defer { coordinator.teardown() } + + let stmts = coordinator.fkDisableStatements(for: .mariadb) + #expect(stmts == ["SET FOREIGN_KEY_CHECKS=0"]) + } + + @Test("FK disable: SQLite returns PRAGMA foreign_keys = OFF") + func fkDisableSQLite() { + let coordinator = makeCoordinator(type: .sqlite) + defer { coordinator.teardown() } + + let stmts = coordinator.fkDisableStatements(for: .sqlite) + #expect(stmts == ["PRAGMA foreign_keys = OFF"]) + } + + @Test("FK disable: PostgreSQL returns empty") + func fkDisablePostgreSQL() { + let coordinator = makeCoordinator(type: .postgresql) + defer { coordinator.teardown() } + + let stmts = coordinator.fkDisableStatements(for: .postgresql) + #expect(stmts.isEmpty) + } + + // MARK: - FK Enable Fallback (no plugin) + + @Test("FK enable: MySQL returns SET FOREIGN_KEY_CHECKS=1") + func fkEnableMySQL() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.fkEnableStatements(for: .mysql) + #expect(stmts == ["SET FOREIGN_KEY_CHECKS=1"]) + } + + @Test("FK enable: SQLite returns PRAGMA foreign_keys = ON") + func fkEnableSQLite() { + let coordinator = makeCoordinator(type: .sqlite) + defer { coordinator.teardown() } + + let stmts = coordinator.fkEnableStatements(for: .sqlite) + #expect(stmts == ["PRAGMA foreign_keys = ON"]) + } + + // MARK: - Truncate Fallback (no plugin) + + @Test("Truncate: MySQL uses TRUNCATE TABLE with backtick-quoted name") + func truncateMySQL() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: ["users"], + deletes: [], + options: [:], + includeFKHandling: false + ) + #expect(stmts == ["TRUNCATE TABLE `users`"]) + } + + @Test("Truncate: PostgreSQL with cascade") + func truncatePostgreSQLCascade() { + let coordinator = makeCoordinator(type: .postgresql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: ["orders"], + deletes: [], + options: ["orders": TableOperationOptions(ignoreForeignKeys: false, cascade: true)], + includeFKHandling: false + ) + #expect(stmts == ["TRUNCATE TABLE \"orders\" CASCADE"]) + } + + @Test("Truncate: PostgreSQL without cascade") + func truncatePostgreSQLNoCascade() { + let coordinator = makeCoordinator(type: .postgresql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: ["orders"], + deletes: [], + options: [:], + includeFKHandling: false + ) + #expect(stmts == ["TRUNCATE TABLE \"orders\""]) + } + + // MARK: - Drop Fallback (no plugin) + + @Test("Drop: MySQL uses DROP TABLE with backtick-quoted name") + func dropMySQL() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: [], + deletes: ["users"], + options: [:], + includeFKHandling: false + ) + #expect(stmts == ["DROP TABLE `users`"]) + } + + @Test("Drop: PostgreSQL with cascade") + func dropPostgreSQLCascade() { + let coordinator = makeCoordinator(type: .postgresql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: [], + deletes: ["orders"], + options: ["orders": TableOperationOptions(ignoreForeignKeys: false, cascade: true)], + includeFKHandling: false + ) + #expect(stmts == ["DROP TABLE \"orders\" CASCADE"]) + } + + // MARK: - Combined Operations + + @Test("MySQL: truncate + drop with FK handling") + func combinedMySQLWithFK() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: ["alpha"], + deletes: ["beta"], + options: [ + "alpha": TableOperationOptions(ignoreForeignKeys: true, cascade: false), + "beta": TableOperationOptions(ignoreForeignKeys: true, cascade: false) + ], + includeFKHandling: true + ) + // FK disable, truncate alpha, drop beta, FK enable + #expect(stmts.first == "SET FOREIGN_KEY_CHECKS=0") + #expect(stmts.last == "SET FOREIGN_KEY_CHECKS=1") + #expect(stmts.contains("TRUNCATE TABLE `alpha`")) + #expect(stmts.contains("DROP TABLE `beta`")) + } + + @Test("Tables are sorted for consistent execution order") + func sortedOrder() { + let coordinator = makeCoordinator(type: .mysql) + defer { coordinator.teardown() } + + let stmts = coordinator.generateTableOperationSQL( + truncates: ["zebra", "apple"], + deletes: [], + options: [:], + includeFKHandling: false + ) + #expect(stmts == ["TRUNCATE TABLE `apple`", "TRUNCATE TABLE `zebra`"]) + } +} From 5e382b831d05ea5ac84067a47d21d1c651e0da38 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:44 +0700 Subject: [PATCH 07/15] feat: move MSSQL/Oracle pagination to plugin query building hooks --- CHANGELOG.md | 4 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 197 +++++++++++++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 195 +++++++++++++ TablePro/Core/Database/DatabaseDriver.swift | 6 +- .../Core/Plugins/PluginDriverAdapter.swift | 8 +- .../Services/Query/TableQueryBuilder.swift | 276 +----------------- .../Views/Main/MainContentCoordinator.swift | 10 +- 7 files changed, 412 insertions(+), 284 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..ad51102a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form - Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass) +### Changed + +- Moved MSSQL and Oracle pagination query building (`OFFSET...FETCH NEXT`) from `TableQueryBuilder` into their respective plugin drivers via `buildBrowseQuery`/`buildFilteredQuery`/`buildQuickSearchQuery`/`buildCombinedQuery` hooks + ### Fixed - Plugin icon rendering now supports custom asset images (e.g., duckdb-icon) alongside SF Symbols in Installed and Browse tabs diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 899914cf..342f0c02 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -967,6 +967,203 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "CREATE DATABASE \(quotedName)") } + // MARK: - Query Building + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = mssqlQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let orderBy = mssqlBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = mssqlQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let whereClause = mssqlBuildWhereClause(filters: filters, logicMode: logicMode) + if !whereClause.isEmpty { + query += " WHERE \(whereClause)" + } + let orderBy = mssqlBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildQuickSearchQuery( + table: String, + searchText: String, + columns: [String], + sortColumns: [(columnIndex: Int, ascending: Bool)], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = mssqlQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let escapedSearch = mssqlEscapeForLike(searchText) + let conditions = columns.map { column -> String in + let quotedColumn = mssqlQuoteIdentifier(column) + return "CAST(\(quotedColumn) AS NVARCHAR(MAX)) LIKE '%\(escapedSearch)%' ESCAPE '\\'" + } + if !conditions.isEmpty { + query += " WHERE (" + conditions.joined(separator: " OR ") + ")" + } + let orderBy = mssqlBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildCombinedQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + searchText: String, + searchColumns: [String], + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = mssqlQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let filterConditions = mssqlBuildWhereClause(filters: filters, logicMode: logicMode) + let escapedSearch = mssqlEscapeForLike(searchText) + let searchConditions = searchColumns.map { column -> String in + let quotedColumn = mssqlQuoteIdentifier(column) + return "CAST(\(quotedColumn) AS NVARCHAR(MAX)) LIKE '%\(escapedSearch)%' ESCAPE '\\'" + } + let searchClause = searchConditions.isEmpty + ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" + var whereParts: [String] = [] + if !filterConditions.isEmpty { + whereParts.append("(\(filterConditions))") + } + if !searchClause.isEmpty { + whereParts.append(searchClause) + } + if !whereParts.isEmpty { + query += " WHERE " + whereParts.joined(separator: " AND ") + } + let orderBy = mssqlBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + // MARK: - Query Building Helpers + + private func mssqlQuoteIdentifier(_ identifier: String) -> String { + "[\(identifier.replacingOccurrences(of: "]", with: "]]"))]" + } + + private func mssqlBuildOrderByClause( + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String] + ) -> String? { + let parts = sortColumns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < columns.count else { return nil } + let columnName = columns[sortCol.columnIndex] + let direction = sortCol.ascending ? "ASC" : "DESC" + let quotedColumn = mssqlQuoteIdentifier(columnName) + return "\(quotedColumn) \(direction)" + } + guard !parts.isEmpty else { return nil } + return "ORDER BY " + parts.joined(separator: ", ") + } + + private func mssqlEscapeForLike(_ text: String) -> String { + text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + .replacingOccurrences(of: "'", with: "''") + } + + private func mssqlEscapeValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespaces) + if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" } + if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { return "1" } + if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { return "0" } + if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed } + return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'" + } + + private func mssqlBuildWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String + ) -> String { + let conditions = filters.compactMap { filter -> String? in + mssqlBuildFilterCondition(column: filter.column, op: filter.op, value: filter.value) + } + guard !conditions.isEmpty else { return "" } + let separator = logicMode == "and" ? " AND " : " OR " + return conditions.joined(separator: separator) + } + + private func mssqlBuildFilterCondition(column: String, op: String, value: String) -> String? { + let quoted = mssqlQuoteIdentifier(column) + switch op { + case "=": return "\(quoted) = \(mssqlEscapeValue(value))" + case "!=": return "\(quoted) != \(mssqlEscapeValue(value))" + case ">": return "\(quoted) > \(mssqlEscapeValue(value))" + case ">=": return "\(quoted) >= \(mssqlEscapeValue(value))" + case "<": return "\(quoted) < \(mssqlEscapeValue(value))" + case "<=": return "\(quoted) <= \(mssqlEscapeValue(value))" + case "IS NULL": return "\(quoted) IS NULL" + case "IS NOT NULL": return "\(quoted) IS NOT NULL" + case "IS EMPTY": return "(\(quoted) IS NULL OR \(quoted) = '')" + case "IS NOT EMPTY": return "(\(quoted) IS NOT NULL AND \(quoted) != '')" + case "CONTAINS": + let escaped = mssqlEscapeForLike(value) + return "\(quoted) LIKE '%\(escaped)%' ESCAPE '\\'" + case "NOT CONTAINS": + let escaped = mssqlEscapeForLike(value) + return "\(quoted) NOT LIKE '%\(escaped)%' ESCAPE '\\'" + case "STARTS WITH": + let escaped = mssqlEscapeForLike(value) + return "\(quoted) LIKE '\(escaped)%' ESCAPE '\\'" + case "ENDS WITH": + let escaped = mssqlEscapeForLike(value) + return "\(quoted) LIKE '%\(escaped)' ESCAPE '\\'" + case "IN": + let values = value.split(separator: ",") + .map { mssqlEscapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) IN (\(values))" + case "NOT IN": + let values = value.split(separator: ",") + .map { mssqlEscapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) NOT IN (\(values))" + case "BETWEEN": + let parts = value.split(separator: ",", maxSplits: 1) + guard parts.count == 2 else { return nil } + let v1 = mssqlEscapeValue(parts[0].trimmingCharacters(in: .whitespaces)) + let v2 = mssqlEscapeValue(parts[1].trimmingCharacters(in: .whitespaces)) + return "\(quoted) BETWEEN \(v1) AND \(v2)" + case "REGEX": + let escaped = value.replacingOccurrences(of: "'", with: "''") + return "\(quoted) LIKE '%\(escaped)%'" + default: return nil + } + } + // MARK: - Private Helpers /// Convert `?` placeholders to `@p1, @p2, ...` and build sp_executesql components. diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 142c6c5c..46697eff 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -549,6 +549,201 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { _currentSchema = schema } + // MARK: - Query Building + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = oracleQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let orderBy = oracleBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = oracleQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let whereClause = oracleBuildWhereClause(filters: filters, logicMode: logicMode) + if !whereClause.isEmpty { + query += " WHERE \(whereClause)" + } + let orderBy = oracleBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildQuickSearchQuery( + table: String, + searchText: String, + columns: [String], + sortColumns: [(columnIndex: Int, ascending: Bool)], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = oracleQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let escapedSearch = oracleEscapeForLike(searchText) + let conditions = columns.map { column -> String in + let quotedColumn = oracleQuoteIdentifier(column) + return "CAST(\(quotedColumn) AS VARCHAR2(4000)) LIKE '%\(escapedSearch)%' ESCAPE '\\'" + } + if !conditions.isEmpty { + query += " WHERE (" + conditions.joined(separator: " OR ") + ")" + } + let orderBy = oracleBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + func buildCombinedQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + searchText: String, + searchColumns: [String], + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let quotedTable = oracleQuoteIdentifier(table) + var query = "SELECT * FROM \(quotedTable)" + let filterConditions = oracleBuildWhereClause(filters: filters, logicMode: logicMode) + let escapedSearch = oracleEscapeForLike(searchText) + let searchConditions = searchColumns.map { column -> String in + let quotedColumn = oracleQuoteIdentifier(column) + return "CAST(\(quotedColumn) AS VARCHAR2(4000)) LIKE '%\(escapedSearch)%' ESCAPE '\\'" + } + let searchClause = searchConditions.isEmpty + ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" + var whereParts: [String] = [] + if !filterConditions.isEmpty { + whereParts.append("(\(filterConditions))") + } + if !searchClause.isEmpty { + whereParts.append(searchClause) + } + if !whereParts.isEmpty { + query += " WHERE " + whereParts.joined(separator: " AND ") + } + let orderBy = oracleBuildOrderByClause(sortColumns: sortColumns, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + // MARK: - Query Building Helpers + + private func oracleQuoteIdentifier(_ identifier: String) -> String { + "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private func oracleBuildOrderByClause( + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String] + ) -> String? { + let parts = sortColumns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < columns.count else { return nil } + let columnName = columns[sortCol.columnIndex] + let direction = sortCol.ascending ? "ASC" : "DESC" + let quotedColumn = oracleQuoteIdentifier(columnName) + return "\(quotedColumn) \(direction)" + } + guard !parts.isEmpty else { return nil } + return "ORDER BY " + parts.joined(separator: ", ") + } + + private func oracleEscapeForLike(_ text: String) -> String { + text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + .replacingOccurrences(of: "'", with: "''") + } + + private func oracleEscapeValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespaces) + if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" } + if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed } + return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'" + } + + private func oracleBuildWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String + ) -> String { + let conditions = filters.compactMap { filter -> String? in + oracleBuildFilterCondition(column: filter.column, op: filter.op, value: filter.value) + } + guard !conditions.isEmpty else { return "" } + let separator = logicMode == "and" ? " AND " : " OR " + return conditions.joined(separator: separator) + } + + private func oracleBuildFilterCondition(column: String, op: String, value: String) -> String? { + let quoted = oracleQuoteIdentifier(column) + switch op { + case "=": return "\(quoted) = \(oracleEscapeValue(value))" + case "!=": return "\(quoted) != \(oracleEscapeValue(value))" + case ">": return "\(quoted) > \(oracleEscapeValue(value))" + case ">=": return "\(quoted) >= \(oracleEscapeValue(value))" + case "<": return "\(quoted) < \(oracleEscapeValue(value))" + case "<=": return "\(quoted) <= \(oracleEscapeValue(value))" + case "IS NULL": return "\(quoted) IS NULL" + case "IS NOT NULL": return "\(quoted) IS NOT NULL" + case "IS EMPTY": return "(\(quoted) IS NULL OR \(quoted) = '')" + case "IS NOT EMPTY": return "(\(quoted) IS NOT NULL AND \(quoted) != '')" + case "CONTAINS": + let escaped = oracleEscapeForLike(value) + return "\(quoted) LIKE '%\(escaped)%' ESCAPE '\\'" + case "NOT CONTAINS": + let escaped = oracleEscapeForLike(value) + return "\(quoted) NOT LIKE '%\(escaped)%' ESCAPE '\\'" + case "STARTS WITH": + let escaped = oracleEscapeForLike(value) + return "\(quoted) LIKE '\(escaped)%' ESCAPE '\\'" + case "ENDS WITH": + let escaped = oracleEscapeForLike(value) + return "\(quoted) LIKE '%\(escaped)' ESCAPE '\\'" + case "IN": + let values = value.split(separator: ",") + .map { oracleEscapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) IN (\(values))" + case "NOT IN": + let values = value.split(separator: ",") + .map { oracleEscapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) NOT IN (\(values))" + case "BETWEEN": + let parts = value.split(separator: ",", maxSplits: 1) + guard parts.count == 2 else { return nil } + let v1 = oracleEscapeValue(parts[0].trimmingCharacters(in: .whitespaces)) + let v2 = oracleEscapeValue(parts[1].trimmingCharacters(in: .whitespaces)) + return "\(quoted) BETWEEN \(v1) AND \(v2)" + case "REGEX": + let escaped = value.replacingOccurrences(of: "'", with: "''") + return "\(quoted) LIKE '%\(escaped)%'" + default: return nil + } + } + // MARK: - Private Helpers private func buildOracleFullType( diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 90f693a8..3cd7ed59 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -138,8 +138,8 @@ protocol DatabaseDriver: AnyObject { /// Rollback the current transaction func rollbackTransaction() async throws - /// Access to the underlying plugin driver for NoSQL query dispatch - var noSqlPluginDriver: (any PluginDatabaseDriver)? { get } + /// Access to the underlying plugin driver for query building dispatch + var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { get } } // MARK: - Schema Switching @@ -158,7 +158,7 @@ extension DatabaseDriver { /// Override in drivers that support version querying var serverVersion: String? { nil } - var noSqlPluginDriver: (any PluginDatabaseDriver)? { nil } + var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { nil } func testConnection() async throws -> Bool { try await connect() diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 31ef4ade..f4676897 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -13,10 +13,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private let pluginDriver: any PluginDatabaseDriver var serverVersion: String? { pluginDriver.serverVersion } - var noSqlPluginDriver: (any PluginDatabaseDriver)? { - // Only expose plugin driver for NoSQL dispatch if it actually handles query building. - // SQL drivers (MySQL, PostgreSQL, etc.) return nil from buildBrowseQuery and should - // use standard SQL query rewriting for sort/filter instead. + var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { + // Expose plugin driver for query building dispatch if it implements the hooks. + // SQL drivers without custom pagination (MySQL, PostgreSQL, etc.) return nil + // from buildBrowseQuery and use standard SQL query rewriting instead. guard pluginDriver.buildBrowseQuery( table: "_probe", sortColumns: [], columns: [], limit: 1, offset: 0 ) != nil else { diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index e889d469..cf4c917d 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -44,7 +44,7 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) + // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildBrowseQuery( @@ -55,20 +55,6 @@ struct TableQueryBuilder { } } - if databaseType == .mssql { - return buildMSSQLBaseQuery( - tableName: tableName, sortState: sortState, - columns: columns, limit: limit, offset: offset - ) - } - - if databaseType == .oracle { - return buildOracleBaseQuery( - tableName: tableName, sortState: sortState, - columns: columns, limit: limit, offset: offset - ) - } - let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -100,7 +86,7 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) + // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -115,30 +101,6 @@ struct TableQueryBuilder { } } - if databaseType == .mssql { - return buildMSSQLFilteredQuery( - tableName: tableName, - filters: filters, - logicMode: logicMode, - sortState: sortState, - columns: columns, - limit: limit, - offset: offset - ) - } - - if databaseType == .oracle { - return buildOracleFilteredQuery( - tableName: tableName, - filters: filters, - logicMode: logicMode, - sortState: sortState, - columns: columns, - limit: limit, - offset: offset - ) - } - let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -175,7 +137,7 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) + // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildQuickSearchQuery( @@ -186,20 +148,6 @@ struct TableQueryBuilder { } } - if databaseType == .mssql { - return buildMSSQLQuickSearchQuery( - tableName: tableName, searchText: searchText, columns: columns, - sortState: sortState, limit: limit, offset: offset - ) - } - - if databaseType == .oracle { - return buildOracleQuickSearchQuery( - tableName: tableName, searchText: searchText, columns: columns, - sortState: sortState, limit: limit, offset: offset - ) - } - let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -246,7 +194,7 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) + // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -262,22 +210,6 @@ struct TableQueryBuilder { } } - if databaseType == .mssql { - return buildMSSQLCombinedQuery( - tableName: tableName, filters: filters, logicMode: logicMode, - searchText: searchText, searchColumns: searchColumns, - sortState: sortState, columns: columns, limit: limit, offset: offset - ) - } - - if databaseType == .oracle { - return buildOracleCombinedQuery( - tableName: tableName, filters: filters, logicMode: logicMode, - searchText: searchText, searchColumns: searchColumns, - sortState: sortState, columns: columns, limit: limit, offset: offset - ) - } - let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -490,204 +422,4 @@ struct TableQueryBuilder { return "CAST(\(column) AS VARCHAR2(4000)) LIKE '%\(searchText)%' ESCAPE '\\'" } } - - // MARK: - MSSQL Query Helpers - - private func buildMSSQLBaseQuery( - tableName: String, - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildMSSQLFilteredQuery( - tableName: String, - filters: [TableFilter], - logicMode: FilterLogicMode, - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let generator = FilterSQLGenerator(databaseType: databaseType) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) - if !whereClause.isEmpty { - query += " \(whereClause)" - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildMSSQLQuickSearchQuery( - tableName: String, - searchText: String, - columns: [String], - sortState: SortState?, - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let escapedSearch = escapeForLike(searchText) - let conditions = columns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - if !conditions.isEmpty { - query += " WHERE (" + conditions.joined(separator: " OR ") + ")" - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildMSSQLCombinedQuery( - tableName: String, - filters: [TableFilter], - logicMode: FilterLogicMode, - searchText: String, - searchColumns: [String], - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let generator = FilterSQLGenerator(databaseType: databaseType) - let filterConditions = generator.generateConditions(from: filters, logicMode: logicMode) - let escapedSearch = escapeForLike(searchText) - let searchConditions = searchColumns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - let searchClause = searchConditions.isEmpty ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" - var whereParts: [String] = [] - if !filterConditions.isEmpty { - whereParts.append("(\(filterConditions))") - } - if !searchClause.isEmpty { - whereParts.append(searchClause) - } - if !whereParts.isEmpty { - query += " WHERE " + whereParts.joined(separator: " AND ") - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - // MARK: - Oracle Query Helpers - - private func buildOracleBaseQuery( - tableName: String, - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY 1" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildOracleFilteredQuery( - tableName: String, - filters: [TableFilter], - logicMode: FilterLogicMode, - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let generator = FilterSQLGenerator(databaseType: databaseType) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) - if !whereClause.isEmpty { - query += " \(whereClause)" - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY 1" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildOracleQuickSearchQuery( - tableName: String, - searchText: String, - columns: [String], - sortState: SortState?, - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let escapedSearch = escapeForLike(searchText) - let conditions = columns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - if !conditions.isEmpty { - query += " WHERE (" + conditions.joined(separator: " OR ") + ")" - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY 1" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } - - private func buildOracleCombinedQuery( - tableName: String, - filters: [TableFilter], - logicMode: FilterLogicMode, - searchText: String, - searchColumns: [String], - sortState: SortState?, - columns: [String], - limit: Int, - offset: Int - ) -> String { - let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - let generator = FilterSQLGenerator(databaseType: databaseType) - let filterConditions = generator.generateConditions(from: filters, logicMode: logicMode) - let escapedSearch = escapeForLike(searchText) - let searchConditions = searchColumns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - let searchClause = searchConditions.isEmpty ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" - var whereParts: [String] = [] - if !filterConditions.isEmpty { - whereParts.append("(\(filterConditions))") - } - if !searchClause.isEmpty { - whereParts.append(searchClause) - } - if !whereParts.isEmpty { - query += " WHERE " + whereParts.joined(separator: " AND ") - } - let orderBy = buildOrderByClause(sortState: sortState, columns: columns) - ?? "ORDER BY 1" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query - } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 0394519f..6d684db7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -279,14 +279,14 @@ final class MainContentCoordinator { rightPanelState?.activeTab = .aiChat } - /// Set up the plugin driver for NoSQL query dispatch on the query builder and change manager. + /// Set up the plugin driver for query building dispatch on the query builder and change manager. private func setupPluginDriver() { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - let noSqlDriver = driver.noSqlPluginDriver - queryBuilder.setPluginDriver(noSqlDriver) - changeManager.pluginDriver = noSqlDriver + let pluginDriver = driver.queryBuildingPluginDriver + queryBuilder.setPluginDriver(pluginDriver) + changeManager.pluginDriver = pluginDriver // Remove observer once successfully set up - if noSqlDriver != nil, let observer = pluginDriverObserver { + if pluginDriver != nil, let observer = pluginDriverObserver { NotificationCenter.default.removeObserver(observer) pluginDriverObserver = nil } From 0157cf993ed6b9015b0e9bbc2661d7ef5bfc7335 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:21:50 +0700 Subject: [PATCH 08/15] feat: add buildExplainQuery to PluginDatabaseDriver protocol --- CHANGELOG.md | 1 + .../ClickHousePlugin.swift | 6 ++ Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 6 ++ .../MongoDBPluginDriver.swift | 62 +++++++++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 6 ++ .../PostgreSQLPluginDriver.swift | 6 ++ .../RedshiftPluginDriver.swift | 6 ++ .../RedisDriverPlugin/RedisPluginDriver.swift | 14 +++ Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 6 ++ .../PluginDatabaseDriver.swift | 5 ++ .../Core/Plugins/PluginDriverAdapter.swift | 6 ++ .../Views/Main/MainContentCoordinator.swift | 38 +++++--- .../Plugins/ExplainQueryPluginTests.swift | 86 +++++++++++++++++++ 13 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 TableProTests/Core/Plugins/ExplainQueryPluginTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..9ff264ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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 - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 378a9ba5..2df34a8f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -525,6 +525,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { lock.unlock() } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + // MARK: - Kill Query private func killQuery(queryId: String) { diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 21cc35ad..16ed5215 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -764,6 +764,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw DuckDBPluginError.unsupportedOperation } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + // MARK: - Private Helpers nonisolated private func setInterruptHandle(_ handle: duckdb_connection?) { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index ecbddb24..a05a904a 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -436,6 +436,68 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { currentDb = database } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + guard let operation = try? MongoShellParser.parse(sql) else { + return "db.runCommand({\"explain\": \"\(sql)\", \"verbosity\": \"executionStats\"})" + } + + switch operation { + case .find(let collection, let filter, let options): + var findDoc = "\"find\": \"\(collection)\", \"filter\": \(filter)" + if let sort = options.sort { + findDoc += ", \"sort\": \(sort)" + } + if let skip = options.skip { + findDoc += ", \"skip\": \(skip)" + } + if let limit = options.limit { + findDoc += ", \"limit\": \(limit)" + } + if let projection = options.projection { + findDoc += ", \"projection\": \(projection)" + } + return "db.runCommand({\"explain\": {\(findDoc)}, \"verbosity\": \"executionStats\"})" + + case .findOne(let collection, let filter): + return "db.runCommand({\"explain\": {\"find\": \"\(collection)\", \"filter\": \(filter), \"limit\": 1}, \"verbosity\": \"executionStats\"})" + + case .aggregate(let collection, let pipeline): + return "db.runCommand({\"explain\": {\"aggregate\": \"\(collection)\", \"pipeline\": \(pipeline), \"cursor\": {}}, \"verbosity\": \"executionStats\"})" + + case .countDocuments(let collection, let filter): + return "db.runCommand({\"explain\": {\"count\": \"\(collection)\", \"query\": \(filter)}, \"verbosity\": \"executionStats\"})" + + case .deleteOne(let collection, let filter): + return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 1}]}, \"verbosity\": \"executionStats\"})" + + case .deleteMany(let collection, let filter): + return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 0}]}, \"verbosity\": \"executionStats\"})" + + case .updateOne(let collection, let filter, let update): + return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": false}]}, \"verbosity\": \"executionStats\"})" + + case .updateMany(let collection, let filter, let update): + return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": true}]}, \"verbosity\": \"executionStats\"})" + + case .findOneAndUpdate(let collection, let filter, let update): + let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(update)" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + + case .findOneAndReplace(let collection, let filter, let replacement): + let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(replacement)" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + + case .findOneAndDelete(let collection, let filter): + let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"remove\": true" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + + default: + return "db.runCommand({\"explain\": \"\(sql)\", \"verbosity\": \"executionStats\"})" + } + } + // MARK: - Query Building func buildBrowseQuery( diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 966152f9..fb79e92e 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -577,6 +577,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + // MARK: - Private Helpers private func extractTableName(from query: String) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 35994236..7742436e 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -159,6 +159,12 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "SET statement_timeout = '\(ms)'") } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 48c3d54c..1830f3d6 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -158,6 +158,12 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "SET statement_timeout = '\(ms)'") } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index cd12c1fb..009edc82 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -359,6 +359,20 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await conn.selectDatabase(dbIndex) } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + + if parts.count >= 2 { + let key = parts[1] + return "DEBUG OBJECT \(key)" + } + + return "INFO commandstats" + } + // MARK: - Query Building func buildBrowseQuery( diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3d0b7e6c..26b94544 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -350,6 +350,12 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { sqlite3_interrupt(db) } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN QUERY PLAN \(sql)" + } + // MARK: - Pagination func fetchRowCount(query: String) async throws -> Int { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d0a9492d..70b1bdd8 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -85,6 +85,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws + + // EXPLAIN query building (optional) + func buildExplainQuery(_ sql: String) -> String? } public extension PluginDatabaseDriver { @@ -175,6 +178,8 @@ public extension PluginDatabaseDriver { func buildCombinedQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, searchText: String, searchColumns: [String], sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func generateStatements(table: String, columns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } + func buildExplainQuery(_ sql: String) -> String? { nil } + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 31ef4ade..3033cbfc 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -273,6 +273,12 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.switchDatabase(to: database) } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + pluginDriver.buildExplainQuery(sql) + } + // MARK: - Result Mapping private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 0394519f..b958a302 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -673,22 +673,32 @@ final class MainContentCoordinator { let statements = SQLStatementScanner.allStatements(in: trimmed) guard let stmt = statements.first else { return } - // Build database-specific EXPLAIN prefix - let explainSQL: String - switch connection.type { - case .mssql, .oracle: - return - case .clickhouse: + // ClickHouse interactive explain gets special handling + if connection.type == .clickhouse { runClickHouseExplain(variant: .plan) return - case .sqlite: - explainSQL = "EXPLAIN QUERY PLAN \(stmt)" - case .mysql, .mariadb, .postgresql, .redshift, .duckdb: - explainSQL = "EXPLAIN \(stmt)" - case .mongodb: - explainSQL = Self.buildMongoExplain(for: stmt) - case .redis: - explainSQL = Self.buildRedisDebugCommand(for: stmt) + } + + // Build database-specific EXPLAIN query via plugin, with fallback + let explainSQL: String + if let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, + let pluginExplain = adapter.buildExplainQuery(stmt) { + explainSQL = pluginExplain + } else { + switch connection.type { + case .mssql, .oracle: + return + case .sqlite: + explainSQL = "EXPLAIN QUERY PLAN \(stmt)" + case .mysql, .mariadb, .postgresql, .redshift, .duckdb: + explainSQL = "EXPLAIN \(stmt)" + case .mongodb: + explainSQL = Self.buildMongoExplain(for: stmt) + case .redis: + explainSQL = Self.buildRedisDebugCommand(for: stmt) + case .clickhouse: + return + } } let level = connection.safeModeLevel diff --git a/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift b/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift new file mode 100644 index 00000000..4cc09d13 --- /dev/null +++ b/TableProTests/Core/Plugins/ExplainQueryPluginTests.swift @@ -0,0 +1,86 @@ +// +// ExplainQueryPluginTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +/// Minimal stub implementing PluginDatabaseDriver for testing buildExplainQuery. +/// Returns a fixed explain string or nil depending on configuration. +private final class StubExplainDriver: PluginDatabaseDriver { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + private let explainResult: ((String) -> String?)? + + init(explainResult: ((String) -> String?)? = nil) { + self.explainResult = explainResult + } + + func buildExplainQuery(_ sql: String) -> String? { + explainResult?(sql) + } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchRowCount(query: String) async throws -> Int { 0 } + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("buildExplainQuery plugin protocol") +struct ExplainQueryPluginTests { + @Test("Default implementation returns nil") + func defaultReturnsNil() { + let driver = StubExplainDriver() + #expect(driver.buildExplainQuery("SELECT 1") == nil) + } + + @Test("Custom implementation returns explain SQL") + func customReturnsExplain() { + let driver = StubExplainDriver { sql in + "EXPLAIN \(sql)" + } + #expect(driver.buildExplainQuery("SELECT * FROM users") == "EXPLAIN SELECT * FROM users") + } + + @Test("SQLite-style EXPLAIN QUERY PLAN") + func sqliteStyleExplain() { + let driver = StubExplainDriver { sql in + "EXPLAIN QUERY PLAN \(sql)" + } + let result = driver.buildExplainQuery("SELECT id FROM items") + #expect(result == "EXPLAIN QUERY PLAN SELECT id FROM items") + } + + @Test("Unsupported database returns nil") + func unsupportedReturnsNil() { + let driver = StubExplainDriver { _ in nil } + #expect(driver.buildExplainQuery("SELECT 1") == nil) + } +} From 9792c8daf0be29900253aa1de4e7fba27b5b6a02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:23:01 +0700 Subject: [PATCH 09/15] remove accidentally staged embedded repos --- .claude/worktrees/agent-a3f40b9c | 1 - .claude/worktrees/agent-a588ec25 | 1 - .claude/worktrees/agent-a9fd3533 | 1 - .claude/worktrees/agent-aa502b98 | 1 - .claude/worktrees/agent-aa55f20b | 1 - .claude/worktrees/agent-abee80f7 | 1 - .claude/worktrees/agent-ad9e1aa8 | 1 - .claude/worktrees/agent-ada8fd8a | 1 - .claude/worktrees/agent-ade276ae | 1 - .claude/worktrees/agent-aef509be | 1 - .claude/worktrees/agent-af9c97f3 | 1 - astro-landingpage | 1 - licenseapp | 1 - 13 files changed, 13 deletions(-) delete mode 160000 .claude/worktrees/agent-a3f40b9c delete mode 160000 .claude/worktrees/agent-a588ec25 delete mode 160000 .claude/worktrees/agent-a9fd3533 delete mode 160000 .claude/worktrees/agent-aa502b98 delete mode 160000 .claude/worktrees/agent-aa55f20b delete mode 160000 .claude/worktrees/agent-abee80f7 delete mode 160000 .claude/worktrees/agent-ad9e1aa8 delete mode 160000 .claude/worktrees/agent-ada8fd8a delete mode 160000 .claude/worktrees/agent-ade276ae delete mode 160000 .claude/worktrees/agent-aef509be delete mode 160000 .claude/worktrees/agent-af9c97f3 delete mode 160000 astro-landingpage delete mode 160000 licenseapp diff --git a/.claude/worktrees/agent-a3f40b9c b/.claude/worktrees/agent-a3f40b9c deleted file mode 160000 index abdf28a3..00000000 --- a/.claude/worktrees/agent-a3f40b9c +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abdf28a35484a1ef1928a6df154ca830d40738d2 diff --git a/.claude/worktrees/agent-a588ec25 b/.claude/worktrees/agent-a588ec25 deleted file mode 160000 index e95f919a..00000000 --- a/.claude/worktrees/agent-a588ec25 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e95f919a0bb60dddb8cbce3b6b93bec0db4cdf43 diff --git a/.claude/worktrees/agent-a9fd3533 b/.claude/worktrees/agent-a9fd3533 deleted file mode 160000 index b3ec79d4..00000000 --- a/.claude/worktrees/agent-a9fd3533 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b3ec79d4e133be1e446782632c352df78d565876 diff --git a/.claude/worktrees/agent-aa502b98 b/.claude/worktrees/agent-aa502b98 deleted file mode 160000 index abdf28a3..00000000 --- a/.claude/worktrees/agent-aa502b98 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abdf28a35484a1ef1928a6df154ca830d40738d2 diff --git a/.claude/worktrees/agent-aa55f20b b/.claude/worktrees/agent-aa55f20b deleted file mode 160000 index 5e382b83..00000000 --- a/.claude/worktrees/agent-aa55f20b +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5e382b831d05ea5ac84067a47d21d1c651e0da38 diff --git a/.claude/worktrees/agent-abee80f7 b/.claude/worktrees/agent-abee80f7 deleted file mode 160000 index 90bf8d76..00000000 --- a/.claude/worktrees/agent-abee80f7 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90bf8d76aa0b1752e995c80bfe903dc6402f71d6 diff --git a/.claude/worktrees/agent-ad9e1aa8 b/.claude/worktrees/agent-ad9e1aa8 deleted file mode 160000 index abf00cae..00000000 --- a/.claude/worktrees/agent-ad9e1aa8 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abf00caeb112d0e19f578ee84064706403209b37 diff --git a/.claude/worktrees/agent-ada8fd8a b/.claude/worktrees/agent-ada8fd8a deleted file mode 160000 index 663d850a..00000000 --- a/.claude/worktrees/agent-ada8fd8a +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 663d850af4394c1867cfee506884cef0c74ae863 diff --git a/.claude/worktrees/agent-ade276ae b/.claude/worktrees/agent-ade276ae deleted file mode 160000 index bb7c9037..00000000 --- a/.claude/worktrees/agent-ade276ae +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bb7c9037c8a56dc96b855b52568dc65c0f3d0fb7 diff --git a/.claude/worktrees/agent-aef509be b/.claude/worktrees/agent-aef509be deleted file mode 160000 index 0157cf99..00000000 --- a/.claude/worktrees/agent-aef509be +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0157cf993ed6b9015b0e9bbc2661d7ef5bfc7335 diff --git a/.claude/worktrees/agent-af9c97f3 b/.claude/worktrees/agent-af9c97f3 deleted file mode 160000 index 90bf8d76..00000000 --- a/.claude/worktrees/agent-af9c97f3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90bf8d76aa0b1752e995c80bfe903dc6402f71d6 diff --git a/astro-landingpage b/astro-landingpage deleted file mode 160000 index b5938182..00000000 --- a/astro-landingpage +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b5938182b6639bb1227868b516a802ff580a195b diff --git a/licenseapp b/licenseapp deleted file mode 160000 index 16291f9e..00000000 --- a/licenseapp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16291f9efb3aee46881cd7edf75efc5c1e58b91b From a12b8ce16391c037f8bf5b1626af0dced20a7150 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:25:39 +0700 Subject: [PATCH 10/15] remove accidentally staged embedded repos --- .claude/worktrees/agent-a3f40b9c | 1 - .claude/worktrees/agent-a588ec25 | 1 - .claude/worktrees/agent-a9fd3533 | 1 - .claude/worktrees/agent-aa502b98 | 1 - .claude/worktrees/agent-aa55f20b | 1 - .claude/worktrees/agent-abee80f7 | 1 - .claude/worktrees/agent-ad9e1aa8 | 1 - .claude/worktrees/agent-ada8fd8a | 1 - .claude/worktrees/agent-ade276ae | 1 - .claude/worktrees/agent-aef509be | 1 - .claude/worktrees/agent-af9c97f3 | 1 - astro-landingpage | 1 - licenseapp | 1 - 13 files changed, 13 deletions(-) delete mode 160000 .claude/worktrees/agent-a3f40b9c delete mode 160000 .claude/worktrees/agent-a588ec25 delete mode 160000 .claude/worktrees/agent-a9fd3533 delete mode 160000 .claude/worktrees/agent-aa502b98 delete mode 160000 .claude/worktrees/agent-aa55f20b delete mode 160000 .claude/worktrees/agent-abee80f7 delete mode 160000 .claude/worktrees/agent-ad9e1aa8 delete mode 160000 .claude/worktrees/agent-ada8fd8a delete mode 160000 .claude/worktrees/agent-ade276ae delete mode 160000 .claude/worktrees/agent-aef509be delete mode 160000 .claude/worktrees/agent-af9c97f3 delete mode 160000 astro-landingpage delete mode 160000 licenseapp diff --git a/.claude/worktrees/agent-a3f40b9c b/.claude/worktrees/agent-a3f40b9c deleted file mode 160000 index abdf28a3..00000000 --- a/.claude/worktrees/agent-a3f40b9c +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abdf28a35484a1ef1928a6df154ca830d40738d2 diff --git a/.claude/worktrees/agent-a588ec25 b/.claude/worktrees/agent-a588ec25 deleted file mode 160000 index e95f919a..00000000 --- a/.claude/worktrees/agent-a588ec25 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e95f919a0bb60dddb8cbce3b6b93bec0db4cdf43 diff --git a/.claude/worktrees/agent-a9fd3533 b/.claude/worktrees/agent-a9fd3533 deleted file mode 160000 index b3ec79d4..00000000 --- a/.claude/worktrees/agent-a9fd3533 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b3ec79d4e133be1e446782632c352df78d565876 diff --git a/.claude/worktrees/agent-aa502b98 b/.claude/worktrees/agent-aa502b98 deleted file mode 160000 index abdf28a3..00000000 --- a/.claude/worktrees/agent-aa502b98 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abdf28a35484a1ef1928a6df154ca830d40738d2 diff --git a/.claude/worktrees/agent-aa55f20b b/.claude/worktrees/agent-aa55f20b deleted file mode 160000 index 5e382b83..00000000 --- a/.claude/worktrees/agent-aa55f20b +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5e382b831d05ea5ac84067a47d21d1c651e0da38 diff --git a/.claude/worktrees/agent-abee80f7 b/.claude/worktrees/agent-abee80f7 deleted file mode 160000 index 90bf8d76..00000000 --- a/.claude/worktrees/agent-abee80f7 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90bf8d76aa0b1752e995c80bfe903dc6402f71d6 diff --git a/.claude/worktrees/agent-ad9e1aa8 b/.claude/worktrees/agent-ad9e1aa8 deleted file mode 160000 index abf00cae..00000000 --- a/.claude/worktrees/agent-ad9e1aa8 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abf00caeb112d0e19f578ee84064706403209b37 diff --git a/.claude/worktrees/agent-ada8fd8a b/.claude/worktrees/agent-ada8fd8a deleted file mode 160000 index 663d850a..00000000 --- a/.claude/worktrees/agent-ada8fd8a +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 663d850af4394c1867cfee506884cef0c74ae863 diff --git a/.claude/worktrees/agent-ade276ae b/.claude/worktrees/agent-ade276ae deleted file mode 160000 index bb7c9037..00000000 --- a/.claude/worktrees/agent-ade276ae +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bb7c9037c8a56dc96b855b52568dc65c0f3d0fb7 diff --git a/.claude/worktrees/agent-aef509be b/.claude/worktrees/agent-aef509be deleted file mode 160000 index 0157cf99..00000000 --- a/.claude/worktrees/agent-aef509be +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0157cf993ed6b9015b0e9bbc2661d7ef5bfc7335 diff --git a/.claude/worktrees/agent-af9c97f3 b/.claude/worktrees/agent-af9c97f3 deleted file mode 160000 index 90bf8d76..00000000 --- a/.claude/worktrees/agent-af9c97f3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90bf8d76aa0b1752e995c80bfe903dc6402f71d6 diff --git a/astro-landingpage b/astro-landingpage deleted file mode 160000 index b5938182..00000000 --- a/astro-landingpage +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b5938182b6639bb1227868b516a802ff580a195b diff --git a/licenseapp b/licenseapp deleted file mode 160000 index 16291f9e..00000000 --- a/licenseapp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16291f9efb3aee46881cd7edf75efc5c1e58b91b From 08ed63fde7e8d8648c2d1b885ad2f5c8fab2f39c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:34:36 +0700 Subject: [PATCH 11/15] chore: remove .md files not intended for this PR --- CLAUDE.md | 2 +- docs/development/plugin-extensibility-plan.md | 673 ------------- docs/development/plugin-system-analysis.md | 937 ------------------ 3 files changed, 1 insertion(+), 1611 deletions(-) delete mode 100644 docs/development/plugin-extensibility-plan.md delete mode 100644 docs/development/plugin-system-analysis.md diff --git a/CLAUDE.md b/CLAUDE.md index 8dfc5259..56947f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,7 +183,7 @@ These are **non-negotiable** — never skip them: - **Plans must include edge cases.** When creating implementation plans, identify edge cases, thread safety concerns, and boundary conditions. Include them as explicit checklist items in the plan — don't defer discovery to code review. - **Implementation includes self-review.** Before committing, agents must check: thread safety (lock coverage, race conditions), all code paths (loops, early returns, between iterations), error handling, and flag/state reset logic. This eliminates the review→fix→review cycle. - **Tests are part of implementation, not a separate step.** When implementing a feature, write tests in the same commit or immediately after — don't wait for a separate `/write-tests` invocation. The implementation agent should include test writing in its scope. -- **Always use team agents** for implementation work. Use the Agent tool to delegate to specialized agents: `general-purpose` (implementation), `feature-dev:code-architect` (design), `feature-dev:code-explorer` (analysis), `feature-dev:code-reviewer` (review), `code-simplifier:code-simplifier` (cleanup). Use the Skill tool for `/feature-dev` (guided feature development). +- **Always use team agents** for implementation work. Use the Agent tool (not subagents/tasks) to delegate coding to specialized agents (e.g., `feature-dev:feature-dev`, `feature-dev:code-architect`, `code-simplifier:code-simplifier`). - **Always parallelize** independent tasks. Launch multiple agents in a single message. - **Main context = orchestrator only.** Read files, launch agents, summarize results, update tracking. Never do heavy implementation directly. - **Agent prompts must be self-contained.** Include file paths, the specific problem, and clear instructions. diff --git a/docs/development/plugin-extensibility-plan.md b/docs/development/plugin-extensibility-plan.md deleted file mode 100644 index c6ac9867..00000000 --- a/docs/development/plugin-extensibility-plan.md +++ /dev/null @@ -1,673 +0,0 @@ -# Plugin Extensibility Plan - -Make the plugin system fully extensible so third-party developers can ship a `.tableplugin` bundle that works without any app-side code changes. Eliminate the `DatabaseType` enum as the source of truth; move all database-specific metadata and behavior into the plugin protocols. - -## Current Problem - -Adding a new database (e.g., DuckDB) requires modifying **25+ files** across the main app target. Every file that switches on `DatabaseType` must be updated. The enum is closed (`CaseIterable`), making external plugins impossible. - -The plugin system currently only decouples the **driver** (C bridge + query execution). Everything else is hardcoded: - -- Connection form layout -- SQL dialect (keywords, quoting, escaping) -- DDL/DML generation -- Filter/autocomplete behavior -- Theme colors, icons, toolbar labels -- File extension and URL scheme handling -- Export tree structure -- Schema editor type lists - -## Target Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ TablePro App │ -│ │ -│ PluginManager ←── discovers & loads plugins │ -│ │ │ -│ ├── DriverPluginDescriptor (static meta) │ -│ │ brand color, icon, port, auth, │ -│ │ file extensions, URL schemes, │ -│ │ connection mode, capabilities, │ -│ │ column types, system schemas, │ -│ │ query language name │ -│ │ │ -│ ├── SQLDialectDescriptor (from plugin) │ -│ │ keywords, functions, data types, │ -│ │ identifier quote, escape rules │ -│ │ │ -│ └── PluginDatabaseDriver (instance) │ -│ connect, query, introspect, │ -│ DDL/DML generation, pagination, │ -│ filter SQL, EXPLAIN, TRUNCATE, │ -│ FK enable/disable, view templates │ -│ │ -│ App UI reads from PluginManager registry │ -│ App UI renders connection fields dynamically │ -│ No switch on DatabaseType anywhere │ -└─────────────────────────────────────────────────┘ -``` - -**Key principles:** - -- Plugin is the single source of truth for all database-specific behavior -- App code is generic — iterates plugin descriptors, never switches on type -- `DatabaseType` enum becomes a string-based identifier, not a closed enum -- `ConnectionField` gains a `fieldType` discriminator for dynamic form rendering -- `DatabaseConnection` stores extra fields in `[String: String]`, not typed properties - ---- - -## Phase 1: Plugin Descriptor Protocol - -Extend `DriverPlugin` with all static metadata the app currently reads from `DatabaseType` switches. - -### 1.1 — Extend `DriverPlugin` with UI/capability metadata - -**File:** `Plugins/TableProPluginKit/Sources/DriverPlugin.swift` - -Add these static properties (all with default implementations): - -```swift -// Connection -static var requiresAuthentication: Bool { get } // default: true -static var connectionMode: ConnectionMode { get } // default: .network -static var urlSchemes: [String] { get } // default: [] -static var fileExtensions: [String] { get } // default: [] - -// UI -static var brandColorHex: String { get } // default: "#808080" -static var queryLanguageName: String { get } // default: "SQL" -static var editorLanguage: EditorLanguage { get } // default: .sql - -// Capabilities -static var supportsForeignKeys: Bool { get } // default: true -static var supportsSchemaEditing: Bool { get } // default: true -static var supportsDatabaseSwitching: Bool { get } // default: true -static var supportsSchemaSwitching: Bool { get } // default: false -static var supportsImport: Bool { get } // default: true -static var supportsExport: Bool { get } // default: true -static var supportsHealthMonitor: Bool { get } // default: true - -// Schema -static var systemDatabaseNames: [String] { get } // default: [] -static var systemSchemaNames: [String] { get } // default: [] -static var databaseGroupingStrategy: GroupingStrategy { get } // default: .byDatabase -static var defaultGroupName: String { get } // default: "main" - -// Column types for structure editor -static var columnTypesByCategory: [String: [String]] { get } // default: SQL standard types -``` - -- [ ] Define `ConnectionMode` enum: `.network`, `.fileBased` -- [ ] Define `EditorLanguage` enum: `.sql`, `.javascript`, `.bash`, `.custom(String)` -- [ ] Define `GroupingStrategy` enum: `.byDatabase`, `.bySchema`, `.flat` -- [ ] Add all properties above with default implementations -- [ ] Update all 11 existing plugins to declare their values - -### 1.2 — Extend `ConnectionField` with field types - -**File:** `Plugins/TableProPluginKit/Sources/ConnectionField.swift` - -```swift -public enum ConnectionFieldType: String, Codable, Sendable { - case text - case secureText - case number - case stepper - case picker - case filePath - case toggle -} - -// Add to ConnectionField: -public let fieldType: ConnectionFieldType // default: .text -public let options: [String]? // for .picker type -public let range: ClosedRange? // for .stepper type -public let fileExtensions: [String]? // for .filePath type -``` - -- [ ] Add `ConnectionFieldType` enum -- [ ] Extend `ConnectionField` with `fieldType`, `options`, `range`, `fileExtensions` -- [ ] Provide backward-compatible initializer (default `fieldType: .text`) - -### 1.3 — Generalize `DatabaseConnection` extra fields - -**File:** `TablePro/Models/Connection/DatabaseConnection.swift` - -Replace the typed optional properties with a generic dictionary: - -```swift -// Remove: -var mongoReadPreference: String? -var mongoWriteConcern: String? -var redisDatabase: Int? -var mssqlSchema: String? -var oracleServiceName: String? - -// Add: -var driverFields: [String: String] = [:] -``` - -- [ ] Add `driverFields: [String: String]` to `DatabaseConnection` -- [ ] Migrate existing per-driver fields to `driverFields` keys -- [ ] Update `Codable` conformance with migration from old keys -- [ ] Update `DatabaseDriverFactory.buildAdditionalFields` to pass `driverFields` directly -- [ ] Update `ConnectionStorage` (Keychain) if driver fields are stored there - -### 1.4 — Make `DatabaseType` open (string-based) - -**File:** `TablePro/Models/Connection/DatabaseConnection.swift` - -Transform `DatabaseType` from a closed enum to a string-based struct: - -```swift -struct DatabaseType: RawRepresentable, Hashable, Codable, Sendable { - let rawValue: String - init(rawValue: String) { self.rawValue = rawValue } - - // Known types (for backward compat during migration) - static let mysql = DatabaseType(rawValue: "MySQL") - static let postgresql = DatabaseType(rawValue: "PostgreSQL") - // ... etc -} -``` - -All computed properties (`iconName`, `defaultPort`, etc.) become lookups into `PluginManager`: - -```swift -var iconName: String { - PluginManager.shared.driverDescriptor(for: pluginTypeId)?.iconName ?? "database-icon" -} -``` - -- [ ] Convert `DatabaseType` from enum to struct -- [ ] Remove all switch statements from `DatabaseType` -- [ ] Add `PluginManager.driverDescriptor(for:)` lookup method -- [ ] Ensure `CaseIterable` replacement works for connection-type picker UI -- [ ] Handle unknown/unregistered types gracefully in UI - ---- - -## Phase 2: SQL Dialect into Plugin - -Move all SQL dialect knowledge from the app into the plugin. - -### 2.1 — Move `SQLDialectProvider` to TableProPluginKit - -**Files:** - -- `Plugins/TableProPluginKit/Sources/SQLDialectDescriptor.swift` (new) -- `TablePro/Core/Services/Query/SQLDialectProvider.swift` (refactor) - -```swift -// In TableProPluginKit: -public struct SQLDialectDescriptor: Sendable { - public let identifierQuote: String // `"` or `` ` `` or `[` - public let keywords: [String] - public let functions: [String] - public let dataTypes: [String] - public let parameterStyle: ParameterStyle // .questionMark or .dollar - public let likeEscapeClause: String // " ESCAPE '\\'" or "" - public let regexOperator: String? // "~", "REGEXP", nil - public let booleanLiterals: (true: String, false: String) // ("TRUE","FALSE") or ("1","0") - public let requiresBackslashEscaping: Bool // MySQL/MariaDB: true - public let supportsExplain: Bool - public let explainPrefix: String // "EXPLAIN", "EXPLAIN QUERY PLAN" -} - -public enum ParameterStyle: String, Sendable { - case questionMark // ? - case dollar // $1, $2 -} -``` - -Add to `DriverPlugin`: - -```swift -static var sqlDialect: SQLDialectDescriptor? { get } // nil for NoSQL -``` - -- [ ] Create `SQLDialectDescriptor` in TableProPluginKit -- [ ] Create `ParameterStyle` enum -- [ ] Add `sqlDialect` property to `DriverPlugin` with default -- [ ] Implement in all SQL-based plugins -- [ ] Refactor `SQLDialectFactory` to read from plugin registry -- [ ] Remove all per-database dialect structs from app target - -### 2.2 — Move identifier quoting to plugin - -**Files:** - -- `TablePro/Models/Connection/DatabaseConnection.swift` -- `Plugins/TableProPluginKit/Sources/PluginDatabaseDriver.swift` - -```swift -// In PluginDatabaseDriver: -var identifierQuote: String { get } // default: "\"" -func quoteIdentifier(_ name: String) -> String // default impl uses identifierQuote -``` - -- [ ] Add `identifierQuote` and `quoteIdentifier` to `PluginDatabaseDriver` -- [ ] Provide default implementation -- [ ] Override in plugins that need special behavior (MSSQL `[brackets]`, MongoDB no-quote) -- [ ] Remove `identifierQuote` and `quoteIdentifier` from `DatabaseType` -- [ ] Update `PluginDriverAdapter` to bridge the new methods - -### 2.3 — Move string escaping to plugin - -**Files:** - -- `TablePro/Core/Database/SQLEscaping.swift` -- `Plugins/TableProPluginKit/Sources/PluginDatabaseDriver.swift` - -- [ ] Add `escapeStringLiteral(_ value: String) -> String` to `PluginDatabaseDriver` -- [ ] Default: single-quote doubling -- [ ] MySQL/MariaDB/ClickHouse override: also backslash-escape -- [ ] Remove switch from `SQLEscaping.swift` - -### 2.4 — Move filter SQL to plugin - -**Files:** - -- `TablePro/Core/Database/FilterSQLGenerator.swift` -- `Plugins/TableProPluginKit/Sources/PluginDatabaseDriver.swift` - -Properties already covered by `SQLDialectDescriptor`: `likeEscapeClause`, `regexOperator`, `booleanLiterals`, `requiresBackslashEscaping`. - -Additional method: - -```swift -func castColumnToText(_ column: String) -> String // default: column (no cast) -// PostgreSQL: "column::TEXT" -// MySQL: "CAST(column AS CHAR)" -// MSSQL: "CAST(column AS NVARCHAR(MAX))" -``` - -- [ ] Add `castColumnToText` to `PluginDatabaseDriver` -- [ ] Refactor `FilterSQLGenerator` to read from dialect descriptor -- [ ] Remove all `DatabaseType` switches from `FilterSQLGenerator` -- [ ] Refactor `TableQueryBuilder.buildLikeCondition` similarly - -### 2.5 — Move autocomplete to plugin - -**Files:** - -- `TablePro/Core/Autocomplete/SQLCompletionProvider.swift` - -The `dataTypeKeywords()` method (5 switches, ~120 lines) and `createTable` context completions (~20 lines) should come from the plugin. - -- [ ] Add `completionKeywords(for context: String) -> [String]?` to `PluginDatabaseDriver` -- [ ] Refactor `SQLCompletionProvider` to use dialect descriptor for type/keyword lists -- [ ] Remove all `DatabaseType` switches from `SQLCompletionProvider` -- [ ] `dataTypeKeywords()` reads from `sqlDialect.dataTypes` - ---- - -## Phase 3: DML/DDL Generation into Plugin - -Move all per-database SQL generation into the plugin driver. - -### 3.1 — Move DML statement generation - -**File:** `TablePro/Core/ChangeTracking/SQLStatementGenerator.swift` - -The `generateStatements` hook already exists on `PluginDatabaseDriver`. Currently only MongoDB/Redis implement it. Move ClickHouse's `ALTER TABLE UPDATE/DELETE` into its plugin, and MSSQL/Oracle's `TOP(1)`/`ROWNUM` syntax into theirs. - -```swift -// Already exists: -func generateStatements(...) -> [(statement: String, parameters: [String?])]? -``` - -- [ ] Implement `generateStatements` in ClickHouseDriverPlugin -- [ ] Implement `generateStatements` in MSSQLDriverPlugin -- [ ] Implement `generateStatements` in OracleDriverPlugin -- [ ] Remove ClickHouse/MSSQL/Oracle branches from `SQLStatementGenerator` -- [ ] Move `placeholder(at:)` to use `sqlDialect.parameterStyle` -- [ ] Remove all `DatabaseType` switches from `SQLStatementGenerator` - -### 3.2 — Move DDL schema generation - -**File:** `TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift` - -This is the largest file (~600 lines of per-type switches). Add schema DDL methods to `PluginDatabaseDriver`: - -```swift -func generateAddColumnSQL(table: String, column: SchemaColumnDef) -> String? -func generateModifyColumnSQL(table: String, changes: ColumnChanges) -> [String]? -func generateDropColumnSQL(table: String, column: String) -> String? -func generateAddIndexSQL(table: String, index: SchemaIndexDef) -> String? -func generateDropIndexSQL(table: String, indexName: String) -> String? -func generateAddForeignKeySQL(table: String, fk: SchemaForeignKeyDef) -> String? -func generateDropForeignKeySQL(table: String, constraintName: String) -> String? -func generateModifyPrimaryKeySQL(table: String, columns: [String]) -> [String]? -``` - -All return `nil` by default (unsupported). Each plugin implements what it supports. - -- [ ] Define `SchemaColumnDef`, `ColumnChanges`, `SchemaIndexDef`, `SchemaForeignKeyDef` transfer types in TableProPluginKit -- [ ] Add all DDL methods to `PluginDatabaseDriver` with `nil` defaults -- [ ] Implement in MySQL, PostgreSQL, MSSQL, Oracle, ClickHouse, DuckDB, SQLite plugins -- [ ] Refactor `SchemaStatementGenerator` to call plugin first, use generic SQL fallback -- [ ] Remove all `DatabaseType` switches from `SchemaStatementGenerator` - -### 3.3 — Move table operations to plugin - -**File:** `TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift` - -```swift -// Add to PluginDatabaseDriver: -func truncateTableStatements(table: String, schema: String?) -> [String] -func dropObjectStatement(name: String, type: String, cascade: Bool, schema: String?) -> String -func fkDisableStatements() -> [String] // default: [] -func fkEnableStatements() -> [String] // default: [] -``` - -- [ ] Add methods to `PluginDatabaseDriver` with defaults -- [ ] Implement in all plugins -- [ ] Remove `fkDisableStatements`/`fkEnableStatements` from `ImportDataSinkAdapter` (duplicate) -- [ ] Refactor `MainContentCoordinator+TableOperations` to call plugin -- [ ] Remove all `DatabaseType` switches - -### 3.4 — Move query building (pagination) to plugin - -**File:** `TablePro/Core/Services/Query/TableQueryBuilder.swift` - -The `buildBrowseQuery`/`buildFilteredQuery`/`buildQuickSearchQuery`/`buildCombinedQuery` hooks already exist. Implement them in MSSQL and Oracle plugins to remove the hardcoded pagination branches. - -```swift -// Add to PluginDatabaseDriver: -func buildPaginatedQuery(base: String, limit: Int, offset: Int) -> String -// Default: "base LIMIT limit OFFSET offset" -// MSSQL/Oracle override with FETCH NEXT syntax -``` - -- [ ] Add `buildPaginatedQuery` to `PluginDatabaseDriver` -- [ ] Implement `buildBrowseQuery` etc. in MSSQLDriverPlugin -- [ ] Implement `buildBrowseQuery` etc. in OracleDriverPlugin -- [ ] Remove MSSQL/Oracle helper methods from `TableQueryBuilder` -- [ ] Simplify `TableQueryBuilder` to: plugin dispatch → standard LIMIT/OFFSET - -### 3.5 — Move EXPLAIN and view templates to plugin - -**Files:** - -- `TablePro/Views/Main/MainContentCoordinator.swift` -- `TablePro/Views/Main/MainContentCommandActions.swift` - -```swift -// Add to PluginDatabaseDriver: -func explainQuery(_ sql: String) -> String? // nil = unsupported -func createViewTemplate(name: String) -> String -func editViewTemplate(name: String, definition: String) -> String -``` - -- [ ] Add methods to `PluginDatabaseDriver` -- [ ] Implement in all plugins -- [ ] Refactor coordinator to call plugin -- [ ] Remove all `DatabaseType` switches from both files - ---- - -## Phase 4: Dynamic Connection Form - -Replace hardcoded form sections with plugin-driven rendering. - -### 4.1 — Render `additionalConnectionFields` dynamically - -**File:** `TablePro/Views/Connection/ConnectionFormView.swift` - -The form currently has hardcoded sections for MongoDB (read preference picker, write concern picker), Redis (database stepper), MSSQL (schema field), Oracle (service name field). Replace with: - -```swift -ForEach(plugin.additionalConnectionFields) { field in - switch field.fieldType { - case .text: TextField(field.label, text: binding(for: field.id)) - case .secureText: SecureField(field.label, text: binding(for: field.id)) - case .stepper: Stepper(field.label, value: intBinding(for: field.id), in: field.range!) - case .picker: Picker(field.label, selection: binding(for: field.id)) { ... } - case .filePath: FilePathField(label: field.label, extensions: field.fileExtensions) - case .toggle: Toggle(field.label, isOn: boolBinding(for: field.id)) - case .number: TextField(field.label, value: intBinding(for: field.id)) - } -} -``` - -- [ ] Implement dynamic field rendering in `ConnectionFormView` -- [ ] Use `connectionMode` to switch between file-picker and host/port layouts -- [ ] Use `requiresAuthentication` to show/hide username/password -- [ ] Use `supportsSSH`/`supportsSSL` (from descriptor) for tab visibility -- [ ] Remove ALL hardcoded `if type == .mongodb` / `.redis` / `.mssql` / `.oracle` sections -- [ ] Update each plugin's `additionalConnectionFields` with proper `fieldType` - -### 4.2 — Dynamic connection type picker - -**File:** `TablePro/Views/Connection/ConnectionFormView.swift` (type selector) - -Replace the static `DatabaseType.allCases` with `PluginManager.shared.availableDriverTypes`: - -```swift -Picker("Type", selection: $selectedType) { - ForEach(PluginManager.shared.availableDriverDescriptors) { descriptor in - Label(descriptor.displayName, image: descriptor.iconName) - .tag(descriptor.typeId) - } -} -``` - -- [ ] Add `availableDriverDescriptors` to `PluginManager` -- [ ] Replace `DatabaseType.allCases` usage in connection form -- [ ] Handle "not installed" state for downloadable plugins - ---- - -## Phase 5: Dynamic UI Metadata - -Replace all remaining hardcoded UI switches with plugin lookups. - -### 5.1 — Theme colors from plugin - -**File:** `TablePro/Theme/Theme.swift` - -- [ ] Remove all per-database static color constants -- [ ] Remove `DatabaseType.themeColor` extension -- [ ] Add `PluginManager.brandColor(for typeId: String) -> Color` that parses `brandColorHex` -- [ ] Update all call sites to use plugin lookup - -### 5.2 — Toolbar labels and visibility from plugin - -**Files:** - -- `TablePro/Views/Toolbar/TableProToolbarView.swift` -- `TablePro/Views/Toolbar/ConnectionStatusView.swift` -- `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift` - -- [ ] Replace `databaseType == .sqlite || databaseType == .duckdb` with `!descriptor.supportsDatabaseSwitching` -- [ ] Replace `databaseType == .mongodb` etc. with `descriptor.queryLanguageName` -- [ ] Replace `databaseType == .redis` toolbar hiding with `!descriptor.supportsImport` -- [ ] Replace subtitle formatting with `descriptor.connectionMode` check - -### 5.3 — Export dialog from plugin - -**File:** `TablePro/Views/Export/ExportDialog.swift` - -- [ ] Replace per-type tree-building switch with `descriptor.databaseGroupingStrategy` -- [ ] Use `descriptor.defaultGroupName` for auto-expansion -- [ ] Use driver's `fetchSchemas()`/`fetchDatabases()` generically - -### 5.4 — Database switcher from plugin - -**File:** `TablePro/ViewModels/DatabaseSwitcherViewModel.swift` - -- [ ] Replace `isSystemItem` per-type lists with `descriptor.systemDatabaseNames`/`systemSchemaNames` -- [ ] Replace `databaseType == .redshift` mode check with `descriptor.supportsSchemaSwitching` - -### 5.5 — Type picker from plugin - -**File:** `TablePro/Views/Structure/TypePickerContentView.swift` - -- [ ] Replace all 5 per-type switch tables with `descriptor.columnTypesByCategory` -- [ ] Remove all `DatabaseType` switches - -### 5.6 — Editor language from plugin - -**Files:** - -- `TablePro/Views/Editor/SQLEditorView.swift` -- `TablePro/Views/Main/Child/MainEditorContentView.swift` -- `TablePro/Views/Filter/FilterPanelView.swift` -- `TablePro/Views/Components/SQLReviewPopover.swift` - -- [ ] Replace `databaseType == .mongodb ? .javascript : ...` with `descriptor.editorLanguage` -- [ ] Remove all `DatabaseType` checks for editor language - -### 5.7 — File opening from plugin - -**Files:** - -- `TablePro/AppDelegate+FileOpen.swift` -- `TablePro/AppDelegate+ConnectionHandler.swift` - -- [ ] Replace hardcoded `sqliteFileExtensions`/`duckdbFileExtensions` with plugin registry lookup -- [ ] Replace hardcoded `databaseURLSchemes` with plugin registry lookup -- [ ] Replace `handleSQLiteFile`/`handleDuckDBFile` with generic `handleDatabaseFile(_ url: URL, typeId: String)` -- [ ] At startup, query all loaded plugins for file extensions and register them - -### 5.8 — AI integration from plugin - -**Files:** - -- `TablePro/Core/AI/AIPromptTemplates.swift` -- `TablePro/Core/AI/AISchemaContext.swift` - -- [ ] Replace `databaseType == .mongodb` checks with `descriptor.queryLanguageName`/`editorLanguage` -- [ ] Remove hardcoded AI instruction strings per database type - ---- - -## Phase 6: Remove `DatabaseType` Enum Switches - -After Phases 1-5, systematically remove all remaining `switch databaseType` statements. - -### Files with switches to eliminate - -| File | Switches | Phase | -| ---------------------------------------------- | ----------------- | -------- | -| `DatabaseConnection.swift` | 9 | 1.4 | -| `Theme.swift` | 1 | 5.1 | -| `DatabaseDriver.swift` | 2 | 2.2, 1.3 | -| `DatabaseManager.swift` | 3 | 1.1, 3.3 | -| `ConnectionFormView.swift` | 8+ | 4.1, 4.2 | -| `SQLDialectProvider.swift` | 1 (factory) | 2.1 | -| `FilterSQLGenerator.swift` | 5 | 2.4 | -| `TableQueryBuilder.swift` | 6 | 3.4 | -| `SQLCompletionProvider.swift` | 4 | 2.5 | -| `SchemaStatementGenerator.swift` | 8 | 3.2 | -| `SQLStatementGenerator.swift` | 5 | 3.1 | -| `SQLEscaping.swift` | 1 | 2.3 | -| `SQLParameterInliner.swift` | 1 | 2.1 | -| `ImportDataSinkAdapter.swift` | 2 | 3.3 | -| `MainContentCoordinator.swift` | 5 | 3.5 | -| `MainContentCommandActions.swift` | 2 | 3.5 | -| `MainContentCoordinator+TableOperations.swift` | 5 | 3.3 | -| `MainContentCoordinator+Navigation.swift` | 1 | 3.4 | -| `TypePickerContentView.swift` | 5 | 5.5 | -| `ExportDialog.swift` | 2 | 5.3 | -| `ConnectionStatusView.swift` | 1 | 5.2 | -| `ConnectionSwitcherPopover.swift` | 1 | 5.2 | -| `TableProToolbarView.swift` | 4 | 5.2 | -| `DatabaseSwitcherViewModel.swift` | 2 | 5.4 | -| `ConnectionURLFormatter.swift` | 2 | 5.2 | -| `AppDelegate+FileOpen.swift` | 3 | 5.7 | -| `AppDelegate+ConnectionHandler.swift` | 2 | 5.7 | -| `ContentView.swift` | 3 | 5.2 | -| `MainContentView.swift` | 3 | 5.2 | -| `QueryTab.swift` | 2 | 3.4 | -| `SQLReviewPopover.swift` | 3 | 5.6 | -| `SQLEditorView.swift` | 1 | 5.6 | -| `StructureRowProvider.swift` | 3 | 5.5 | -| `TableOperationDialog.swift` | 3 | 5.2 | -| `DataGridView+Editing.swift` | 1 | 5.2 | -| `SessionStateFactory.swift` | 1 | 5.2 | -| `AIPromptTemplates.swift` | 1 | 5.8 | -| `AISchemaContext.swift` | 2 | 5.8 | -| **Total** | **~112 switches** | | - ---- - -## Phase 7: Plugin SDK and Documentation - -### 7.1 — Plugin development guide - -- [ ] Document `DriverPlugin` protocol with all required/optional properties -- [ ] Document `PluginDatabaseDriver` protocol with all methods -- [ ] Document `SQLDialectDescriptor` structure -- [ ] Document `ConnectionField` with field types -- [ ] Create a template plugin project -- [ ] Document build/sign/distribute workflow - -### 7.2 — Plugin validation - -- [ ] Add `PluginManager` validation for required descriptor properties -- [ ] Add runtime checks for malformed plugin descriptors -- [ ] Add plugin compatibility version checking - ---- - -## Implementation Order - -``` -Phase 1.1 DriverPlugin descriptor properties -Phase 1.2 ConnectionField field types - │ - ├── Phase 2.1 SQLDialectDescriptor in PluginKit - ├── Phase 2.2 Identifier quoting in plugin - ├── Phase 2.3 String escaping in plugin - │ - ├── Phase 3.1 DML generation in plugins - ├── Phase 3.2 DDL generation in plugins - ├── Phase 3.3 Table operations in plugins - ├── Phase 3.4 Pagination in plugins - ├── Phase 3.5 EXPLAIN/view templates in plugins - │ -Phase 1.3 Generalize DatabaseConnection fields -Phase 1.4 Open DatabaseType (string-based) - │ - ├── Phase 4.1 Dynamic connection form - ├── Phase 4.2 Dynamic type picker - │ - ├── Phase 5.1–5.8 All UI lookups from plugin - │ -Phase 6 Remove all DatabaseType switches -Phase 7 SDK docs and validation -``` - -Phases 2 and 3 can run in parallel once Phase 1.1 lands. -Phases 4 and 5 require Phase 1.3 + 1.4. -Phase 6 is the cleanup sweep after everything else. - ---- - -## Files to Create - -| File | Purpose | -| -------------------------------------------------------------- | ------------------------------------------ | -| `Plugins/TableProPluginKit/Sources/ConnectionMode.swift` | `.network` / `.fileBased` enum | -| `Plugins/TableProPluginKit/Sources/EditorLanguage.swift` | `.sql` / `.javascript` / `.bash` enum | -| `Plugins/TableProPluginKit/Sources/GroupingStrategy.swift` | `.byDatabase` / `.bySchema` / `.flat` enum | -| `Plugins/TableProPluginKit/Sources/SQLDialectDescriptor.swift` | Dialect metadata struct | -| `Plugins/TableProPluginKit/Sources/ParameterStyle.swift` | `.questionMark` / `.dollar` enum | -| `Plugins/TableProPluginKit/Sources/SchemaChangeTypes.swift` | DDL transfer types | -| `Plugins/TableProPluginKit/Sources/ConnectionFieldType.swift` | Field type enum | - -## Key Files to Modify - -| File | Changes | -| -------------------------------------------------------------- | ---------------------------------------- | -| `Plugins/TableProPluginKit/Sources/DriverPlugin.swift` | Add all descriptor properties | -| `Plugins/TableProPluginKit/Sources/PluginDatabaseDriver.swift` | Add DDL, DML, filter, pagination methods | -| `Plugins/TableProPluginKit/Sources/ConnectionField.swift` | Add `fieldType` discriminator | -| `TablePro/Models/Connection/DatabaseConnection.swift` | Open `DatabaseType`, generalize fields | -| `TablePro/Core/Plugins/PluginManager.swift` | Add descriptor registry, lookup methods | -| `TablePro/Core/Plugins/PluginDriverAdapter.swift` | Bridge new protocol methods | -| All 11 plugin `*Plugin.swift` files | Implement new protocol requirements | diff --git a/docs/development/plugin-system-analysis.md b/docs/development/plugin-system-analysis.md deleted file mode 100644 index dbe3195c..00000000 --- a/docs/development/plugin-system-analysis.md +++ /dev/null @@ -1,937 +0,0 @@ -# TablePro Plugin System — Full Source Analysis - -> Generated: 2026-03-11 | Covers all 9 database plugins + framework + infrastructure - ---- - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [TableProPluginKit Framework](#tablepropluginkit-framework) -3. [Plugin Loading Infrastructure](#plugin-loading-infrastructure) -4. [Host App Driver Layer](#host-app-driver-layer) -5. [All Database Plugins](#all-database-plugins) -6. [Query Building & Change Tracking](#query-building--change-tracking) -7. [Supporting Infrastructure](#supporting-infrastructure) -8. [Cross-Plugin Comparison Matrix](#cross-plugin-comparison-matrix) -9. [Known Gaps & Future Work](#known-gaps--future-work) -10. [File Reference](#file-reference) - ---- - -## Architecture Overview - -``` -Views / Coordinators - │ - DatabaseManager (session lifecycle, connection pool, health monitoring) - │ - DatabaseDriver protocol (internal app interface) - │ - PluginDriverAdapter (bridge layer — type conversion, status tracking) - │ - PluginDatabaseDriver (plugin-facing interface, in TableProPluginKit) - │ - Concrete Plugin Driver (e.g., MySQLPluginDriver, MongoDBPluginDriver) - │ - C Bridge / SPM Package (e.g., CMariaDB, CLibPQ, OracleNIO) -``` - -**Plugin bundles** (`.tableplugin`) are loaded at runtime from: - -1. `Bundle.main.builtInPlugInsURL` — app's `Contents/PlugIns/` -2. `~/Library/Application Support/TablePro/Plugins/` — user-installed - ---- - -## TableProPluginKit Framework - -Shared framework embedded in every plugin bundle. Defines all cross-boundary contracts. - -### Protocol Hierarchy - -#### `TableProPlugin` — root protocol - -| Property/Method | Type | Default | -| ------------------- | ----------------------------- | -------- | -| `pluginName` | `String` (static) | required | -| `pluginVersion` | `String` (static) | required | -| `pluginDescription` | `String` (static) | required | -| `capabilities` | `[PluginCapability]` (static) | required | -| `dependencies` | `[String]` (static) | `[]` | - -#### `DriverPlugin: TableProPlugin` — database driver factory - -| Property/Method | Type | Default | -| ---------------------------- | ------------------------------ | -------- | -| `databaseTypeId` | `String` (static) | required | -| `databaseDisplayName` | `String` (static) | required | -| `iconName` | `String` (static) | required | -| `defaultPort` | `Int` (static) | required | -| `additionalConnectionFields` | `[ConnectionField]` (static) | `[]` | -| `additionalDatabaseTypeIds` | `[String]` (static) | `[]` | -| `driverVariant(for:)` | `String?` (static) | `nil` | -| `createDriver(config:)` | `any PluginDatabaseDriver` | required | -| `requiresAuthentication` | `Bool` (static) | `true` | -| `connectionMode` | `ConnectionMode` (static) | `.network` | -| `urlSchemes` | `[String]` (static) | `[]` | -| `fileExtensions` | `[String]` (static) | `[]` | -| `brandColorHex` | `String` (static) | `"#808080"` | -| `queryLanguageName` | `String` (static) | `"SQL"` | -| `editorLanguage` | `EditorLanguage` (static) | `.sql` | -| `supportsForeignKeys` | `Bool` (static) | `true` | -| `supportsSchemaEditing` | `Bool` (static) | `true` | -| `supportsDatabaseSwitching` | `Bool` (static) | `true` | -| `supportsSchemaSwitching` | `Bool` (static) | `false` | -| `supportsImport` | `Bool` (static) | `true` | -| `supportsExport` | `Bool` (static) | `true` | -| `supportsHealthMonitor` | `Bool` (static) | `true` | -| `systemDatabaseNames` | `[String]` (static) | `[]` | -| `systemSchemaNames` | `[String]` (static) | `[]` | -| `databaseGroupingStrategy` | `GroupingStrategy` (static) | `.byDatabase` | -| `defaultGroupName` | `String` (static) | `"main"` | -| `columnTypesByCategory` | `[String: [String]]` (static) | 7-category dict (Integer, Float, String, Date, Binary, Boolean, JSON) | - -#### `ExportFormatPlugin: TableProPlugin` — export format - -| Property/Method | Default | -| ------------------------------------------------------------------- | --------------------------- | -| `formatId`, `formatDisplayName`, `defaultFileExtension`, `iconName` | required | -| `supportedDatabaseTypeIds`, `excludedDatabaseTypeIds` | `[]` | -| `perTableOptionColumns` | `[]` | -| `export(tables:dataSource:destination:progress:)` | required | -| `defaultTableOptionValues()` | `[]` | -| `isTableExportable(optionValues:)` | `true` | -| `currentFileExtension` | `Self.defaultFileExtension` | -| `warnings: [String]` | `[]` | - -#### `ImportFormatPlugin: TableProPlugin` — import format - -| Property/Method | Default | -| --------------------------------------------------------------------- | -------- | -| `formatId`, `formatDisplayName`, `acceptedFileExtensions`, `iconName` | required | -| `supportedDatabaseTypeIds`, `excludedDatabaseTypeIds` | `[]` | -| `performImport(source:sink:progress:)` | required | - -#### `SettablePluginDiscoverable` — type-erased settings witness - -| Property/Method | Default | -| ---------------- | ------- | -| `settingsView()` | — | - -Runtime-discoverable via `as? any SettablePluginDiscoverable` (needed because `SettablePlugin` has an associated type). - -#### `SettablePlugin: SettablePluginDiscoverable` — unified settings protocol - -| Property/Method | Default | -| ---------------------------- | ------------------------------------------------------ | -| `Settings` (associated type) | `Codable & Equatable` | -| `settingsStorageId` (static) | required | -| `settings` | required (stored var with `didSet { saveSettings() }`) | -| `settingsView()` | `nil` | -| `loadSettings()` | loads from `PluginSettingsStorage` | -| `saveSettings()` | saves to `PluginSettingsStorage` | - -Adopted by all 5 export plugins and SQL import plugin. Replaces the former `optionsView()` methods on `ExportFormatPlugin`/`ImportFormatPlugin` and `settingsView()` on `DriverPlugin`. - -### `PluginDatabaseDriver` — Core Driver Protocol - -Marked `AnyObject, Sendable`. All methods with defaults noted. - -#### Connection - -| Method | Default | -| ------------------------ | --------------------- | -| `connect() async throws` | required | -| `disconnect()` | required | -| `ping() async throws` | `execute("SELECT 1")` | -| `serverVersion: String?` | `nil` | - -#### Query Execution - -| Method | Default | -| --------------------------------------------------------------------------- | ----------------------- | -| `execute(query:) async throws -> PluginQueryResult` | required | -| `fetchRowCount(query:) async throws -> Int` | wraps `COUNT(*)` | -| `fetchRows(query:offset:limit:) async throws -> PluginQueryResult` | appends `LIMIT/OFFSET` | -| `executeParameterized(query:parameters:) async throws -> PluginQueryResult` | inline `?` substitution | - -#### Schema - -| Method | Default | -| ------------------------------------------------------------------------ | -------- | -| `fetchTables(schema:) async throws -> [PluginTableInfo]` | required | -| `fetchColumns(table:schema:) async throws -> [PluginColumnInfo]` | required | -| `fetchIndexes(table:schema:) async throws -> [PluginIndexInfo]` | required | -| `fetchForeignKeys(table:schema:) async throws -> [PluginForeignKeyInfo]` | required | -| `fetchTableDDL(table:schema:) async throws -> String` | required | -| `fetchViewDefinition(view:schema:) async throws -> String` | required | -| `fetchTableMetadata(table:schema:) async throws -> PluginTableMetadata` | required | -| `fetchDatabases() async throws -> [String]` | required | -| `fetchDatabaseMetadata(_:) async throws -> PluginDatabaseMetadata` | required | - -#### Schema Navigation - -| Method | Default | -| ----------------------------------------- | ------- | -| `supportsSchemas: Bool` | `false` | -| `fetchSchemas() async throws -> [String]` | `[]` | -| `switchSchema(to:) async throws` | no-op | -| `currentSchema: String?` | `nil` | - -#### Transactions - -| Method | Default | -| ------------------------------------ | --------------------- | -| `supportsTransactions: Bool` | `true` | -| `beginTransaction() async throws` | `execute("BEGIN")` | -| `commitTransaction() async throws` | `execute("COMMIT")` | -| `rollbackTransaction() async throws` | `execute("ROLLBACK")` | - -#### Execution Control - -| Method | Default | -| ------------------------------------ | ------- | -| `cancelQuery() throws` | no-op | -| `applyQueryTimeout(_:) async throws` | no-op | - -#### Batch Operations (all have defaults) - -| Method | Default | -| ----------------------------------------- | ---------------------- | -| `fetchApproximateRowCount(table:schema:)` | `nil` | -| `fetchAllColumns(schema:)` | N+1 loop | -| `fetchAllForeignKeys(schema:)` | N+1 loop | -| `fetchAllDatabaseMetadata()` | N+1 loop | -| `fetchDependentTypes(table:schema:)` | `[]` | -| `fetchDependentSequences(table:schema:)` | `[]` | -| `createDatabase(name:charset:collation:)` | throws "not supported" | -| `switchDatabase(to:)` | throws "not supported" | - -#### NoSQL Query Building (all default `nil` — SQL plugins leave unimplemented) - -| Method | -| ------------------------------------------------------------------------------------------------------------------- | -| `buildBrowseQuery(table:sortColumns:columns:limit:offset:) -> String?` | -| `buildFilteredQuery(table:filters:logicMode:sortColumns:columns:limit:offset:) -> String?` | -| `buildQuickSearchQuery(table:searchText:columns:sortColumns:limit:offset:) -> String?` | -| `buildCombinedQuery(table:filters:logicMode:searchText:searchColumns:sortColumns:columns:limit:offset:) -> String?` | - -#### Statement Generation (default `nil` — NoSQL plugins override) - -| Method | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generateStatements(table:columns:changes:insertedRowData:deletedRowIndices:insertedRowIndices:) -> [(statement: String, parameters: [String?])]?` | - -### Transfer Types - -| Type | Key Fields | -| ------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `PluginQueryResult` | `columns`, `columnTypeNames`, `rows: [[String?]]`, `rowsAffected`, `executionTime`, `isTruncated` | -| `PluginColumnInfo` | `name`, `dataType`, `isNullable`, `isPrimaryKey`, `defaultValue`, `extra`, `charset`, `collation`, `comment` | -| `PluginTableInfo` | `name`, `type` ("TABLE"/"VIEW"), `rowCount` | -| `PluginTableMetadata` | `tableName`, `dataSize`, `indexSize`, `totalSize`, `rowCount`, `comment`, `engine` | -| `PluginDatabaseMetadata` | `name`, `tableCount`, `sizeBytes`, `isSystemDatabase` | -| `PluginForeignKeyInfo` | `name`, `column`, `referencedTable`, `referencedColumn`, `onDelete`, `onUpdate` | -| `PluginIndexInfo` | `name`, `columns`, `isUnique`, `isPrimary`, `type` | -| `DriverConnectionConfig` | `host`, `port`, `username`, `password`, `database`, `additionalFields: [String: String]` | -| `ConnectionField` | `id`, `label`, `placeholder`, `isRequired`, `defaultValue`, `fieldType: FieldType`, `isSecure` (computed from fieldType) | -| `ConnectionField.FieldType` | enum: `.text`, `.secure`, `.dropdown(options:)` | -| `ConnectionField.DropdownOption` | `value`, `label` | -| `PluginRowChange` | `rowIndex`, `type (.insert/.update/.delete)`, `cellChanges`, `originalRow` | -| `PluginCapability` | enum: `.databaseDriver`, `.exportFormat`, `.importFormat` | -| `PluginRowLimits` | `defaultMax = 100_000` (static constant) | -| `ConnectionMode` | enum: `.network`, `.fileBased` | -| `EditorLanguage` | enum: `.sql`, `.javascript`, `.bash`, `.custom(String)` | -| `GroupingStrategy` | enum: `.byDatabase`, `.bySchema`, `.flat` | -| `PluginExportTable` | `name`, `databaseName`, `tableType`, `optionValues`, `qualifiedName` | -| `PluginExportOptionColumn` | `id`, `label`, `width`, `defaultValue` | -| `PluginExportError` | enum: `.fileWriteFailed`, `.encodingFailed`, `.compressionFailed`, `.exportFailed` | -| `PluginExportCancellationError` | empty struct, `Error + LocalizedError` | -| `PluginSequenceInfo` | `name`, `ddl` | -| `PluginEnumTypeInfo` | `name`, `labels: [String]` | -| `PluginImportResult` | `executedStatements`, `executionTime`, `failedStatement?`, `failedLine?` | -| `PluginImportError` | enum: `.statementFailed`, `.rollbackFailed`, `.cancelled`, `.importFailed` | -| `PluginImportCancellationError` | empty struct, `Error + LocalizedError` | - -### Error Protocol - -`PluginDriverError` — plugins conform their errors: - -- `pluginErrorMessage: String` -- `pluginErrorCode: Int?` (default: `nil`) -- `pluginSqlState: String?` (default: `nil`) -- `pluginErrorDetail: String?` (default: `nil`) - -### Concurrency Helpers - -`PluginConcurrencySupport.swift` — bridges blocking C library calls to Swift concurrency: - -- `pluginDispatchAsync(on:execute:) async throws -> T` -- `pluginDispatchAsync(on:execute:) async throws` (void) -- `pluginDispatchAsyncCancellable(on:cancellationCheck:execute:) async throws -> T` - -### Shared Utilities - -- `PluginSettingsStorage` — namespaced `UserDefaults` (`com.TablePro.plugin..`) -- `PluginExportUtilities` — `escapeJSONString`, `createFileHandle`, `sanitizeForSQLComment` -- `MongoShellParser` — parses MongoDB shell syntax into `MongoOperation` cases -- `ArrayExtension` — `subscript(safe:)` on `Array` - ---- - -## Plugin Loading Infrastructure - -### PluginManager (`Core/Plugins/PluginManager.swift`) - -`@MainActor @Observable` singleton. Central registry. - -**State:** - -- `plugins: [PluginEntry]` — all discovered metadata -- `driverPlugins: [String: any DriverPlugin]` — keyed by `databaseTypeId` -- `exportPlugins: [String: any ExportFormatPlugin]` — keyed by `formatId` -- `importPlugins: [String: any ImportFormatPlugin]` — keyed by `formatId` -- `disabledPluginIds: Set` — persisted to `UserDefaults` -- `pluginInstances: [String: any TableProPlugin]` — all loaded plugin instances -- `needsRestart: Bool` — true after uninstall -- `isInstalling: Bool` — true during installation - -**Two-phase loading:** - -1. **`discoverAllPlugins()`** (synchronous, at launch) — scans both directories for `.tableplugin` bundles, reads `Info.plist` for version checks (`TableProPluginKitVersion`, `TableProMinAppVersion`). User-installed plugins: verifies code signature against Team ID `D7HJ5TFYCU`. -2. **`loadPendingPlugins()`** (async, deferred to next run loop) — calls `bundle.load()`, reads `principalClass`, casts to `TableProPlugin.Type`, calls `registerCapabilities`. - -**Capability registration:** Pattern-matches on protocol conformance (`DriverPlugin`, `ExportFormatPlugin`, `ImportFormatPlugin`). - -**Plugin installation:** ZIP extraction via `/usr/bin/ditto -xk`, signature verification, copy to user plugins dir. Registry support: download via `RegistryClient`, SHA-256 checksum verification. - -**`currentPluginKitVersion = 1`** — rejects plugins declaring a higher version. - -### PluginEntry (`Core/Plugins/PluginModels.swift`) - -Lightweight metadata: `id` (bundle identifier), `bundle`, `url`, `source` (.builtIn/.userInstalled), `name`, `version`, `capabilities`, `isEnabled`. - -### PluginError (`Core/Plugins/PluginError.swift`) - -Cases: `invalidBundle`, `signatureInvalid`, `checksumMismatch`, `incompatibleVersion`, `appVersionTooOld`, `cannotUninstallBuiltIn`, `notFound`, `noCompatibleBinary`, `installFailed`, `pluginConflict`, `downloadFailed`, `pluginNotInstalled`, `incompatibleWithCurrentApp`. - ---- - -## Host App Driver Layer - -### DatabaseDriver Protocol (`Core/Database/DatabaseDriver.swift`) - -Internal interface mirroring `PluginDatabaseDriver` but using app-side types (`QueryResult`, `ColumnInfo`, etc.). Key additions beyond plugin protocol: - -- `var connection: DatabaseConnection { get }` -- `var status: ConnectionStatus { get }` -- `func testConnection() async throws -> Bool` (connect + disconnect) -- `var noSqlPluginDriver: (any PluginDatabaseDriver)?` (default: `nil`) - -**`SchemaSwitchable`** sub-protocol: `currentSchema`, `escapedSchema`, `switchSchema(to:)`. - -### DatabaseDriverFactory (`DatabaseDriver.swift`) - -Static method: `createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver`. - -**Lookup chain:** - -1. `connection.type.pluginTypeId` → `PluginManager.shared.driverPlugins[...]` -2. Fallback: `loadPendingPlugins()` safety call -3. Build `DriverConnectionConfig` (password from Keychain, SSL fields, type-specific extras) -4. `plugin.createDriver(config:)` → `PluginDatabaseDriver` -5. Wrap in `PluginDriverAdapter(connection:pluginDriver:)` → return - -### PluginDriverAdapter (`Core/Plugins/PluginDriverAdapter.swift`) - -Bridges `PluginDatabaseDriver` → `DatabaseDriver`. Conforms to both `DatabaseDriver` and `SchemaSwitchable`. - -Key behaviors: - -- **Status tracking:** Owns `status: ConnectionStatus`, sets `.connecting`/`.connected`/`.error` around plugin calls -- **NoSQL detection:** Probes `buildBrowseQuery(table: "_probe", ...)` — non-nil = NoSQL plugin handles query building -- **`mapQueryResult`:** Converts `PluginQueryResult` → `QueryResult`, maps type name strings to `ColumnType` enum via uppercased prefix/suffix matching -- **Schema context:** Passes `pluginDriver.currentSchema` as `schema:` to all schema-related calls - -### DatabaseManager (`Core/Database/DatabaseManager.swift`) - -`@MainActor @Observable` singleton managing sessions. - -**State:** - -- `activeSessions: [UUID: ConnectionSession]` -- `currentSessionId: UUID?` -- `healthMonitors: [UUID: ConnectionHealthMonitor]` -- `connectionListVersion: Int` — incremented on connection list changes -- `connectionStatusVersion: Int` — incremented on status changes - -**Connection flow:** - -1. Check for existing session → switch to it -2. SSH tunnel if needed (`SSHTunnelManager`) -3. `DatabaseDriverFactory.createDriver(for:)` → `PluginDriverAdapter` -4. `driver.connect()`, `applyQueryTimeout()`, `executeStartupCommands()` -5. Schema/database initialization per driver type -6. `startHealthMonitor(for:)` (skipped for SQLite/DuckDB) - -**Health monitoring (`ConnectionHealthMonitor`):** - -- 30s ping interval -- Exponential backoff reconnect: initial `[2, 4, 8]s`, doubles up to `120s` cap -- Random 0–10s initial stagger across multiple connections - ---- - -## All Database Plugins - -### 1. MySQLDriverPlugin — MySQL / MariaDB - -| Attribute | Value | -| ---------------- | ------------------------------------------------------- | -| **Entry** | `Plugins/MySQLDriverPlugin/MySQLPlugin.swift` | -| **Driver** | `MySQLPluginDriver` (618 lines) | -| **Connection** | `MariaDBPluginConnection` (822 lines) | -| **C Bridge** | `CMariaDB` → `libmariadb.a` | -| **DB Types** | MySQL (primary) + MariaDB (`additionalDatabaseTypeIds`) | -| **Default Port** | 3306 | -| **File Count** | 4 Swift + C bridge | - -**Implemented methods (overriding defaults):** - -- `connect`, `disconnect`, `ping` -- `execute` (with auto-reconnect on error codes 2006/2013/2055) -- `executeParameterized` — native `mysql_stmt_*` prepared statements -- `cancelQuery` — opens 2nd connection, sends `KILL QUERY ` -- `applyQueryTimeout` — detects MariaDB vs MySQL (`max_statement_time` vs `max_execution_time`) -- `fetchTables`, `fetchColumns`, `fetchAllColumns` -- `fetchIndexes`, `fetchForeignKeys`, `fetchAllForeignKeys` -- `fetchApproximateRowCount` — `information_schema.TABLES.TABLE_ROWS` -- `fetchTableDDL`, `fetchViewDefinition`, `fetchTableMetadata` -- `fetchDatabases`, `fetchDatabaseMetadata`, `fetchAllDatabaseMetadata` -- `createDatabase` — charset whitelist validation -- `switchDatabase` — `` USE `db` `` -- `beginTransaction` — `START TRANSACTION` -- `fetchRowCount`, `fetchRows` — strip existing LIMIT/OFFSET - -**Unique:** GEOMETRY WKB→WKT parser, ENUM/SET flag detection from wire protocol, native prepared statements. - -**Transactions:** Yes | **Schemas:** No - ---- - -### 2. PostgreSQLDriverPlugin — PostgreSQL / Redshift - -| Attribute | Value | -| ---------------- | ------------------------------------------------------------- | -| **Entry** | `Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift` | -| **Driver (PG)** | `PostgreSQLPluginDriver` (744 lines) | -| **Driver (RS)** | `RedshiftPluginDriver` (652 lines) | -| **Connection** | `LibPQPluginConnection` | -| **C Bridge** | `CLibPQ` → `libpq.a` | -| **DB Types** | PostgreSQL (primary) + Redshift (`additionalDatabaseTypeIds`) | -| **Default Port** | 5432 | -| **File Count** | 4 Swift + C bridge | - -**PostgreSQL — implemented methods:** - -- All standard + `supportsSchemas = true` -- `fetchSchemas` — `information_schema.schemata`, excludes `pg_%`/`information_schema` -- `switchSchema` — `SET search_path TO "schema", public` -- `fetchColumns` — complex JOIN across `pg_statio_all_tables`, `pg_description`, PK subquery; USER-DEFINED → ENUM -- `fetchTableDDL` — 3 parallel `async let` queries → reconstructed DDL -- `fetchDependentTypes` — PostgreSQL ENUM types via `pg_enum` -- `fetchDependentSequences` — sequences via `pg_sequences` + `pg_attrdef` -- `fetchApproximateRowCount` — `pg_class.reltuples` -- `cancelQuery` — `PQcancel` -- `applyQueryTimeout` — `SET statement_timeout = 'Nms'` -- `createDatabase` — charset whitelist validation - -**Redshift — differences:** - -- `fetchIndexes` — Redshift `pg_table_def` for DISTKEY/SORTKEY -- `fetchApproximateRowCount` — `svv_table_info.tbl_rows` -- `fetchTableDDL` — `SHOW TABLE` or `pg_attribute` + DISTKEY/SORTKEY -- `fetchTableMetadata` / `fetchDatabaseMetadata` — `svv_table_info` - -**Transactions:** Yes | **Schemas:** Yes (both drivers) - ---- - -### 3. SQLiteDriverPlugin — SQLite - -| Attribute | Value | -| ------------------ | ----------------------------------------------------------- | -| **Entry + Driver** | `Plugins/SQLiteDriverPlugin/SQLitePlugin.swift` (684 lines) | -| **Connection** | `SQLiteConnectionActor` (Swift `actor`) | -| **C Bridge** | None — macOS SDK `SQLite3` | -| **Default Port** | 0 (file-based) | -| **File Count** | 1 Swift | - -**Implemented methods:** - -- All standard schema/query methods -- `executeParameterized` — native `sqlite3_bind_text`/`sqlite3_bind_null` -- `cancelQuery` — `sqlite3_interrupt` -- `applyQueryTimeout` — `sqlite3_busy_timeout` (lock wait, not execution) -- `fetchAllColumns` — single query via `pragma_table_info` -- `fetchTableDDL` — `sqlite_master.sql` + DDL prettifier -- `fetchDatabases` — `[]` (N/A) -- `createDatabase` — throws unsupported - -**Unique:** Actor-based concurrency, DDL formatter, file-based connection, `~` path expansion. - -**Transactions:** Yes | **Schemas:** No - ---- - -### 4. ClickHouseDriverPlugin — ClickHouse - -| Attribute | Value | -| ------------------ | ------------------------------------------------------------------- | -| **Entry + Driver** | `Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift` (868 lines) | -| **C Bridge** | None — URLSession HTTP | -| **Connection** | HTTP POST to `http[s]://host:port/` with Basic auth | -| **Default Port** | 8123 | -| **File Count** | 1 Swift | - -**Implemented methods:** - -- All standard + custom parameterized queries via ClickHouse HTTP param protocol (`{p1:String}`) -- `cancelQuery` — cancel URLSession task + `KILL QUERY WHERE query_id` -- `applyQueryTimeout` — `SET max_execution_time = N` -- `switchDatabase` — in-memory only -- `fetchApproximateRowCount` — `system.parts` SUM(rows) -- `fetchForeignKeys` — `[]` (ClickHouse has no FKs) -- Response parsing: `FORMAT TabSeparatedWithNamesAndTypes`, `\N` = NULL - -**Unique:** HTTP-only (no C dependency), TSV parsing, self-signed cert support via `InsecureTLSDelegate`, engine type in table listing. - -**Transactions:** No (`supportsTransactions = false`) | **Schemas:** No - ---- - -### 5. MSSQLDriverPlugin — SQL Server - -| Attribute | Value | -| --------------------- | ---------------------------------------------------------- | -| **Entry + Driver** | `Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift` (1047 lines) | -| **Connection** | `FreeTDSConnection` — TDS 7.4 | -| **C Bridge** | `CFreeTDS` → `libsybdb.a` | -| **Additional Fields** | `mssqlSchema` (default: `dbo`) | -| **Default Port** | 1433 | -| **File Count** | 1 Swift + C bridge | - -**Implemented methods:** - -- All standard + `supportsSchemas = true` -- `executeParameterized` — `sp_executesql` with `@p1, @p2, ...` params -- `switchDatabase` — `dbuse()` via FreeTDS -- `switchSchema` — in-memory only -- `fetchTableDDL` — manual DDL reconstruction from columns + indexes + FKs -- `fetchRows` — `OFFSET N ROWS FETCH NEXT N ROWS ONLY` (T-SQL pagination) -- Global error/message handlers via NSLock-protected string - -- `cancelQuery` — `FreeTDSConnection.cancelCurrentQuery()` -- `applyQueryTimeout` — `SET LOCK_TIMEOUT ` - -**Unique:** Multi-result set support, native NVARCHAR (UTF-16LE), `hasTopLevelOrderBy` reverse scanner. - -**Transactions:** Yes | **Schemas:** Yes - ---- - -### 6. MongoDBDriverPlugin — MongoDB - -| Attribute | Value | -| --------------------- | ------------------------------------------------------------- | -| **Entry** | `Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift` | -| **Driver** | `MongoDBPluginDriver` (803 lines) | -| **Connection** | `MongoDBConnection` (libmongoc URI) | -| **C Bridge** | `CLibMongoc` → libmongoc | -| **Additional Fields** | `mongoAuthSource`, `mongoReadPreference`, `mongoWriteConcern` | -| **Default Port** | 27017 | -| **File Count** | 6 Swift + C bridge | - -**Implemented methods:** - -- All standard schema methods (columns inferred by sampling 500 docs) -- `execute` — parses via `MongoShellParser`, dispatches to typed operations -- `cancelQuery` — via `MongoDBConnection.cancelCurrentQuery()` -- `fetchAllColumns` — parallelized (batch size 4 via `withThrowingTaskGroup`) -- `fetchTableDDL` — JS shell code (collection options + createIndex calls) -- `fetchForeignKeys` — `[]` -- **All 4 `build*Query` methods** — delegates to `MongoDBQueryBuilder` -- **`generateStatements`** — delegates to `MongoDBStatementGenerator` - -**Supported operations:** `find`, `findOne`, `aggregate`, `countDocuments`, `insertOne/Many`, `updateOne/Many`, `replaceOne`, `deleteOne/Many`, `createIndex`, `dropIndex`, `findOneAndUpdate/Replace/Delete`, `drop`, `runCommand`, `listCollections`, `listDatabases`, `ping` - -**Unique:** Schema inferred by document sampling, BSON flattener for tabular display, full MQL CRUD coverage. - -**Transactions:** No | **Schemas:** No - ---- - -### 7. RedisDriverPlugin — Redis - -| Attribute | Value | -| --------------------- | --------------------------------------------- | -| **Entry** | `Plugins/RedisDriverPlugin/RedisPlugin.swift` | -| **Driver** | `RedisPluginDriver` (~1412 lines) | -| **Connection** | `RedisPluginConnection` (hiredis) | -| **C Bridge** | `CRedis` → libhiredis | -| **Additional Fields** | `redisDatabase` | -| **Default Port** | 6379 | -| **File Count** | 6 Swift + C bridge | - -**Implemented methods:** - -- `ping` — `PING` command -- `execute` — parses via `RedisCommandParser`, dispatches to typed operations -- `cancelQuery` — via connection cancel -- `fetchTables` — `INFO keyspace` (databases = "tables") -- `fetchColumns` — hardcoded: `[Key (PK), Type, TTL, Value]` -- `fetchApproximateRowCount` — `DBSIZE` -- `fetchTableDDL` — comment block with DB info + sampled type distribution -- **All 4 `build*Query` methods** — delegates to `RedisQueryBuilder` -- **`generateStatements`** — delegates to `RedisStatementGenerator` - -**Concept mapping:** databases → "tables", key-value pairs → "rows" with Key/Type/TTL/Value columns. - -**Unique:** SCAN-based enumeration (avoids KEYS), pipeline support, `SELECT 1` remapped to `PING`. - -**Transactions:** No | **Schemas:** No - ---- - -### 8. OracleDriverPlugin — Oracle - -| Attribute | Value | -| --------------------- | ----------------------------------------------------------- | -| **Entry + Driver** | `Plugins/OracleDriverPlugin/OraclePlugin.swift` (643 lines) | -| **Connection** | `OracleConnectionWrapper` (247 lines) | -| **C Bridge** | None — OracleNIO (SPM) | -| **Additional Fields** | `oracleServiceName` | -| **Default Port** | 1521 | -| **File Count** | 2 Swift | - -**Implemented methods:** - -- All standard + `supportsSchemas = true` -- `execute` — intercepts `SELECT 1` → `SELECT 1 FROM DUAL` -- `beginTransaction` — no-op (Oracle implicit transactions) -- `fetchRows` — `OFFSET N ROWS FETCH NEXT N ROWS ONLY` (Oracle 12c+) -- `fetchSchemas` = `fetchDatabases` = `ALL_USERS` (Oracle schemas = users) -- `switchSchema` — `ALTER SESSION SET CURRENT_SCHEMA` -- `fetchTableDDL` — `DBMS_METADATA.GET_DDL` with manual fallback -- `fetchAllColumns`, `fetchAllForeignKeys` — bulk queries - -**Unique:** Pure Swift NIO client, multi-strategy cell decoding, implicit transactions, `DBMS_METADATA.GET_DDL`. - -**Not implemented:** `cancelQuery` (default no-op — OracleNIO has no cancel API). - -**Transactions:** Implicit (no BEGIN needed) | **Schemas:** Yes - ---- - -### 9. DuckDBDriverPlugin — DuckDB - -| Attribute | Value | -| ------------------ | ----------------------------------------------------------- | -| **Entry + Driver** | `Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift` (908 lines) | -| **Connection** | `DuckDBConnectionActor` (Swift `actor`) | -| **C Bridge** | `CDuckDB` → DuckDB C API | -| **Default Port** | 0 (file-based) | -| **File Count** | 1 Swift + C bridge | - -**Implemented methods:** - -- All standard + `supportsSchemas = true` (default: `main`) -- `executeParameterized` — native `duckdb_bind_*` -- `cancelQuery` — `duckdb_interrupt` -- `fetchTableDDL` — `duckdb_tables()` first, manual fallback -- `fetchIndexes` — `duckdb_indexes()` with ART index type -- `serverVersion` — `duckdb_library_version()` at runtime -- Analytical types: HUGEINT, LIST, STRUCT, MAP, UNION, UUID, BIT - -**Unique:** Auto-installs/loads extensions on connect, analytical type support, file-based like SQLite. - -**Transactions:** Yes | **Schemas:** Yes - ---- - -## Query Building & Change Tracking - -### TableQueryBuilder (`Core/Services/Query/TableQueryBuilder.swift`) - -Pure `struct` with `databaseType` and optional `pluginDriver`. Constructs SELECT queries for table browser. - -**Plugin dispatch pattern** (all 4 query methods): - -1. If `pluginDriver != nil` → call `pluginDriver.buildXxx(...)` -2. If plugin returns non-nil String → use it (MongoDB/Redis) -3. If nil → fall through to hardcoded SQL path -4. MSSQL/Oracle: `OFFSET n ROWS FETCH NEXT n ROWS ONLY` -5. Others: `LIMIT n OFFSET n` - -**LIKE escaping per dialect:** -| Database | Pattern | -|---|---| -| PostgreSQL/Redshift | `column::TEXT LIKE '%x%' ESCAPE '\'` | -| MySQL/MariaDB | `CAST(column AS CHAR) LIKE '%x%'` | -| ClickHouse | `toString(column) LIKE '%x%' ESCAPE '\'` | -| DuckDB | `CAST(column AS VARCHAR) LIKE '%x%' ESCAPE '\'` | -| MSSQL | `CAST(column AS NVARCHAR(MAX)) LIKE '%x%' ESCAPE '\'` | -| Oracle | `CAST(column AS VARCHAR2(4000)) LIKE '%x%' ESCAPE '\'` | -| SQLite/MongoDB/Redis | bare `LIKE '%x%' ESCAPE '\'` | - -### DataChangeManager (`Core/ChangeTracking/DataChangeManager.swift`) - -`@MainActor @Observable` — central controller for in-flight data edits. - -**Plugin integration in `generateSQL()`:** - -1. If `pluginDriver != nil`: convert `RowChange` → `PluginRowChange`, call `pluginDriver.generateStatements(...)` -2. Non-nil result → map to `ParameterizedStatement` -3. `nil` result → fall through to `SQLStatementGenerator` -4. For MongoDB/Redis: throws if plugin driver unavailable - -### SQLStatementGenerator (`Core/ChangeTracking/SQLStatementGenerator.swift`) - -Placeholder syntax per database: - -- PostgreSQL/Redshift/DuckDB: `$1`, `$2`, ... -- Everything else: `?` - -Per-database quirks: - -- MySQL/MariaDB: `UPDATE ... LIMIT 1` / `DELETE ... LIMIT 1` -- MSSQL: `UPDATE TOP (1)` / `DELETE TOP (1)` -- Oracle: `AND ROWNUM = 1` -- ClickHouse: `ALTER TABLE t UPDATE/DELETE WHERE ...` - -### SQLDialectProvider (`Core/Services/Query/SQLDialectProvider.swift`) - -Per-dialect keyword/function/type sets for autocomplete. Factory: `SQLDialectFactory.createDialect(for: DatabaseType)`. - -| Dialect | Databases | `identifierQuote` | -| ------------------- | -------------------- | ----------------- | -| `MySQLDialect` | MySQL, MariaDB | `` ` `` | -| `PostgreSQLDialect` | PostgreSQL, Redshift | `"` | -| `SQLiteDialect` | SQLite | `` ` `` | -| `MSSQLDialect` | SQL Server | `[` | -| `OracleDialect` | Oracle | `"` | -| `ClickHouseDialect` | ClickHouse | `` ` `` | -| `DuckDBDialect` | DuckDB | `"` | - -MongoDB/Redis fall back to `SQLiteDialect` (placeholder). - ---- - -## Supporting Infrastructure - -### DatabaseType Enum (`Models/Connection/DatabaseConnection.swift`) - -| Case | Raw Value | `pluginTypeId` | Port | `identifierQuote` | -| ------------- | -------------- | -------------- | ----- | ----------------- | -| `.mysql` | `"MySQL"` | `"MySQL"` | 3306 | `` ` `` | -| `.mariadb` | `"MariaDB"` | `"MySQL"` | 3306 | `` ` `` | -| `.postgresql` | `"PostgreSQL"` | `"PostgreSQL"` | 5432 | `"` | -| `.sqlite` | `"SQLite"` | `"SQLite"` | 0 | `` ` `` | -| `.redshift` | `"Redshift"` | `"PostgreSQL"` | 5439 | `"` | -| `.mongodb` | `"MongoDB"` | `"MongoDB"` | 27017 | `"` | -| `.redis` | `"Redis"` | `"Redis"` | 6379 | `"` | -| `.mssql` | `"SQL Server"` | `"SQL Server"` | 1433 | `[` | -| `.oracle` | `"Oracle"` | `"Oracle"` | 1521 | `"` | -| `.clickhouse` | `"ClickHouse"` | `"ClickHouse"` | 8123 | `` ` `` | -| `.duckdb` | `"DuckDB"` | `"DuckDB"` | 0 | `"` | - -Key computed properties: `isDownloadablePlugin`, `requiresAuthentication`, `supportsForeignKeys`, `supportsSchemaEditing`, `quoteIdentifier(_:)`. - -### Connection Models (`Models/Connection/`) - -- **`DatabaseConnection`** — persisted config: host, port, database, username, type, SSH/SSL configs, per-driver fields -- **`ConnectionSession`** — runtime state: driver, status, tables, selectedTables, currentSchema/Database -- **`SSHConfiguration`** — password/key/agent auth, jump hosts, 1Password socket detection -- **`SSLConfiguration`** — 5 SSL modes, CA/client cert/key paths - -### Schema Models (`Models/Schema/`) - -- **`EditableColumnDefinition`** — richer than `PluginColumnInfo` (adds autoIncrement, unsigned, onUpdate) -- **`EditableIndexDefinition`** — IndexType enum (BTREE, HASH, FULLTEXT, SPATIAL, GIN, GIST, BRIN) -- **`EditableForeignKeyDefinition`** — ReferentialAction enum -- **`SchemaChange`** — enum: addColumn/modifyColumn/deleteColumn + indexes + FKs + modifyPrimaryKey -- Has `isDestructive` and `requiresDataMigration` computed properties - -### Export/Import Adapters - -- **`ExportDataSourceAdapter`** — `DatabaseDriver` → `PluginExportDataSource` -- **`ImportDataSinkAdapter`** — `DatabaseDriver` → `PluginImportDataSink` (includes FK disable/enable per database) -- **`SqlFileImportSource`** — wraps SQL file URL with decompression + async statement streaming - ---- - -## Cross-Plugin Comparison Matrix - -| Feature | MySQL | PostgreSQL | Redshift | SQLite | ClickHouse | MSSQL | MongoDB | Redis | Oracle | DuckDB | -| ------------------------- | ------------------ | ----------------------- | ----------------- | ------------- | ------------------ | ---------------- | ---------------- | -------------- | ---------------- | --------------- | -| **Transactions** | Yes | Yes | Yes | Yes | No | Yes | No | No | Implicit | Yes | -| **Schemas** | No | Yes | Yes | No | No | Yes | No | No | Yes | Yes | -| **Parameterized** | Native stmt | Native PQ | Native PQ | Native bind | HTTP params | sp_executesql | N/A | N/A | Default fallback | Native bind | -| **cancelQuery** | KILL QUERY | PQcancel | PQcancel | interrupt | KILL HTTP | FreeTDS cancel | mongoc cancel | hiredis cancel | no-op | interrupt | -| **Query Timeout** | max_execution_time | statement_timeout | statement_timeout | busy_timeout | max_execution_time | SET LOCK_TIMEOUT | setQueryTimeout | no-op | no-op | no-op | -| **DB Switching** | USE | (reconnect) | (reconnect) | N/A | in-memory | dbuse() | in-memory | N/A | N/A | N/A | -| **Custom Query Builder** | No | No | No | No | No | No | Yes | Yes | No | No | -| **Custom Stmt Generator** | No | No | No | No | No | No | Yes | Yes | No | No | -| **Approx Row Count** | info_schema | pg_class | svv_table_info | N/A | system.parts | sys.partitions | countDocuments | DBSIZE | N/A | N/A | -| **DDL Source** | SHOW CREATE | pg_attribute (parallel) | SHOW TABLE | sqlite_master | SHOW CREATE | Manual build | JS comment block | Comment block | DBMS_METADATA | duckdb_tables() | -| **Dependent Types** | No | Yes (ENUM) | No | No | No | No | No | No | No | No | -| **Dependent Seqs** | No | Yes | No | No | No | No | No | No | No | No | -| **C Bridge** | CMariaDB | CLibPQ | CLibPQ | SDK SQLite3 | URLSession | CFreeTDS | CLibMongoc | CRedis | OracleNIO | CDuckDB | -| **Lines of Code** | ~1440 | ~1478 | ~652 | ~684 | ~868 | ~1105 | ~803+ | ~1412 | ~890 | ~908 | - ---- - -## Known Gaps & Future Work - -Based on `docs/development/plugin-extensibility-plan.md`: - -### Current Limitations - -- **~112 `switch databaseType` sites** across ~37 files — SQL dialect, DDL/DML generation, filter behavior, autocomplete, theme colors, icons, and connection form layout are still hardcoded in the host app -- **Schema DDL generation** (`SchemaStatementGenerator`) has NO plugin dispatch — entirely per-type branches in app -- **SQLDialectProvider** is app-side only — plugins cannot provide their own keywords/functions/types -- **MSSQL**: `applyQueryTimeout` uses `SET LOCK_TIMEOUT` (lock wait, not execution timeout) -- **Oracle**: `cancelQuery` uses default no-op (OracleNIO limitation) -- **MongoDB/Redis**: Fall back to `SQLiteDialect` in autocomplete (placeholder) - -### Planned Phases (from extensibility plan) - -1. **Phase 1**: Plugin capability descriptors (connection form, SQL dialect, icons) -2. **Phase 2**: SQL dialect as plugin-provided `SQLDialectDescriptor` -3. **Phase 3**: Schema DDL generation moved to plugin -4. **Phase 4**: Filter/query building fully delegated -5. **Phase 5**: Autocomplete provider as plugin capability -6. **Phase 6**: Theme/icon customization -7. **Phase 7**: Full third-party plugin marketplace - ---- - -## File Reference - -### TableProPluginKit - -| File | Role | -| ---------------------------------------------------------- | ----------------------------------------------------- | -| `Plugins/TableProPluginKit/TableProPlugin.swift` | Root plugin protocol | -| `Plugins/TableProPluginKit/DriverPlugin.swift` | Driver factory protocol | -| `Plugins/TableProPluginKit/PluginDatabaseDriver.swift` | Core driver protocol (30+ methods) | -| `Plugins/TableProPluginKit/PluginCapability.swift` | Capability enum | -| `Plugins/TableProPluginKit/PluginQueryResult.swift` | Query result transfer type | -| `Plugins/TableProPluginKit/PluginColumnInfo.swift` | Column schema transfer type | -| `Plugins/TableProPluginKit/PluginTableInfo.swift` | Table info transfer type | -| `Plugins/TableProPluginKit/PluginTableMetadata.swift` | Table metadata transfer type | -| `Plugins/TableProPluginKit/PluginDatabaseMetadata.swift` | Database metadata transfer type | -| `Plugins/TableProPluginKit/PluginForeignKeyInfo.swift` | FK info transfer type | -| `Plugins/TableProPluginKit/PluginIndexInfo.swift` | Index info transfer type | -| `Plugins/TableProPluginKit/DriverConnectionConfig.swift` | Connection config for plugins | -| `Plugins/TableProPluginKit/ConnectionField.swift` | Custom connection field descriptor | -| `Plugins/TableProPluginKit/PluginConcurrencySupport.swift` | Async bridge for C calls | -| `Plugins/TableProPluginKit/PluginDriverError.swift` | Error protocol | -| `Plugins/TableProPluginKit/PluginSettingsStorage.swift` | Namespaced UserDefaults | -| `Plugins/TableProPluginKit/SettablePlugin.swift` | SettablePlugin + SettablePluginDiscoverable protocols | -| `Plugins/TableProPluginKit/PluginExportUtilities.swift` | Export helpers | -| `Plugins/TableProPluginKit/ExportFormatPlugin.swift` | Export plugin protocol | -| `Plugins/TableProPluginKit/ImportFormatPlugin.swift` | Import plugin protocol | -| `Plugins/TableProPluginKit/MongoShellParser.swift` | MongoDB shell syntax parser | -| `Plugins/TableProPluginKit/PluginRowLimits.swift` | Row limit constant | -| `Plugins/TableProPluginKit/ConnectionMode.swift` | Connection mode enum (network, fileBased) | -| `Plugins/TableProPluginKit/EditorLanguage.swift` | Editor language enum (sql, javascript, bash, custom) | -| `Plugins/TableProPluginKit/GroupingStrategy.swift` | Grouping strategy enum (byDatabase, bySchema, flat) | -| `Plugins/TableProPluginKit/PluginExportTypes.swift` | Export transfer types (table, option, error, sequence) | -| `Plugins/TableProPluginKit/PluginImportTypes.swift` | Import transfer types (result, error, cancellation) | -| `Plugins/TableProPluginKit/PluginExportProgress.swift` | Export progress tracking | -| `Plugins/TableProPluginKit/PluginImportProgress.swift` | Import progress tracking | -| `Plugins/TableProPluginKit/PluginExportDataSource.swift` | Export data source protocol | -| `Plugins/TableProPluginKit/PluginImportDataSink.swift` | Import data sink protocol | -| `Plugins/TableProPluginKit/PluginImportSource.swift` | Import source protocol | - -### Host App Core - -| File | Role | -| ------------------------------------------------------------- | -------------------------------------------- | -| `TablePro/Core/Plugins/PluginManager.swift` | Plugin discovery, loading, registration | -| `TablePro/Core/Plugins/PluginDriverAdapter.swift` | PluginDatabaseDriver → DatabaseDriver bridge | -| `TablePro/Core/Plugins/PluginModels.swift` | PluginEntry, PluginSource | -| `TablePro/Core/Plugins/PluginError.swift` | Plugin error cases | -| `TablePro/Core/Plugins/ExportDataSourceAdapter.swift` | Export data source bridge | -| `TablePro/Core/Plugins/ImportDataSinkAdapter.swift` | Import data sink bridge | -| `TablePro/Core/Plugins/SqlFileImportSource.swift` | SQL file import streaming | -| `TablePro/Core/Plugins/Registry/PluginManager+Registry.swift` | Remote plugin download | -| `TablePro/Core/Database/DatabaseDriver.swift` | Internal driver protocol + factory | -| `TablePro/Core/Database/DatabaseManager.swift` | Session lifecycle, health monitoring | -| `TablePro/Core/Database/ConnectionHealthMonitor.swift` | 30s ping, reconnect backoff | -| `TablePro/Core/Services/Query/TableQueryBuilder.swift` | Query construction with plugin dispatch | -| `TablePro/Core/Services/Query/SQLDialectProvider.swift` | Per-dialect keyword/type sets | -| `TablePro/Core/Database/FilterSQLGenerator.swift` | WHERE clause generation | -| `TablePro/Core/ChangeTracking/DataChangeManager.swift` | Change tracking + plugin dispatch | -| `TablePro/Core/ChangeTracking/SQLStatementGenerator.swift` | INSERT/UPDATE/DELETE generation | -| `TablePro/Core/ChangeTracking/DataChangeModels.swift` | RowChange, CellChange types | -| `TablePro/Core/ChangeTracking/AnyChangeManager.swift` | Type-erased change manager | -| `TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift` | ALTER TABLE DDL generation | -| `TablePro/Models/Connection/DatabaseConnection.swift` | DatabaseType enum + DatabaseConnection | -| `TablePro/Models/Connection/ConnectionSession.swift` | Runtime session state | -| `TablePro/Models/Schema/SchemaChange.swift` | Schema change operations | -| `TablePro/Models/Schema/ColumnDefinition.swift` | Editable column definition | - -### Plugin Bundles - -| Plugin | Main Driver File | -| ------------------ | ------------------------------------------------------------- | -| MySQL | `Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift` | -| MySQL (connection) | `Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift` | -| PostgreSQL | `Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift` | -| Redshift | `Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift` | -| SQLite | `Plugins/SQLiteDriverPlugin/SQLitePlugin.swift` | -| ClickHouse | `Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift` | -| MSSQL | `Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift` | -| MongoDB | `Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift` | -| Redis | `Plugins/RedisDriverPlugin/RedisPluginDriver.swift` | -| Oracle | `Plugins/OracleDriverPlugin/OraclePlugin.swift` | -| DuckDB | `Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift` | - -### Documentation - -| File | Content | -| ----------------------------------------------- | ------------------------------------------- | -| `docs/development/plugin-extensibility-plan.md` | 7-phase plan to eliminate ~112 switch sites | -| `docs/development/plugin-settings-tracking.md` | Plugin settings progress tracking | -| `docs/development/architecture.mdx` | High-level architecture overview | - ---- - -## Documentation Accuracy Tracking - -Last audited: 2026-03-11. - -### Outstanding Inaccuracies - -None — all tracked issues resolved. - -### Resolved Inaccuracies - -| Issue | Resolution | Date | -| --------------------------------------------------- | --------------------------------------------- | ---------- | -| ExportFormatPlugin listed `optionsView()` (removed) | Replaced with SettablePlugin protocol section | 2026-03-11 | -| ImportFormatPlugin listed `optionsView()` (removed) | Replaced with SettablePlugin protocol section | 2026-03-11 | -| SettablePlugin.swift missing from File Reference | Added to TableProPluginKit table | 2026-03-11 | -| MSSQL listed cancelQuery/applyQueryTimeout as no-op | Updated: FreeTDS cancel + SET LOCK_TIMEOUT | 2026-03-11 | -| MSSQL comparison matrix showed no-op | Updated to FreeTDS cancel / SET LOCK_TIMEOUT | 2026-03-11 | -| DriverPlugin missing 18 UI/capability properties | Added all properties with types and defaults | 2026-03-12 | -| Transfer Types missing 12 types | Added ConnectionMode, EditorLanguage, GroupingStrategy, export/import types | 2026-03-12 | -| ConnectionField missing FieldType/DropdownOption | Updated with FieldType enum and DropdownOption | 2026-03-12 | -| ExportFormatPlugin missing 4 members | Added defaultTableOptionValues, isTableExportable, currentFileExtension, warnings | 2026-03-12 | -| PluginManager missing 3 state properties | Added pluginInstances, needsRestart, isInstalling | 2026-03-12 | -| DatabaseManager missing 2 state properties | Added connectionListVersion, connectionStatusVersion | 2026-03-12 | -| MongoDB/Redis file count wrong (5 vs 6) | Updated to 6 Swift + C bridge each | 2026-03-12 | -| LOC counts off for Redis/PostgreSQL/MSSQL | Updated: Redis ~1412, PostgreSQL ~1478, MSSQL ~1105 | 2026-03-12 | From 38bae2386351fdcdb68c35661aafe770dd1ff5ef Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 16:57:24 +0700 Subject: [PATCH 12/15] refactor: remove database-specific DML switches from SQLStatementGenerator --- .../ChangeTracking/DataChangeManager.swift | 8 +-- .../SQLStatementGenerator.swift | 71 +++---------------- 2 files changed, 12 insertions(+), 67 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 79667ca4..ad576634 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -684,9 +684,7 @@ final class DataChangeManager { ) let expectedUpdates = changes.count(where: { $0.type == .update }) - let actualUpdates = statements.count(where: { - $0.sql.hasPrefix("UPDATE") || $0.sql.hasPrefix("ALTER TABLE") - }) + let actualUpdates = statements.count(where: { $0.sql.hasPrefix("UPDATE") }) if expectedUpdates > 0 && actualUpdates < expectedUpdates { throw DatabaseError.queryFailed( @@ -696,9 +694,7 @@ final class DataChangeManager { } let expectedDeletes = changes.count(where: { $0.type == .delete && deletedRowIndices.contains($0.rowIndex) }) - let actualDeletes = statements.count(where: { - $0.sql.hasPrefix("DELETE") || $0.sql.contains(" DELETE ") - }) + let actualDeletes = statements.count(where: { $0.sql.hasPrefix("DELETE") }) if expectedDeletes > 0 && actualDeletes < expectedDeletes { throw DatabaseError.queryFailed( diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index b659dbb1..7063ac25 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -66,8 +66,7 @@ struct SQLStatementGenerator { } } - // Generate individual UPDATE statements with LIMIT 1 (safer than batched CASE/WHEN) - // This prevents accidentally updating multiple rows with the same value + // Generate individual UPDATE statements (safer than batched CASE/WHEN) if !updateChanges.isEmpty { for change in updateChanges { if let stmt = generateUpdateSQL(for: change) { @@ -95,13 +94,12 @@ struct SQLStatementGenerator { return statements } - /// Get placeholder syntax for the database type private func placeholder(at index: Int) -> String { switch databaseType { case .postgresql, .redshift, .duckdb: - return "$\(index + 1)" // PostgreSQL/DuckDB uses $1, $2, etc. - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: - return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, Oracle, and ClickHouse use ? + return "$\(index + 1)" + default: + return "?" } } @@ -259,16 +257,8 @@ struct SQLStatementGenerator { parameters.append(pkValue) let whereClause = "\(databaseType.quoteIdentifier(pkColumn)) = \(placeholder(at: parameters.count - 1))" - let sql: String - if databaseType == .clickhouse { - sql = - "ALTER TABLE \(databaseType.quoteIdentifier(tableName)) UPDATE \(setClauses) WHERE \(whereClause)" - } else { - let limitClause = - (databaseType == .mysql || databaseType == .mariadb) ? " LIMIT 1" : "" - sql = - "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)\(limitClause)" - } + let sql = + "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } else { guard let originalRow = change.originalRow else { @@ -294,25 +284,8 @@ struct SQLStatementGenerator { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - - let sql: String - switch databaseType { - case .mysql, .mariadb, .sqlite: - sql = - "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) LIMIT 1" - case .mssql: - sql = - "UPDATE TOP (1) \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" - case .oracle: - sql = - "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" - case .clickhouse: - sql = - "ALTER TABLE \(databaseType.quoteIdentifier(tableName)) UPDATE \(setClauses) WHERE \(whereClause)" - case .postgresql, .redshift, .duckdb, .mongodb, .redis: - sql = - "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" - } + let sql = + "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } @@ -344,15 +317,8 @@ struct SQLStatementGenerator { guard !conditions.isEmpty else { return nil } - // Combine all conditions with OR let whereClause = conditions.joined(separator: " OR ") - let sql: String - if databaseType == .clickhouse { - sql = - "ALTER TABLE \(databaseType.quoteIdentifier(tableName)) DELETE WHERE \(whereClause)" - } else { - sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" - } + let sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } @@ -386,24 +352,7 @@ struct SQLStatementGenerator { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - - let sql: String - switch databaseType { - case .mysql, .mariadb, .sqlite: - sql = - "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) LIMIT 1" - case .mssql: - sql = - "DELETE TOP (1) FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" - case .oracle: - sql = - "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) AND ROWNUM = 1" - case .clickhouse: - sql = - "ALTER TABLE \(databaseType.quoteIdentifier(tableName)) DELETE WHERE \(whereClause)" - case .postgresql, .redshift, .duckdb, .mongodb, .redis: - sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" - } + let sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } From d949d0857308277dc3a5604bb768c6fea815c06c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 18:13:44 +0700 Subject: [PATCH 13/15] refactor: remove all DatabaseType fallback switches for clean plugin-only architecture --- TablePro/Core/Database/DatabaseManager.swift | 7 +- .../SchemaStatementGenerator.swift | 582 +++-------------- .../Services/Query/SQLDialectProvider.swift | 505 +-------------- .../Services/Query/TableQueryBuilder.swift | 176 +----- .../MainContentCoordinator+MongoDB.swift | 63 -- .../MainContentCoordinator+Redis.swift | 16 - ...inContentCoordinator+TableOperations.swift | 109 +--- .../Views/Main/MainContentCoordinator.swift | 23 +- .../Views/Structure/TableStructureView.swift | 8 +- .../ClickHouse/ClickHouseDialectTests.swift | 99 +-- .../Plugins/SQLDialectDescriptorTests.swift | 22 - .../SchemaStatementGeneratorMSSQLTests.swift | 263 -------- .../SchemaStatementGeneratorPluginTests.swift | 259 ++++++-- .../SchemaStatementGeneratorTests.swift | 586 ------------------ 14 files changed, 326 insertions(+), 2392 deletions(-) delete mode 100644 TableProTests/Core/SchemaTracking/SchemaStatementGeneratorMSSQLTests.swift delete mode 100644 TableProTests/Core/SchemaTracking/SchemaStatementGeneratorTests.swift diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 3d2cc8de..45f096a4 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -687,13 +687,12 @@ final class DatabaseManager { driver: driver ) - // Extract plugin driver for DDL delegation (nil for non-plugin drivers) - let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver + guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { + throw DatabaseError.unsupportedOperation + } - // Generate SQL statements let generator = SchemaStatementGenerator( tableName: tableName, - databaseType: databaseType, primaryKeyConstraintName: pkConstraintName, pluginDriver: resolvedPluginDriver ) diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index f10aece4..39261715 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -3,7 +3,7 @@ // TablePro // // Generates ALTER TABLE SQL statements from schema changes. -// Supports MySQL, PostgreSQL, and SQLite with database-specific syntax. +// Delegates all DDL generation to the plugin driver. // import Foundation @@ -16,28 +16,23 @@ struct SchemaStatement { let isDestructive: Bool } -/// Generates SQL statements for schema modifications +/// Generates SQL statements for schema modifications by delegating to the plugin driver. struct SchemaStatementGenerator { - private let databaseType: DatabaseType private let tableName: String /// Actual primary key constraint name (queried from database). - /// Used by PostgreSQL which requires the constraint name for DROP CONSTRAINT. - /// Falls back to `{table}_pkey` convention if nil. + /// Passed to plugin for databases that require it (e.g. PostgreSQL DROP CONSTRAINT). private let primaryKeyConstraintName: String? - /// Optional plugin driver for database-specific DDL generation. - /// When non-nil, plugin methods are tried first; nil results fall back to built-in logic. - private let pluginDriver: (any PluginDatabaseDriver)? + /// Plugin driver for database-specific DDL generation. + private let pluginDriver: any PluginDatabaseDriver init( tableName: String, - databaseType: DatabaseType, primaryKeyConstraintName: String? = nil, - pluginDriver: (any PluginDatabaseDriver)? = nil + pluginDriver: any PluginDatabaseDriver ) { self.tableName = tableName - self.databaseType = databaseType self.primaryKeyConstraintName = primaryKeyConstraintName self.pluginDriver = pluginDriver } @@ -46,12 +41,12 @@ struct SchemaStatementGenerator { func generate(changes: [SchemaChange]) throws -> [SchemaStatement] { var statements: [SchemaStatement] = [] - // Sort changes by dependency order let sortedChanges = sortByDependency(changes) for change in sortedChanges { - let stmt = try generateStatement(for: change) - // Ensure every statement ends with a semicolon + guard let stmt = try generateStatement(for: change) else { + continue + } let sql = stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";" statements.append(SchemaStatement(sql: sql, description: stmt.description, isDestructive: stmt.isDestructive)) } @@ -71,8 +66,8 @@ struct SchemaStatementGenerator { // 6. Add indexes // 7. Add foreign keys - var fkDeletes: [SchemaChange] = [] // Includes modifyForeignKey (drop+recreate) - var indexDeletes: [SchemaChange] = [] // Includes modifyIndex (drop+recreate) + var fkDeletes: [SchemaChange] = [] + var indexDeletes: [SchemaChange] = [] var columnDeletes: [SchemaChange] = [] var columnModifies: [SchemaChange] = [] var columnAdds: [SchemaChange] = [] @@ -83,10 +78,8 @@ struct SchemaStatementGenerator { for change in changes { switch change { case .deleteForeignKey, .modifyForeignKey: - // Modify FK is handled as drop+recreate, so group with deletes fkDeletes.append(change) case .deleteIndex, .modifyIndex: - // Modify index is handled as drop+recreate, so group with deletes indexDeletes.append(change) case .deleteColumn: columnDeletes.append(change) @@ -108,259 +101,48 @@ struct SchemaStatementGenerator { // MARK: - Statement Generation - private func generateStatement(for change: SchemaChange) throws -> SchemaStatement { + private func generateStatement(for change: SchemaChange) throws -> SchemaStatement? { switch change { case .addColumn(let column): - return try generateAddColumn(column) + return generateAddColumn(column) case .modifyColumn(let old, let new): - return try generateModifyColumn(old: old, new: new) + return generateModifyColumn(old: old, new: new) case .deleteColumn(let column): return generateDeleteColumn(column) case .addIndex(let index): - return try generateAddIndex(index) + return generateAddIndex(index) case .modifyIndex(let old, let new): - return try generateModifyIndex(old: old, new: new) + return generateModifyIndex(old: old, new: new) case .deleteIndex(let index): return generateDeleteIndex(index) case .addForeignKey(let fk): - return try generateAddForeignKey(fk) + return generateAddForeignKey(fk) case .modifyForeignKey(let old, let new): - return try generateModifyForeignKey(old: old, new: new) + return generateModifyForeignKey(old: old, new: new) case .deleteForeignKey(let fk): - return try generateDeleteForeignKey(fk) + return generateDeleteForeignKey(fk) case .modifyPrimaryKey(let old, let new): - return try generateModifyPrimaryKey(old: old, new: new) + return generateModifyPrimaryKey(old: old, new: new) } } // MARK: - Column Operations - private func generateAddColumn(_ column: EditableColumnDefinition) throws -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: toPluginColumnDefinition(column)) { - return SchemaStatement(sql: sql, description: "Add column '\(column.name)'", isDestructive: false) + private func generateAddColumn(_ column: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: toPluginColumnDefinition(column)) else { + return nil } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let columnDef = try buildEditableColumnDefinition(column) - - let keyword = (databaseType == .mssql || databaseType == .oracle) ? "ADD" : "ADD COLUMN" - let sql = "ALTER TABLE \(tableQuoted) \(keyword) \(columnDef)" - return SchemaStatement( - sql: sql, - description: "Add column '\(column.name)'", - isDestructive: false - ) + return SchemaStatement(sql: sql, description: "Add column '\(column.name)'", isDestructive: false) } - private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) throws -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateModifyColumnSQL( - table: tableName, - oldColumn: toPluginColumnDefinition(old), - newColumn: toPluginColumnDefinition(new) - ) { - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) + private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateModifyColumnSQL( + table: tableName, + oldColumn: toPluginColumnDefinition(old), + newColumn: toPluginColumnDefinition(new) + ) else { + return nil } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - - switch databaseType { - case .mysql, .mariadb: - let columnDef = try buildEditableColumnDefinition(new) - let sql: String - if old.name != new.name { - // CHANGE COLUMN is required for renames: ALTER TABLE t CHANGE COLUMN old_name new_name definition - let oldQuoted = databaseType.quoteIdentifier(old.name) - sql = "ALTER TABLE \(tableQuoted) CHANGE COLUMN \(oldQuoted) \(columnDef)" - } else { - // MODIFY COLUMN when name is unchanged: ALTER TABLE t MODIFY COLUMN col definition - sql = "ALTER TABLE \(tableQuoted) MODIFY COLUMN \(columnDef)" - } - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - - case .postgresql, .redshift: - // PostgreSQL: Multiple ALTER COLUMN statements - var statements: [String] = [] - let oldQuoted = databaseType.quoteIdentifier(old.name) - let newQuoted = databaseType.quoteIdentifier(new.name) - - // Rename if needed - if old.name != new.name { - statements.append("ALTER TABLE \(tableQuoted) RENAME COLUMN \(oldQuoted) TO \(newQuoted)") - } - - // Change type if needed - if old.dataType != new.dataType { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) TYPE \(new.dataType)") - } - - // Change nullable if needed - if old.isNullable != new.isNullable { - let constraint = new.isNullable ? "DROP NOT NULL" : "SET NOT NULL" - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) \(constraint)") - } - - // Change default if needed - if old.defaultValue != new.defaultValue { - if let defaultVal = new.defaultValue, !defaultVal.isEmpty { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) SET DEFAULT \(defaultVal)") - } else { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) DROP DEFAULT") - } - } - - let sql = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n") - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - - case .mssql: - var statements: [String] = [] - let newQuoted = databaseType.quoteIdentifier(new.name) - - if old.name != new.name { - let tableAndOld = "\(tableName).\(old.name)" - statements.append("EXEC sp_rename '\(tableAndOld)', '\(new.name)', 'COLUMN'") - } - - if old.dataType != new.dataType || old.isNullable != new.isNullable { - let nullClause = new.isNullable ? "NULL" : "NOT NULL" - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) \(new.dataType) \(nullClause)") - } - - let sql = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n") - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - - case .oracle: - var statements: [String] = [] - let newQuoted = databaseType.quoteIdentifier(new.name) - - if old.name != new.name { - let oldQuoted = databaseType.quoteIdentifier(old.name) - statements.append("ALTER TABLE \(tableQuoted) RENAME COLUMN \(oldQuoted) TO \(newQuoted)") - } - - if old.dataType != new.dataType || old.isNullable != new.isNullable { - let nullClause = new.isNullable ? "NULL" : "NOT NULL" - statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) \(new.dataType) \(nullClause))") - } - - if old.defaultValue != new.defaultValue { - if let defaultVal = new.defaultValue, !defaultVal.isEmpty { - statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) DEFAULT \(defaultVal))") - } else { - statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) DEFAULT NULL)") - } - } - - let sql = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n") - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - - case .clickhouse: - return generateModifyColumnClickHouse(old: old, new: new, tableQuoted: tableQuoted) - - case .duckdb: - // DuckDB: Multiple ALTER COLUMN statements (like PostgreSQL) - var statements: [String] = [] - let oldQuoted = databaseType.quoteIdentifier(old.name) - let newQuoted = databaseType.quoteIdentifier(new.name) - - if old.name != new.name { - statements.append("ALTER TABLE \(tableQuoted) RENAME COLUMN \(oldQuoted) TO \(newQuoted)") - } - - if old.dataType != new.dataType { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) TYPE \(new.dataType)") - } - - if old.isNullable != new.isNullable { - let constraint = new.isNullable ? "DROP NOT NULL" : "SET NOT NULL" - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) \(constraint)") - } - - if old.defaultValue != new.defaultValue { - if let defaultVal = new.defaultValue, !defaultVal.isEmpty { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) SET DEFAULT \(defaultVal)") - } else { - statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) DROP DEFAULT") - } - } - - let sql = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n") - return SchemaStatement( - sql: sql, - description: "Modify column '\(old.name)' to '\(new.name)'", - isDestructive: old.dataType != new.dataType - ) - - case .sqlite, .mongodb, .redis: - // SQLite doesn't support ALTER COLUMN - requires table recreation - // MongoDB/Redis don't use SQL ALTER TABLE - throw DatabaseError.unsupportedOperation - } - } - - private func generateModifyColumnClickHouse( - old: EditableColumnDefinition, - new: EditableColumnDefinition, - tableQuoted: String - ) -> SchemaStatement { - // ClickHouse HTTP interface doesn't support multi-statement queries, - // so we combine changes into a single ALTER TABLE with comma-separated actions - var actions: [String] = [] - let newQuoted = databaseType.quoteIdentifier(new.name) - - if old.name != new.name { - let oldQuoted = databaseType.quoteIdentifier(old.name) - actions.append("RENAME COLUMN \(oldQuoted) TO \(newQuoted)") - } - - if old.dataType != new.dataType || old.isNullable != new.isNullable { - let nullableType = new.isNullable ? "Nullable(\(new.dataType))" : new.dataType - actions.append("MODIFY COLUMN \(newQuoted) \(nullableType)") - } - - if old.defaultValue != new.defaultValue { - if let defaultVal = new.defaultValue, !defaultVal.isEmpty { - actions.append("MODIFY COLUMN \(newQuoted) DEFAULT \(defaultVal)") - } else { - actions.append("MODIFY COLUMN \(newQuoted) REMOVE DEFAULT") - } - } - - if old.comment != new.comment { - if let comment = new.comment, !comment.isEmpty { - let escaped = comment.replacingOccurrences(of: "'", with: "''") - actions.append("COMMENT COLUMN \(newQuoted) '\(escaped)'") - } else { - actions.append("COMMENT COLUMN \(newQuoted) ''") - } - } - - guard !actions.isEmpty else { - return SchemaStatement(sql: "", description: "No changes", isDestructive: false) - } - - let sql = "ALTER TABLE \(tableQuoted) " + actions.joined(separator: ", ") return SchemaStatement( sql: sql, description: "Modify column '\(old.name)' to '\(new.name)'", @@ -368,139 +150,28 @@ struct SchemaStatementGenerator { ) } - private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateDropColumnSQL(table: tableName, columnName: column.name) { - return SchemaStatement(sql: sql, description: "Drop column '\(column.name)'", isDestructive: true) - } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let columnQuoted = databaseType.quoteIdentifier(column.name) - - let sql = "ALTER TABLE \(tableQuoted) DROP COLUMN \(columnQuoted)" - return SchemaStatement( - sql: sql, - description: "Drop column '\(column.name)'", - isDestructive: true - ) - } - - // MARK: - Column Definition Builder - - private func buildEditableColumnDefinition(_ column: EditableColumnDefinition) throws -> String { - var parts: [String] = [] - - parts.append(databaseType.quoteIdentifier(column.name)) - - // ClickHouse uses Nullable(Type) wrapper instead of NOT NULL keyword - if databaseType == .clickhouse { - parts.append(column.isNullable ? "Nullable(\(column.dataType))" : column.dataType) - } else { - parts.append(column.dataType) - - // Unsigned (MySQL/MariaDB only) - if (databaseType == .mysql || databaseType == .mariadb) && column.unsigned { - parts.append("UNSIGNED") - } - - // Nullable - if !column.isNullable { - parts.append("NOT NULL") - } - } - - // Default value - if let defaultValue = column.defaultValue, !defaultValue.isEmpty { - parts.append("DEFAULT \(defaultValue)") - } - - // Auto increment - if column.autoIncrement { - switch databaseType { - case .mysql, .mariadb: - parts.append("AUTO_INCREMENT") - case .postgresql, .redshift: - // PostgreSQL uses SERIAL or IDENTITY - // For simplicity, we'll use SERIAL - parts[1] = "SERIAL" - case .sqlite: - parts.append("AUTOINCREMENT") - case .duckdb: - break // DuckDB has no auto-increment - case .mongodb, .redis, .clickhouse: - break // MongoDB/Redis auto-generate IDs; ClickHouse has no auto-increment - case .mssql: - parts[1] = "INT IDENTITY(1,1)" - case .oracle: - parts.append("GENERATED ALWAYS AS IDENTITY") - } - } - - // On update (MySQL/MariaDB only for timestamp columns) - if databaseType == .mysql || databaseType == .mariadb, - let onUpdate = column.onUpdate, !onUpdate.isEmpty { - parts.append("ON UPDATE \(onUpdate)") - } - - // Comment - if let comment = column.comment, !comment.isEmpty { - switch databaseType { - case .mysql, .mariadb, .clickhouse: - let escapedComment = comment.replacingOccurrences(of: "'", with: "''") - parts.append("COMMENT '\(escapedComment)'") - case .postgresql, .redshift: - // PostgreSQL comments are set via separate COMMENT statement - break - case .sqlite, .mongodb, .redis, .mssql, .oracle, .duckdb: - // SQLite/MongoDB/Redis/MSSQL/Oracle/DuckDB don't support inline column comments - break - } + private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateDropColumnSQL(table: tableName, columnName: column.name) else { + return nil } - - return parts.joined(separator: " ") + return SchemaStatement(sql: sql, description: "Drop column '\(column.name)'", isDestructive: true) } // MARK: - Index Operations - private func generateAddIndex(_ index: EditableIndexDefinition) throws -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(index)) { - return SchemaStatement(sql: sql, description: "Add index '\(index.name)'", isDestructive: false) - } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let indexQuoted = databaseType.quoteIdentifier(index.name) - let columnsQuoted = index.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") - - let uniqueKeyword = index.isUnique ? "UNIQUE " : "" - - let sql: String - switch databaseType { - case .mysql, .mariadb: - let indexType = index.type.rawValue - sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted)) USING \(indexType)" - - case .postgresql, .redshift, .duckdb: - let indexTypeClause = index.type == .btree ? "" : "USING \(index.type.rawValue)" - sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) \(indexTypeClause) (\(columnsQuoted))" - - case .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse: - sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted))" + private func generateAddIndex(_ index: EditableIndexDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(index)) else { + return nil } - - return SchemaStatement( - sql: sql, - description: "Add index '\(index.name)'", - isDestructive: false - ) + return SchemaStatement(sql: sql, description: "Add index '\(index.name)'", isDestructive: false) } - private func generateModifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) throws -> SchemaStatement { - // All databases require drop + recreate for index modification - let dropStmt = generateDeleteIndex(old) - let addStmt = try generateAddIndex(new) - - let sql = "\(dropStmt.sql);\n\(addStmt.sql);" + private func generateModifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) -> SchemaStatement? { + guard let dropSql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: old.name), + let addSql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(new)) else { + return nil + } + let sql = "\(dropSql);\n\(addSql);" return SchemaStatement( sql: sql, description: "Modify index '\(old.name)' to '\(new.name)'", @@ -508,73 +179,31 @@ struct SchemaStatementGenerator { ) } - private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: index.name) { - return SchemaStatement(sql: sql, description: "Drop index '\(index.name)'", isDestructive: false) - } - - let indexQuoted = databaseType.quoteIdentifier(index.name) - - let sql: String - switch databaseType { - case .mysql, .mariadb, .clickhouse: - let tableQuoted = databaseType.quoteIdentifier(tableName) - sql = "DROP INDEX \(indexQuoted) ON \(tableQuoted)" - - case .postgresql, .redshift, .sqlite, .mongodb, .redis, .oracle, .duckdb: - sql = "DROP INDEX \(indexQuoted)" - case .mssql: - let tableQuoted = databaseType.quoteIdentifier(tableName) - sql = "DROP INDEX \(indexQuoted) ON \(tableQuoted)" + private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: index.name) else { + return nil } - - return SchemaStatement( - sql: sql, - description: "Drop index '\(index.name)'", - isDestructive: false - ) + return SchemaStatement(sql: sql, description: "Drop index '\(index.name)'", isDestructive: false) } // MARK: - Foreign Key Operations - private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateAddForeignKeySQL( - table: tableName, - fk: toPluginForeignKeyDefinition(fk) - ) { - return SchemaStatement(sql: sql, description: "Add foreign key '\(fk.name)'", isDestructive: false) + private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateAddForeignKeySQL( + table: tableName, + fk: toPluginForeignKeyDefinition(fk) + ) else { + return nil } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let fkQuoted = databaseType.quoteIdentifier(fk.name) - let columnsQuoted = fk.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") - let refTableQuoted = databaseType.quoteIdentifier(fk.referencedTable) - let refColumnsQuoted = fk.referencedColumns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") - - let sql = """ - ALTER TABLE \(tableQuoted) - ADD CONSTRAINT \(fkQuoted) - FOREIGN KEY (\(columnsQuoted)) - REFERENCES \(refTableQuoted) (\(refColumnsQuoted)) - ON DELETE \(fk.onDelete.rawValue) - ON UPDATE \(fk.onUpdate.rawValue) - """ - - return SchemaStatement( - sql: sql, - description: "Add foreign key '\(fk.name)'", - isDestructive: false - ) + return SchemaStatement(sql: sql, description: "Add foreign key '\(fk.name)'", isDestructive: false) } - private func generateModifyForeignKey(old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) throws -> SchemaStatement { - // Modifying FK requires drop + recreate - let dropStmt = try generateDeleteForeignKey(old) - let addStmt = try generateAddForeignKey(new) - - let sql = "\(dropStmt.sql);\n\(addStmt.sql);" + private func generateModifyForeignKey(old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) -> SchemaStatement? { + guard let dropSql = pluginDriver.generateDropForeignKeySQL(table: tableName, constraintName: old.name), + let addSql = pluginDriver.generateAddForeignKeySQL(table: tableName, fk: toPluginForeignKeyDefinition(new)) else { + return nil + } + let sql = "\(dropSql);\n\(addSql);" return SchemaStatement( sql: sql, description: "Modify foreign key '\(old.name)' to '\(new.name)'", @@ -582,89 +211,24 @@ struct SchemaStatementGenerator { ) } - private func generateDeleteForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { - if let pluginDriver, - let sql = pluginDriver.generateDropForeignKeySQL(table: tableName, constraintName: fk.name) { - return SchemaStatement(sql: sql, description: "Drop foreign key '\(fk.name)'", isDestructive: false) - } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let fkQuoted = databaseType.quoteIdentifier(fk.name) - - let sql: String - switch databaseType { - case .mysql, .mariadb: - sql = "ALTER TABLE \(tableQuoted) DROP FOREIGN KEY \(fkQuoted)" - - case .postgresql, .redshift, .mssql, .oracle, .duckdb: - sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" - case .sqlite, .mongodb, .redis, .clickhouse: - throw DatabaseError.unsupportedOperation + private func generateDeleteForeignKey(_ fk: EditableForeignKeyDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateDropForeignKeySQL(table: tableName, constraintName: fk.name) else { + return nil } - return SchemaStatement( - sql: sql, - description: "Drop foreign key '\(fk.name)'", - isDestructive: false - ) + return SchemaStatement(sql: sql, description: "Drop foreign key '\(fk.name)'", isDestructive: false) } // MARK: - Primary Key Operations - private func generateModifyPrimaryKey(old: [String], new: [String]) throws -> SchemaStatement { - if let pluginDriver, - let sqls = pluginDriver.generateModifyPrimaryKeySQL( - table: tableName, oldColumns: old, newColumns: new - ) { - let joined = sqls.joined(separator: ";\n") - return SchemaStatement( - sql: joined, - description: "Modify primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]", - isDestructive: true - ) + private func generateModifyPrimaryKey(old: [String], new: [String]) -> SchemaStatement? { + guard let sqls = pluginDriver.generateModifyPrimaryKeySQL( + table: tableName, oldColumns: old, newColumns: new + ) else { + return nil } - - let tableQuoted = databaseType.quoteIdentifier(tableName) - let newColumnsQuoted = new.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") - - let sql: String - switch databaseType { - case .mysql, .mariadb: - sql = """ - ALTER TABLE \(tableQuoted) DROP PRIMARY KEY; - ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); - """ - - case .postgresql, .redshift, .duckdb: - // Use actual constraint name if available, otherwise fall back to convention - let pkName = primaryKeyConstraintName ?? "\(tableName)_pkey" - sql = """ - ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); - ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); - """ - - case .mssql: - let pkName = primaryKeyConstraintName ?? "PK_\(tableName)" - sql = """ - ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); - ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); - """ - - case .oracle: - let pkName = primaryKeyConstraintName ?? "PK_\(tableName)" - sql = """ - ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); - ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); - """ - - case .sqlite, .mongodb, .redis, .clickhouse: - // SQLite doesn't support modifying primary keys - requires table recreation - // MongoDB/Redis don't use SQL ALTER TABLE - // ClickHouse primary keys are defined at table creation and cannot be modified - throw DatabaseError.unsupportedOperation - } - + let joined = sqls.joined(separator: ";\n") return SchemaStatement( - sql: sql, + sql: joined, description: "Modify primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]", isDestructive: true ) diff --git a/TablePro/Core/Services/Query/SQLDialectProvider.swift b/TablePro/Core/Services/Query/SQLDialectProvider.swift index 07b0b01d..dcaa8554 100644 --- a/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -8,472 +8,6 @@ import Foundation import TableProPluginKit -// MARK: - MySQL/MariaDB Dialect - -struct MySQLDialect: SQLDialectProvider { - let identifierQuote = "`" - - let keywords: Set = [ - // Core DML keywords - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - // DDL keywords - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", - - // Data types - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - - // Control flow - "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", - - // Set operations - "UNION", "INTERSECT", "EXCEPT", - - // MySQL-specific - "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", - "SHOW", "DESCRIBE", "DESC", "EXPLAIN" - ] - - let functions: Set = [ - // Aggregate - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", - - // String - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - - // Date/Time - "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", - "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", - - // Math - "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", - - // Conversion - "CAST", "CONVERT" - ] - - let dataTypes: Set = [ - // Integer types - "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - - // Decimal types - "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", - - // String types - "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", - "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", - - // Date/Time types - "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", - - // Other types - "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" - ] -} - -// MARK: - PostgreSQL Dialect - -struct PostgreSQLDialect: SQLDialectProvider { - let identifierQuote = "\"" - - let keywords: Set = [ - // Core DML keywords - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - // DDL keywords - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - - // Data attributes - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - - // Control flow - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", - - // Set operations - "UNION", "INTERSECT", "EXCEPT", - - // PostgreSQL-specific - "RETURNING", "WITH", "RECURSIVE", "AS", "MATERIALIZED", - "EXPLAIN", "ANALYZE", "VERBOSE", - "WINDOW", "OVER", "PARTITION", - "LATERAL", "ORDINALITY" - ] - - let functions: Set = [ - // Aggregate - "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", - - // String - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", - - // Date/Time - "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", - - // Math - "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", - - // Conversion - "CAST", "TO_NUMBER", "TO_TIMESTAMP", - - // JSON - "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" - ] - - let dataTypes: Set = [ - // Integer types - "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", - - // Decimal types - "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", - - // String types - "CHAR", "CHARACTER", "VARCHAR", "TEXT", - - // Date/Time types - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", - - // Other types - "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" - ] -} - -// MARK: - SQLite Dialect - -struct SQLiteDialect: SQLDialectProvider { - let identifierQuote = "`" - - let keywords: Set = [ - // Core DML keywords - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - // DDL keywords - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", - - // Data attributes - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", - - // Control flow - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", - - // Set operations - "UNION", "INTERSECT", "EXCEPT", - - // SQLite-specific - "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", - "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", - "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" - ] - - let functions: Set = [ - // Aggregate - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", - - // String - "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", - "REPLACE", "INSTR", "PRINTF", - - // Date/Time - "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", - - // Math - "ABS", "ROUND", "RANDOM", "MIN", "MAX", - - // Conversion - "CAST", "TYPEOF", - - // Other - "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" - ] - - let dataTypes: Set = [ - // SQLite's storage classes - "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", - - // Type affinities - "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "UNSIGNED", "BIG", "INT2", "INT8", - "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", - "NVARCHAR", "CLOB", - "DOUBLE", "PRECISION", "FLOAT", - "DECIMAL", "BOOLEAN", "DATE", "DATETIME" - ] -} - -// MARK: - MSSQL Dialect - -struct MSSQLDialect: SQLDialectProvider { - let identifierQuote = "[" - - let keywords: Set = [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "TOP", "OFFSET", "FETCH", "NEXT", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", "EXEC", - - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "IDENTITY", "NOLOCK", "WITH", "ROWCOUNT", "NEWID", - - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "IIF", - - "UNION", "INTERSECT", "EXCEPT", - - "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", - "PRINT", "GO", "EXEC", "EXECUTE", - "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", - "RETURNING", "OUTPUT", "INSERTED", "DELETED" - ] - - let functions: Set = [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", - - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LEN", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "CHARINDEX", "PATINDEX", - "STUFF", "FORMAT", - - "GETDATE", "GETUTCDATE", "SYSDATETIME", "CURRENT_TIMESTAMP", - "DATEADD", "DATEDIFF", "DATENAME", "DATEPART", - "CONVERT", "CAST", "FORMAT", - - "ROUND", "CEILING", "FLOOR", "ABS", "POWER", "SQRT", "RAND", - - "ISNULL", "ISNUMERIC", "ISDATE", "COALESCE", "NEWID", - "OBJECT_ID", "OBJECT_NAME", "SCHEMA_NAME", "DB_NAME", - "SCOPE_IDENTITY", "@@IDENTITY", "@@ROWCOUNT" - ] - - let dataTypes: Set = [ - "INT", "INTEGER", "TINYINT", "SMALLINT", "BIGINT", - - "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", - - "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT", - - "BINARY", "VARBINARY", "IMAGE", - - "DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", - - "BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", - "ROWVERSION", "TIMESTAMP", "HIERARCHYID" - ] -} - -// MARK: - Oracle Dialect - -struct OracleDialect: SQLDialectProvider { - let identifierQuote = "\"" - - let keywords: Set = [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "FETCH", "FIRST", "ROWS", "ONLY", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", - - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", - - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "DECODE", - - "UNION", "INTERSECT", "MINUS", - - "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", - "EXECUTE", "IMMEDIATE", - "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", - "RETURNING", "CONNECT", "LEVEL", "START", "WITH", "PRIOR", - "ROWNUM", "ROWID", "DUAL", "SYSDATE", "SYSTIMESTAMP" - ] - - let functions: Set = [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", - - "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", - "INITCAP", "TRANSLATE", - - "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", - "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", - "EXTRACT", "TO_DATE", "TO_CHAR", "TO_NUMBER", "TO_TIMESTAMP", - "TRUNC", "ROUND", - - "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", "SIGN", - - "NVL", "NVL2", "DECODE", "COALESCE", "NULLIF", - "GREATEST", "LEAST", "CAST", - "SYS_GUID", "DBMS_RANDOM.VALUE", "USER", "SYS_CONTEXT" - ] - - let dataTypes: Set = [ - "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", - - "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", - - "BLOB", "RAW", "LONG RAW", "BFILE", - - "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", - "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", - - "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" - ] -} - -// MARK: - ClickHouse Dialect - -struct ClickHouseDialect: SQLDialectProvider { - let identifierQuote = "`" - - let keywords: Set = [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", - - "UNION", "INTERSECT", "EXCEPT", - - // ClickHouse-specific - "FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS", - "OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC", - "MATERIALIZED", "WITH" - ] - - let functions: Set = [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - - "NOW", "TODAY", "YESTERDAY", - "CAST", - - // ClickHouse-specific - "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", - "TOSTRING", "TOINT32", "FORMATDATETIME", - "IF", "MULTIIF", - "ARRAYMAP", "ARRAYJOIN", - "MATCH", "CURRENTDATABASE", "VERSION", - "QUANTILE", "TOPK" - ] - - let dataTypes: Set = [ - "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", - "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256", - "FLOAT32", "FLOAT64", - "DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256", - "STRING", "FIXEDSTRING", "UUID", - "DATE", "DATE32", "DATETIME", "DATETIME64", - "ARRAY", "TUPLE", "MAP", - "NULLABLE", "LOWCARDINALITY", - "ENUM8", "ENUM16", - "IPV4", "IPV6", - "JSON", "BOOL" - ] -} - -// MARK: - DuckDB Dialect - -struct DuckDBDialect: SQLDialectProvider { - let identifierQuote = "\"" - - let keywords: Set = [ - // Core DML keywords - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - - // DDL keywords - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - - // Data attributes - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - - // Control flow - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", - - // Set operations - "UNION", "INTERSECT", "EXCEPT", - - // DuckDB-specific - "COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT", - "QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING", - "INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH", - "EXPORT", "IMPORT", - "WITH", "RECURSIVE", "MATERIALIZED", - "EXPLAIN", "ANALYZE", - "WINDOW", "OVER", "PARTITION" - ] - - let functions: Set = [ - // Aggregate - "COUNT", "SUM", "AVG", "MAX", "MIN", - "LIST_AGG", "STRING_AGG", "ARRAY_AGG", - - // String - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", - - // Date/Time - "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", - "EPOCH_MS", - - // Math - "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", - - // Conversion - "CAST", - - // DuckDB-specific - "REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON", - "GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST", - "GENERATE_SERIES", "RANGE" - ] - - let dataTypes: Set = [ - "INTEGER", "BIGINT", "HUGEINT", "UHUGEINT", - "DOUBLE", "FLOAT", "DECIMAL", - "VARCHAR", "TEXT", "BLOB", - "BOOLEAN", - "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL", - "UUID", "JSON", - "LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT" - ] -} - // MARK: - Plugin Dialect Adapter struct PluginDialectAdapter: SQLDialectProvider { @@ -490,45 +24,28 @@ struct PluginDialectAdapter: SQLDialectProvider { } } +// MARK: - Empty Dialect + +private struct EmptyDialect: SQLDialectProvider { + let identifierQuote = "\"" + let keywords: Set = [] + let functions: Set = [] + let dataTypes: Set = [] +} + // MARK: - Dialect Factory struct SQLDialectFactory { - /// Create a dialect provider for the given database type. - /// Prefers plugin-provided dialect info, falling back to built-in dialect structs. static func createDialect(for databaseType: DatabaseType) -> SQLDialectProvider { if Thread.isMainThread { return MainActor.assumeIsolated { if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { return PluginDialectAdapter(descriptor: descriptor) } - return builtInDialect(for: databaseType) + return EmptyDialect() } } - return builtInDialect(for: databaseType) - } - - /// Built-in fallback dialects when plugins are unavailable or off the main actor - static func builtInDialect(for databaseType: DatabaseType) -> SQLDialectProvider { - switch databaseType { - case .mysql, .mariadb: - return MySQLDialect() - case .postgresql, .redshift: - return PostgreSQLDialect() - case .sqlite: - return SQLiteDialect() - case .mongodb: - return SQLiteDialect() // Placeholder until MongoDB dialect is implemented - case .redis: - return SQLiteDialect() // Placeholder until Redis dialect is implemented - case .mssql: - return MSSQLDialect() - case .oracle: - return OracleDialect() - case .clickhouse: - return ClickHouseDialect() - case .duckdb: - return DuckDBDialect() - } + return EmptyDialect() } } diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index cf4c917d..c82a1254 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -29,14 +29,6 @@ struct TableQueryBuilder { // MARK: - Query Building - /// Build a base SELECT query for a table with optional sorting and pagination - /// - Parameters: - /// - tableName: The table to query - /// - sortState: Optional sort state to apply ORDER BY - /// - columns: Available columns (for sort column validation) - /// - limit: Row limit (default 200) - /// - offset: Starting row offset for pagination (default 0) - /// - Returns: Complete SQL query string func buildBaseQuery( tableName: String, sortState: SortState? = nil, @@ -44,7 +36,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildBrowseQuery( @@ -58,7 +49,6 @@ struct TableQueryBuilder { let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" - // Add ORDER BY if sort state is valid if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { query += " \(orderBy)" } @@ -67,16 +57,6 @@ struct TableQueryBuilder { return query } - /// Build a query with filters applied and pagination support - /// - Parameters: - /// - tableName: The table to query - /// - filters: Array of filters to apply - /// - logicMode: AND/OR logic for combining filters - /// - sortState: Optional sort state - /// - columns: Available columns - /// - limit: Row limit (default 200) - /// - offset: Starting row offset for pagination (default 0) - /// - Returns: Complete SQL query string with WHERE clause func buildFilteredQuery( tableName: String, filters: [TableFilter], @@ -86,7 +66,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -102,33 +81,9 @@ struct TableQueryBuilder { } let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - - // Add WHERE clause from filters - let generator = FilterSQLGenerator(databaseType: databaseType) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) - if !whereClause.isEmpty { - query += " \(whereClause)" - } - - // Add ORDER BY - if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { - query += " \(orderBy)" - } - - query += " LIMIT \(limit) OFFSET \(offset)" - return query + return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)" } - /// Build a quick search query that searches across all columns with pagination - /// - Parameters: - /// - tableName: The table to query - /// - searchText: Text to search for - /// - columns: Columns to search in - /// - sortState: Optional sort state - /// - limit: Row limit (default 200) - /// - offset: Starting row offset for pagination (default 0) - /// - Returns: Complete SQL query with OR conditions across all columns func buildQuickSearchQuery( tableName: String, searchText: String, @@ -137,7 +92,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildQuickSearchQuery( @@ -149,40 +103,9 @@ struct TableQueryBuilder { } let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - - // Build OR conditions for all columns - let escapedSearch = escapeForLike(searchText) - let conditions = columns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - - if !conditions.isEmpty { - query += " WHERE (" + conditions.joined(separator: " OR ") + ")" - } - - // Add ORDER BY - if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { - query += " \(orderBy)" - } - - query += " LIMIT \(limit) OFFSET \(offset)" - return query + return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)" } - /// Build a query combining filter rows AND quick search - /// - Parameters: - /// - tableName: The table to query - /// - filters: Array of filters to apply - /// - logicMode: AND/OR logic for combining filters - /// - searchText: Quick search text - /// - searchColumns: Columns for quick search - /// - sortState: Optional sort state - /// - columns: Available columns (for sort validation) - /// - limit: Row limit - /// - offset: Pagination offset - /// - Returns: Complete SQL query with both filter WHERE clause and quick search conditions func buildCombinedQuery( tableName: String, filters: [TableFilter], @@ -194,7 +117,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles plugins with custom query building) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -211,54 +133,14 @@ struct TableQueryBuilder { } let quotedTable = databaseType.quoteIdentifier(tableName) - var query = "SELECT * FROM \(quotedTable)" - - // Build filter conditions - let generator = FilterSQLGenerator(databaseType: databaseType) - let filterConditions = generator.generateConditions(from: filters, logicMode: logicMode) - - // Build quick search conditions - let escapedSearch = escapeForLike(searchText) - let searchConditions = searchColumns.map { column -> String in - let quotedColumn = databaseType.quoteIdentifier(column) - return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) - } - let searchClause = searchConditions.isEmpty ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" - - // Combine with AND - var whereParts: [String] = [] - if !filterConditions.isEmpty { - whereParts.append("(\(filterConditions))") - } - if !searchClause.isEmpty { - whereParts.append(searchClause) - } - - if !whereParts.isEmpty { - query += " WHERE " + whereParts.joined(separator: " AND ") - } - - // Add ORDER BY - if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { - query += " \(orderBy)" - } - - query += " LIMIT \(limit) OFFSET \(offset)" - return query + return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)" } - /// Build a sorted query by modifying an existing query - /// - Parameters: - /// - baseQuery: The original query (ORDER BY will be removed and replaced) - /// - columnName: Column to sort by - /// - ascending: Sort direction - /// - Returns: Modified query with new ORDER BY clause func buildSortedQuery( baseQuery: String, columnName: String, ascending: Bool ) -> String { - // Plugin-based drivers handle sorting at query-build time, not via query rewriting if pluginDriver != nil { return baseQuery } @@ -268,18 +150,15 @@ struct TableQueryBuilder { let quotedColumn = databaseType.quoteIdentifier(columnName) let orderByClause = "ORDER BY \(quotedColumn) \(direction)" - // Insert ORDER BY before pagination clause if let limitRange = query.range(of: "LIMIT", options: .caseInsensitive) { let beforeLimit = query[.. String { - // Plugin-based drivers handle sorting at query-build time, not via query rewriting if pluginDriver != nil { return baseQuery } @@ -310,13 +182,11 @@ struct TableQueryBuilder { var query = removeOrderBy(from: baseQuery) if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { - // Insert ORDER BY before pagination clause if let limitRange = query.range(of: "LIMIT", options: .caseInsensitive) { let beforeLimit = query[.. [(columnIndex: Int, ascending: Bool)] { sortState?.columns.compactMap { sortCol -> (columnIndex: Int, ascending: Bool)? in guard sortCol.columnIndex >= 0 else { return nil } @@ -343,7 +212,6 @@ struct TableQueryBuilder { } ?? [] } - /// Build ORDER BY clause from sort state (supports multi-column) private func buildOrderByClause(sortState: SortState?, columns: [String]) -> String? { guard let state = sortState, state.isSorting else { return nil } @@ -359,7 +227,6 @@ struct TableQueryBuilder { return "ORDER BY " + parts.joined(separator: ", ") } - /// Remove existing ORDER BY clause from a query private func removeOrderBy(from query: String) -> String { var result = query @@ -369,57 +236,20 @@ struct TableQueryBuilder { let afterOrderBy = result[orderByRange.upperBound...] - // Find where ORDER BY clause ends (before LIMIT/OFFSET or end of query) if let limitRange = afterOrderBy.range(of: "LIMIT", options: .caseInsensitive) { - // Keep LIMIT, remove ORDER BY clause let beforeOrderBy = result[.. String { - text - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "%", with: "\\%") - .replacingOccurrences(of: "_", with: "\\_") - .replacingOccurrences(of: "'", with: "''") - } - - /// Build a LIKE condition with proper type casting for non-text columns - /// PostgreSQL requires explicit cast to TEXT for numeric/other types. - /// MySQL/MariaDB default to `\` as the LIKE escape character, so no ESCAPE clause needed. - /// PostgreSQL and SQLite require an explicit ESCAPE declaration. - private func buildLikeCondition(column: String, searchText: String) -> String { - switch databaseType { - case .postgresql, .redshift: - return "\(column)::TEXT LIKE '%\(searchText)%' ESCAPE '\\'" - case .mysql, .mariadb: - return "CAST(\(column) AS CHAR) LIKE '%\(searchText)%'" - case .clickhouse: - return "toString(\(column)) LIKE '%\(searchText)%' ESCAPE '\\'" - case .duckdb: - return "CAST(\(column) AS VARCHAR) LIKE '%\(searchText)%' ESCAPE '\\'" - case .sqlite, .mongodb, .redis: - return "\(column) LIKE '%\(searchText)%' ESCAPE '\\'" - case .mssql: - return "CAST(\(column) AS NVARCHAR(MAX)) LIKE '%\(searchText)%' ESCAPE '\\'" - case .oracle: - return "CAST(\(column) AS VARCHAR2(4000)) LIKE '%\(searchText)%' ESCAPE '\\'" - } - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift index 703d4f5a..63bb504d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift @@ -6,69 +6,6 @@ // import Foundation -import TableProPluginKit extension MainContentCoordinator { - /// Converts a MQL query into a `db.runCommand({"explain": ...})` command. - /// For find operations, builds a structured explain with filter and options. - /// For other operations, returns a generic runCommand explain wrapper. - static func buildMongoExplain(for query: String) -> String { - guard let operation = try? MongoShellParser.parse(query) else { - return "db.runCommand({\"explain\": \"\(query)\", \"verbosity\": \"executionStats\"})" - } - - switch operation { - case .find(let collection, let filter, let options): - var findDoc = "\"find\": \"\(collection)\", \"filter\": \(filter)" - if let sort = options.sort { - findDoc += ", \"sort\": \(sort)" - } - if let skip = options.skip { - findDoc += ", \"skip\": \(skip)" - } - if let limit = options.limit { - findDoc += ", \"limit\": \(limit)" - } - if let projection = options.projection { - findDoc += ", \"projection\": \(projection)" - } - return "db.runCommand({\"explain\": {\(findDoc)}, \"verbosity\": \"executionStats\"})" - - case .findOne(let collection, let filter): - return "db.runCommand({\"explain\": {\"find\": \"\(collection)\", \"filter\": \(filter), \"limit\": 1}, \"verbosity\": \"executionStats\"})" - - case .aggregate(let collection, let pipeline): - return "db.runCommand({\"explain\": {\"aggregate\": \"\(collection)\", \"pipeline\": \(pipeline), \"cursor\": {}}, \"verbosity\": \"executionStats\"})" - - case .countDocuments(let collection, let filter): - return "db.runCommand({\"explain\": {\"count\": \"\(collection)\", \"query\": \(filter)}, \"verbosity\": \"executionStats\"})" - - case .deleteOne(let collection, let filter): - return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 1}]}, \"verbosity\": \"executionStats\"})" - - case .deleteMany(let collection, let filter): - return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 0}]}, \"verbosity\": \"executionStats\"})" - - case .updateOne(let collection, let filter, let update): - return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": false}]}, \"verbosity\": \"executionStats\"})" - - case .updateMany(let collection, let filter, let update): - return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": true}]}, \"verbosity\": \"executionStats\"})" - - case .findOneAndUpdate(let collection, let filter, let update): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(update)" - return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" - - case .findOneAndReplace(let collection, let filter, let replacement): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(replacement)" - return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" - - case .findOneAndDelete(let collection, let filter): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"remove\": true" - return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" - - default: - return "db.runCommand({\"explain\": \"\(query)\", \"verbosity\": \"executionStats\"})" - } - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift index 62fbac86..82a820ff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift @@ -8,20 +8,4 @@ import Foundation extension MainContentCoordinator { - /// Builds a Redis INFO command variant for explaining/profiling a command. - /// Redis has no EXPLAIN equivalent, so we return a DEBUG OBJECT or INFO - /// variant depending on whether the command targets a specific key. - static func buildRedisDebugCommand(for command: String) -> String { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty } - - // If the command references a key (second token), use DEBUG OBJECT - if parts.count >= 2 { - let key = parts[1] - return "DEBUG OBJECT \(key)" - } - - // Generic fallback: return server command stats - return "INFO commandstats" - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index dc2a15be..aae7310f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -37,10 +37,7 @@ extension MainContentCoordinator { let sortedTruncates = truncates.sorted() let sortedDeletes = deletes.sorted() - // Check if any operation needs FK disabled (not applicable to PostgreSQL or MSSQL) - let fkApplicable = dbType != .postgresql && dbType != .clickhouse - && dbType != .mssql && dbType != .oracle && dbType != .duckdb - let needsDisableFK = includeFKHandling && fkApplicable && truncates.union(deletes).contains { tableName in + let needsDisableFK = includeFKHandling && truncates.union(deletes).contains { tableName in options[tableName]?.ignoreForeignKeys == true } @@ -65,10 +62,13 @@ extension MainContentCoordinator { for tableName in sortedDeletes { let quotedName = dbType.quoteIdentifier(tableName) let tableOptions = options[tableName] ?? TableOperationOptions() - statements.append(dropTableStatement( + let stmt = dropTableStatement( tableName: tableName, quotedName: quotedName, isView: viewNames.contains(tableName), options: tableOptions, dbType: dbType - )) + ) + if !stmt.isEmpty { + statements.append(stmt) + } } // FK re-enable must be OUTSIDE transaction to ensure it runs even on rollback @@ -81,106 +81,47 @@ extension MainContentCoordinator { // MARK: - Foreign Key Handling - /// Returns SQL statements to disable foreign key checks for the database type. - /// Tries plugin-provided statements first, falls back to built-in switch. - /// - Note: PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead. func fkDisableStatements(for dbType: DatabaseType) -> [String] { - if let adapter = currentPluginDriverAdapter, - let stmts = adapter.foreignKeyDisableStatements() { - return stmts - } - switch dbType { - case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=0"] - case .postgresql, .redshift, .clickhouse, .mongodb, .redis, .mssql, .oracle, .duckdb: return [] - case .sqlite: return ["PRAGMA foreign_keys = OFF"] + guard let adapter = currentPluginDriverAdapter, + let stmts = adapter.foreignKeyDisableStatements() else { + return [] } + return stmts } - /// Returns SQL statements to re-enable foreign key checks for the database type. - /// Tries plugin-provided statements first, falls back to built-in switch. func fkEnableStatements(for dbType: DatabaseType) -> [String] { - if let adapter = currentPluginDriverAdapter, - let stmts = adapter.foreignKeyEnableStatements() { - return stmts - } - switch dbType { - case .mysql, .mariadb: - return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .clickhouse, .mongodb, .redis, .mssql, .oracle, .duckdb: + guard let adapter = currentPluginDriverAdapter, + let stmts = adapter.foreignKeyEnableStatements() else { return [] - case .sqlite: - return ["PRAGMA foreign_keys = ON"] } + return stmts } // MARK: - Private SQL Builders - /// Generates TRUNCATE/DELETE statements for a table. - /// Tries plugin-provided statements first, falls back to built-in switch. - /// - Note: SQLite uses DELETE and resets auto-increment via sqlite_sequence. private func truncateStatements( tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType ) -> [String] { - if let adapter = currentPluginDriverAdapter, - let stmts = adapter.truncateTableStatements( - table: tableName, schema: nil, cascade: options.cascade - ) { - return stmts - } - switch dbType { - case .mysql, .mariadb, .clickhouse, .duckdb: - return ["TRUNCATE TABLE \(quotedName)"] - case .postgresql, .redshift: - let cascade = options.cascade ? " CASCADE" : "" - return ["TRUNCATE TABLE \(quotedName)\(cascade)"] - case .mssql, .oracle: - return ["TRUNCATE TABLE \(quotedName)"] - case .sqlite: - // DELETE FROM + reset auto-increment counter for true TRUNCATE semantics. - // Note: quotedName uses backticks (via quoteIdentifier) for SQL identifiers, - // while escapedName uses single-quote escaping for string literals in the - // sqlite_sequence query. These are different SQL quoting mechanisms for - // different purposes (identifier vs string literal). - let escapedName = tableName.replacingOccurrences(of: "'", with: "''") - return [ - "DELETE FROM \(quotedName)", - // sqlite_sequence may not exist if no table has AUTOINCREMENT. - // This DELETE will succeed silently if the table isn't in sqlite_sequence. - "DELETE FROM sqlite_sequence WHERE name = '\(escapedName)'" - ] - case .mongodb: - let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return ["db[\"\(escaped)\"].deleteMany({})"] - case .redis: - return ["FLUSHDB"] + guard let adapter = currentPluginDriverAdapter, + let stmts = adapter.truncateTableStatements( + table: tableName, schema: nil, cascade: options.cascade + ) else { + return [] } + return stmts } - /// Generates DROP TABLE/VIEW statement with optional CASCADE. - /// Tries plugin-provided statement first, falls back to built-in switch. private func dropTableStatement( tableName: String, quotedName: String, isView: Bool, options: TableOperationOptions, dbType: DatabaseType ) -> String { let keyword = isView ? "VIEW" : "TABLE" - if let adapter = currentPluginDriverAdapter, - let stmt = adapter.dropObjectStatement( - name: tableName, objectType: keyword, schema: nil, cascade: options.cascade - ) { - return stmt - } - switch dbType { - case .postgresql, .redshift: - return "DROP \(keyword) \(quotedName)\(options.cascade ? " CASCADE" : "")" - case .mysql, .mariadb, .clickhouse, .sqlite, .mssql, .oracle, .duckdb: - return "DROP \(keyword) \(quotedName)" - case .mongodb: - let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "db[\"\(escaped)\"].drop()" - case .redis: - return "DEL \(tableName)" + guard let adapter = currentPluginDriverAdapter, + let stmt = adapter.dropObjectStatement( + name: tableName, objectType: keyword, schema: nil, cascade: options.cascade + ) else { + return "" } + return stmt } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 23e80456..4e341a2f 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -679,26 +679,9 @@ final class MainContentCoordinator { return } - // Build database-specific EXPLAIN query via plugin, with fallback - let explainSQL: String - if let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, - let pluginExplain = adapter.buildExplainQuery(stmt) { - explainSQL = pluginExplain - } else { - switch connection.type { - case .mssql, .oracle: - return - case .sqlite: - explainSQL = "EXPLAIN QUERY PLAN \(stmt)" - case .mysql, .mariadb, .postgresql, .redshift, .duckdb: - explainSQL = "EXPLAIN \(stmt)" - case .mongodb: - explainSQL = Self.buildMongoExplain(for: stmt) - case .redis: - explainSQL = Self.buildRedisDebugCommand(for: stmt) - case .clickhouse: - return - } + guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, + let explainSQL = adapter.buildExplainQuery(stmt) else { + return } let level = connection.safeModeLevel diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 4c801a6b..191d7edd 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -533,10 +533,14 @@ struct TableStructureView: View { return } - let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver + guard let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver else { + toolbarState.previewStatements = ["-- Error: no plugin driver available for DDL generation"] + toolbarState.showSQLReviewPopover = true + return + } + let generator = SchemaStatementGenerator( tableName: tableName, - databaseType: getDatabaseType(), pluginDriver: pluginDriver ) diff --git a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift index 4c25c934..16fd4e03 100644 --- a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift +++ b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift @@ -2,7 +2,7 @@ // ClickHouseDialectTests.swift // TableProTests // -// Tests for ClickHouseDialect and SQLDialectFactory integration +// Tests for ClickHouse dialect via plugin-provided SQLDialectFactory // import Foundation @@ -12,97 +12,10 @@ import Testing @Suite("ClickHouse Dialect") struct ClickHouseDialectTests { - let dialect = ClickHouseDialect() - - // MARK: - Identifier Quote - - @Test("ClickHouse identifier quote is backtick") - func testIdentifierQuote() { - #expect(dialect.identifierQuote == "`") - } - - // MARK: - ClickHouse-Specific Keywords - - @Test("Contains ClickHouse-specific keywords", arguments: [ - "FINAL", "SAMPLE", "PREWHERE", "FORMAT", "SETTINGS", - "OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC" - ]) - func testClickHouseSpecificKeywords(keyword: String) { - #expect(dialect.keywords.contains(keyword)) - } - - @Test("Contains standard SQL keywords", arguments: [ - "SELECT", "FROM", "WHERE", "JOIN", "INSERT", "UPDATE", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE" - ]) - func testStandardKeywords(keyword: String) { - #expect(dialect.keywords.contains(keyword)) - } - - // MARK: - ClickHouse-Specific Functions - - @Test("Contains ClickHouse-specific functions", arguments: [ - "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", - "TOSTRING", "TOINT32", "FORMATDATETIME", - "MULTIIF", "ARRAYMAP", "ARRAYJOIN", - "MATCH", "CURRENTDATABASE", "VERSION", - "QUANTILE", "TOPK" - ]) - func testClickHouseSpecificFunctions(function: String) { - #expect(dialect.functions.contains(function)) - } - - @Test("Contains standard SQL functions", arguments: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "CONCAT", "CAST" - ]) - func testStandardFunctions(function: String) { - #expect(dialect.functions.contains(function)) - } - - // MARK: - ClickHouse-Specific Data Types - - @Test("Contains ClickHouse integer types", arguments: [ - "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", - "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256" - ]) - func testIntegerDataTypes(dataType: String) { - #expect(dialect.dataTypes.contains(dataType)) - } - - @Test("Contains ClickHouse float/decimal types", arguments: [ - "FLOAT32", "FLOAT64", "DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256" - ]) - func testFloatDecimalDataTypes(dataType: String) { - #expect(dialect.dataTypes.contains(dataType)) - } - - @Test("Contains ClickHouse date/time types", arguments: [ - "DATE", "DATE32", "DATETIME", "DATETIME64" - ]) - func testDateTimeDataTypes(dataType: String) { - #expect(dialect.dataTypes.contains(dataType)) - } - - @Test("Contains ClickHouse complex types", arguments: [ - "ARRAY", "TUPLE", "MAP", "NULLABLE", "LOWCARDINALITY", - "ENUM8", "ENUM16" - ]) - func testComplexDataTypes(dataType: String) { - #expect(dialect.dataTypes.contains(dataType)) - } - - @Test("Contains ClickHouse other types", arguments: [ - "STRING", "FIXEDSTRING", "UUID", "IPV4", "IPV6", "JSON", "BOOL" - ]) - func testOtherDataTypes(dataType: String) { - #expect(dialect.dataTypes.contains(dataType)) - } - - // MARK: - Dialect Factory - - @Test("Factory returns ClickHouseDialect for .clickhouse") - func testFactoryReturnsClickHouseDialect() { - let created = SQLDialectFactory.createDialect(for: .clickhouse) - #expect(created is ClickHouseDialect) + @Test("Factory creates dialect for .clickhouse") + @MainActor + func testFactoryCreatesDialect() { + let dialect = SQLDialectFactory.createDialect(for: .clickhouse) + #expect(dialect.identifierQuote == "`" || dialect.identifierQuote == "\"") } } diff --git a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift index 0d2e0baa..1846c0b0 100644 --- a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift +++ b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift @@ -80,26 +80,4 @@ final class SQLDialectDescriptorTests: XCTestCase { XCTAssertTrue(adapter.isDataType("int")) XCTAssertFalse(adapter.isDataType("NONEXISTENT")) } - - // MARK: - Built-in Dialect Fallback - - @MainActor - func testBuiltInDialectFallback() { - let mysqlDialect = SQLDialectFactory.builtInDialect(for: .mysql) - XCTAssertEqual(mysqlDialect.identifierQuote, "`") - XCTAssertFalse(mysqlDialect.keywords.isEmpty) - XCTAssertFalse(mysqlDialect.functions.isEmpty) - XCTAssertFalse(mysqlDialect.dataTypes.isEmpty) - - let pgDialect = SQLDialectFactory.builtInDialect(for: .postgresql) - XCTAssertEqual(pgDialect.identifierQuote, "\"") - XCTAssertTrue(pgDialect.keywords.contains("ILIKE")) - - let mssqlDialect = SQLDialectFactory.builtInDialect(for: .mssql) - XCTAssertEqual(mssqlDialect.identifierQuote, "[") - - let oracleDialect = SQLDialectFactory.builtInDialect(for: .oracle) - XCTAssertEqual(oracleDialect.identifierQuote, "\"") - XCTAssertTrue(oracleDialect.keywords.contains("ROWNUM")) - } } diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorMSSQLTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorMSSQLTests.swift deleted file mode 100644 index 0b840e89..00000000 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorMSSQLTests.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// SchemaStatementGeneratorMSSQLTests.swift -// TableProTests -// -// Tests for SchemaStatementGenerator with databaseType: .mssql -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("Schema Statement Generator MSSQL") -struct SchemaStatementGeneratorMSSQLTests { - // MARK: - Helpers - - private func makeGenerator( - table: String = "users", - pkConstraint: String? = nil - ) -> SchemaStatementGenerator { - SchemaStatementGenerator( - tableName: table, - databaseType: .mssql, - primaryKeyConstraintName: pkConstraint - ) - } - - private func makeColumn( - name: String = "email", - dataType: String = "NVARCHAR(255)", - isNullable: Bool = false - ) -> EditableColumnDefinition { - EditableColumnDefinition( - id: UUID(), - name: name, - dataType: dataType, - isNullable: isNullable, - defaultValue: nil, - autoIncrement: false, - unsigned: false, - comment: nil, - collation: nil, - onUpdate: nil, - charset: nil, - extra: nil, - isPrimaryKey: false - ) - } - - private func makeIndex( - name: String = "idx_email", - columns: [String] = ["email"], - isUnique: Bool = false - ) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), - name: name, - columns: columns, - type: .btree, - isUnique: isUnique, - isPrimary: false, - comment: nil - ) - } - - private func makeFK( - name: String = "fk_user_role", - columns: [String] = ["role_id"], - refTable: String = "roles", - refColumns: [String] = ["id"] - ) -> EditableForeignKeyDefinition { - EditableForeignKeyDefinition( - id: UUID(), - name: name, - columns: columns, - referencedTable: refTable, - referencedColumns: refColumns, - onDelete: .cascade, - onUpdate: .noAction - ) - } - - // MARK: - Column Tests - - @Test("Add column uses ADD (not ADD COLUMN) for MSSQL") - func addColumnUsesAddKeyword() throws { - let generator = makeGenerator() - let column = makeColumn(name: "email", dataType: "NVARCHAR(255)", isNullable: false) - let statements = try generator.generate(changes: [.addColumn(column)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ALTER TABLE [users] ADD")) - #expect(!sql.contains("ADD COLUMN")) - #expect(sql.contains("[email]")) - #expect(sql.contains("NVARCHAR(255)")) - #expect(sql.contains("NOT NULL")) - } - - @Test("Rename column uses EXEC sp_rename syntax") - func renameColumnUsesSPRename() throws { - let generator = makeGenerator() - let old = makeColumn(name: "old_name", dataType: "NVARCHAR(100)") - let new = makeColumn(name: "new_name", dataType: "NVARCHAR(100)") - let statements = try generator.generate(changes: [.modifyColumn(old: old, new: new)]) - - let allSQL = statements.map { $0.sql }.joined(separator: "\n") - #expect(allSQL.contains("sp_rename")) - #expect(allSQL.contains("users.old_name")) - #expect(allSQL.contains("new_name")) - #expect(allSQL.contains("COLUMN")) - } - - @Test("Modify column type uses ALTER COLUMN syntax") - func modifyColumnTypeUsesAlterColumn() throws { - let generator = makeGenerator() - let old = makeColumn(name: "email", dataType: "VARCHAR(100)") - let new = makeColumn(name: "email", dataType: "TEXT") - let statements = try generator.generate(changes: [.modifyColumn(old: old, new: new)]) - - let allSQL = statements.map { $0.sql }.joined(separator: "\n") - #expect(allSQL.contains("ALTER TABLE [users] ALTER COLUMN [email]")) - #expect(allSQL.contains("TEXT")) - } - - @Test("Modify column nullability uses ALTER COLUMN") - func modifyColumnNullabilityUsesAlterColumn() throws { - let generator = makeGenerator() - let old = makeColumn(name: "email", dataType: "NVARCHAR(255)", isNullable: false) - let new = makeColumn(name: "email", dataType: "NVARCHAR(255)", isNullable: true) - let statements = try generator.generate(changes: [.modifyColumn(old: old, new: new)]) - - let allSQL = statements.map { $0.sql }.joined(separator: "\n") - #expect(allSQL.contains("ALTER COLUMN")) - } - - @Test("Drop column uses DROP COLUMN with bracket-quoted name") - func dropColumn() throws { - let generator = makeGenerator() - let column = makeColumn(name: "email") - let statements = try generator.generate(changes: [.deleteColumn(column)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ALTER TABLE [users] DROP COLUMN [email]")) - #expect(statements[0].isDestructive == true) - } - - // MARK: - Index Tests - - @Test("Add index generates CREATE INDEX with bracket quoting") - func addIndex() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_name", columns: ["col"]) - let statements = try generator.generate(changes: [.addIndex(index)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("CREATE INDEX [idx_name] ON [users] ([col])")) - } - - @Test("Add unique index generates CREATE UNIQUE INDEX") - func addUniqueIndex() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_name", columns: ["col"], isUnique: true) - let statements = try generator.generate(changes: [.addIndex(index)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("CREATE UNIQUE INDEX [idx_name] ON [users] ([col])")) - } - - @Test("Drop index uses DROP INDEX with ON clause for MSSQL") - func dropIndex() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_name") - let statements = try generator.generate(changes: [.deleteIndex(index)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP INDEX [idx_name] ON [users]")) - } - - // MARK: - Foreign Key Tests - - @Test("Add foreign key contains ADD CONSTRAINT with bracket-quoted name") - func addForeignKey() throws { - let generator = makeGenerator() - let fk = makeFK(name: "fk_user_role", columns: ["role_id"], refTable: "roles", refColumns: ["id"]) - let statements = try generator.generate(changes: [.addForeignKey(fk)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ADD CONSTRAINT [fk_user_role]")) - #expect(sql.contains("FOREIGN KEY")) - #expect(sql.contains("[role_id]")) - #expect(sql.contains("[roles]")) - #expect(sql.contains("[id]")) - } - - @Test("Drop foreign key uses DROP CONSTRAINT for MSSQL") - func dropForeignKey() throws { - let generator = makeGenerator() - let fk = makeFK(name: "fk_user_role") - let statements = try generator.generate(changes: [.deleteForeignKey(fk)]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ALTER TABLE [users] DROP CONSTRAINT [fk_user_role]")) - } - - // MARK: - Primary Key Tests - - @Test("Modify primary key uses DROP CONSTRAINT and ADD PRIMARY KEY") - func modifyPrimaryKey() throws { - let generator = makeGenerator(pkConstraint: "PK_users") - let statements = try generator.generate(changes: [.modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"])]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP CONSTRAINT")) - #expect(sql.contains("ADD PRIMARY KEY")) - #expect(sql.contains("[id]")) - #expect(sql.contains("[tenant_id]")) - } - - @Test("Modify primary key with no constraint name falls back to PK underscore tableName") - func modifyPrimaryKeyDefaultConstraintName() throws { - let generator = makeGenerator(table: "orders") - let statements = try generator.generate(changes: [.modifyPrimaryKey(old: ["id"], new: ["order_id"])]) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP CONSTRAINT")) - #expect(sql.contains("PK_orders")) - } - - // MARK: - Statement Validity Tests - - @Test("All generated statements end with semicolon") - func allStatementsEndWithSemicolon() throws { - let generator = makeGenerator() - let column = makeColumn(name: "field1") - let index = makeIndex(name: "idx_field1", columns: ["field1"]) - let changes: [SchemaChange] = [ - .addColumn(column), - .addIndex(index) - ] - let statements = try generator.generate(changes: changes) - - for statement in statements { - #expect(statement.sql.hasSuffix(";")) - } - } - - @Test("Add column is not destructive") - func addColumnNotDestructive() throws { - let generator = makeGenerator() - let column = makeColumn(name: "new_field") - let statements = try generator.generate(changes: [.addColumn(column)]) - - #expect(statements[0].isDestructive == false) - } -} diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift index feeb3302..b3e4cc7c 100644 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -11,7 +11,7 @@ import TableProPluginKit import Testing /// Mock plugin driver that returns custom DDL for specific operations. -/// Methods return nil by default (triggering fallback), unless overridden via closures. +/// Methods return nil by default (operation skipped), unless overridden via closures. private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var addColumnHandler: ((String, PluginColumnDefinition) -> String?)? var modifyColumnHandler: ((String, PluginColumnDefinition, PluginColumnDefinition) -> String?)? @@ -144,45 +144,36 @@ struct SchemaStatementGeneratorPluginTests { ) } - // MARK: - Fallback Tests (plugin returns nil) + // MARK: - Nil Return Tests (plugin returns nil -> change skipped) - @Test("Add column falls back to default when plugin returns nil") - func addColumnFallback() throws { + @Test("Add column is skipped when plugin returns nil") + func addColumnSkippedWhenNil() throws { let mock = MockPluginDriver() - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() let stmts = try generator.generate(changes: [.addColumn(column)]) - #expect(stmts.count == 1) - #expect(stmts[0].sql.contains("ADD COLUMN")) - #expect(stmts[0].sql.contains("`email`")) + #expect(stmts.isEmpty) } - @Test("Drop column falls back to default when plugin returns nil") - func dropColumnFallback() throws { + @Test("Drop column is skipped when plugin returns nil") + func dropColumnSkippedWhenNil() throws { let mock = MockPluginDriver() - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() let stmts = try generator.generate(changes: [.deleteColumn(column)]) - #expect(stmts.count == 1) - #expect(stmts[0].sql.contains("DROP COLUMN")) + #expect(stmts.isEmpty) } - @Test("No plugin driver uses default generation") - func noPluginDriverDefault() throws { - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .postgresql - ) + @Test("Add index is skipped when plugin returns nil") + func addIndexSkippedWhenNil() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let index = makeIndex() let stmts = try generator.generate(changes: [.addIndex(index)]) - #expect(stmts.count == 1) - #expect(stmts[0].sql.contains("CREATE INDEX")) + #expect(stmts.isEmpty) } // MARK: - Plugin Override Tests @@ -194,15 +185,12 @@ struct SchemaStatementGeneratorPluginTests { "ALTER TABLE \(table) ADD \(col.name) \(col.dataType) CUSTOM_SYNTAX" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() let stmts = try generator.generate(changes: [.addColumn(column)]) #expect(stmts.count == 1) #expect(stmts[0].sql.contains("CUSTOM_SYNTAX")) - #expect(!stmts[0].sql.contains("ADD COLUMN")) } @Test("Modify column uses plugin SQL when provided") @@ -212,9 +200,7 @@ struct SchemaStatementGeneratorPluginTests { "ALTER TABLE users CHANGE \(oldCol.name) TO \(newCol.name) PLUGIN_MODIFY" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let oldCol = makeColumn(name: "email") let newCol = makeColumn(name: "email_address") let stmts = try generator.generate(changes: [.modifyColumn(old: oldCol, new: newCol)]) @@ -230,9 +216,7 @@ struct SchemaStatementGeneratorPluginTests { "ALTER TABLE \(table) DROP \(colName) IF EXISTS" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() let stmts = try generator.generate(changes: [.deleteColumn(column)]) @@ -247,9 +231,7 @@ struct SchemaStatementGeneratorPluginTests { "CREATE INDEX \(idx.name) ON \(table) PLUGIN_INDEX" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let index = makeIndex() let stmts = try generator.generate(changes: [.addIndex(index)]) @@ -264,9 +246,7 @@ struct SchemaStatementGeneratorPluginTests { "DROP INDEX IF EXISTS \(indexName)" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let index = makeIndex() let stmts = try generator.generate(changes: [.deleteIndex(index)]) @@ -281,9 +261,7 @@ struct SchemaStatementGeneratorPluginTests { "ALTER TABLE \(table) ADD FK \(fk.name) PLUGIN_FK" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let fk = makeForeignKey() let stmts = try generator.generate(changes: [.addForeignKey(fk)]) @@ -298,9 +276,7 @@ struct SchemaStatementGeneratorPluginTests { "ALTER TABLE users DROP CONSTRAINT \(constraintName) PLUGIN_DROP_FK" } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let fk = makeForeignKey() let stmts = try generator.generate(changes: [.deleteForeignKey(fk)]) @@ -318,9 +294,7 @@ struct SchemaStatementGeneratorPluginTests { ] } - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let stmts = try generator.generate(changes: [.modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"])]) #expect(stmts.count == 1) @@ -328,19 +302,17 @@ struct SchemaStatementGeneratorPluginTests { #expect(stmts[0].isDestructive) } - // MARK: - Mixed Override/Fallback + // MARK: - Mixed Override/Nil - @Test("Plugin overrides some operations while others fall back") - func mixedPluginAndFallback() throws { + @Test("Plugin overrides some operations while others are skipped") + func mixedPluginAndSkipped() throws { let mock = MockPluginDriver() mock.addColumnHandler = { _, col in "PLUGIN_ADD_COL \(col.name)" } - // dropColumnHandler is nil, so drop falls back to default + // dropColumnHandler is nil, so drop is skipped - let generator = SchemaStatementGenerator( - tableName: "users", databaseType: .mysql, pluginDriver: mock - ) + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let addCol = makeColumn(name: "age", dataType: "INT") let dropCol = makeColumn(name: "old_field") @@ -350,15 +322,176 @@ struct SchemaStatementGeneratorPluginTests { .deleteColumn(dropCol) ]) + // Only the add column statement is generated (drop was skipped) + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("PLUGIN_ADD_COL")) + } + + // MARK: - Modify Index/FK (drop+recreate via plugin) + + @Test("Modify index generates drop and create via plugin") + func modifyIndexViaPlugin() throws { + let mock = MockPluginDriver() + mock.dropIndexHandler = { _, indexName in + "DROP INDEX \(indexName)" + } + mock.addIndexHandler = { table, idx in + "CREATE INDEX \(idx.name) ON \(table) (\(idx.columns.joined(separator: ", ")))" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let oldIndex = makeIndex(name: "idx_email", columns: ["email"]) + let newIndex = makeIndex(name: "idx_email", columns: ["email", "name"], isUnique: true) + let stmts = try generator.generate(changes: [.modifyIndex(old: oldIndex, new: newIndex)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("DROP INDEX")) + #expect(stmts[0].sql.contains("CREATE INDEX")) + } + + @Test("Modify index is skipped when drop returns nil") + func modifyIndexSkippedWhenDropNil() throws { + let mock = MockPluginDriver() + mock.addIndexHandler = { table, idx in + "CREATE INDEX \(idx.name) ON \(table)" + } + // dropIndexHandler is nil + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let oldIndex = makeIndex(name: "idx_email") + let newIndex = makeIndex(name: "idx_email", columns: ["email", "name"]) + let stmts = try generator.generate(changes: [.modifyIndex(old: oldIndex, new: newIndex)]) + + #expect(stmts.isEmpty) + } + + @Test("Modify foreign key generates drop and create via plugin") + func modifyForeignKeyViaPlugin() throws { + let mock = MockPluginDriver() + mock.dropForeignKeyHandler = { _, name in + "ALTER TABLE users DROP CONSTRAINT \(name)" + } + mock.addForeignKeyHandler = { table, fk in + "ALTER TABLE \(table) ADD CONSTRAINT \(fk.name) FOREIGN KEY" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let oldFK = makeForeignKey(name: "fk_role") + let newFK = makeForeignKey(name: "fk_role", refColumns: ["role_id"]) + let stmts = try generator.generate(changes: [.modifyForeignKey(old: oldFK, new: newFK)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("DROP CONSTRAINT")) + #expect(stmts[0].sql.contains("ADD CONSTRAINT")) + } + + // MARK: - Dependency Ordering + + @Test("Dependency ordering: FK drops before column drops") + func dependencyOrderingFKBeforeColumn() throws { + let mock = MockPluginDriver() + mock.dropColumnHandler = { table, colName in + "ALTER TABLE \(table) DROP COLUMN \(colName)" + } + mock.dropForeignKeyHandler = { _, name in + "ALTER TABLE users DROP FOREIGN KEY \(name)" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let column = makeColumn(name: "role_id") + let fk = makeForeignKey(name: "fk_role", columns: ["role_id"]) + let stmts = try generator.generate(changes: [ + .deleteColumn(column), + .deleteForeignKey(fk) + ]) + #expect(stmts.count == 2) + #expect(stmts[0].sql.contains("DROP FOREIGN KEY")) + #expect(stmts[1].sql.contains("DROP COLUMN")) + } + + @Test("All statements end with semicolon") + func allStatementsEndWithSemicolon() throws { + let mock = MockPluginDriver() + mock.addColumnHandler = { table, col in + "ALTER TABLE \(table) ADD COLUMN \(col.name) \(col.dataType)" + } + mock.addIndexHandler = { table, idx in + "CREATE INDEX \(idx.name) ON \(table)" + } + mock.addForeignKeyHandler = { table, fk in + "ALTER TABLE \(table) ADD CONSTRAINT \(fk.name) FOREIGN KEY" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let column = makeColumn(name: "field1") + let index = makeIndex(name: "idx_field1", columns: ["field1"]) + let fk = makeForeignKey(name: "fk_field1", columns: ["field1"], refTable: "other", refColumns: ["id"]) - // Drop comes first due to dependency ordering - let dropStmt = stmts[0] - #expect(dropStmt.sql.contains("DROP COLUMN")) - #expect(!dropStmt.sql.contains("PLUGIN")) + let stmts = try generator.generate(changes: [ + .addColumn(column), + .addIndex(index), + .addForeignKey(fk) + ]) + + for stmt in stmts { + #expect(stmt.sql.hasSuffix(";")) + } + } + + @Test("Modify column with type change is destructive") + func modifyColumnTypeChangeDestructive() throws { + let mock = MockPluginDriver() + mock.modifyColumnHandler = { _, oldCol, newCol in + "ALTER TABLE users MODIFY COLUMN \(newCol.name) \(newCol.dataType)" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let oldCol = makeColumn(name: "count", dataType: "INT") + let newCol = makeColumn(name: "count", dataType: "BIGINT") + let stmts = try generator.generate(changes: [.modifyColumn(old: oldCol, new: newCol)]) + + #expect(stmts.count == 1) + #expect(stmts[0].isDestructive == true) + } + + @Test("Add column is not destructive") + func addColumnNotDestructive() throws { + let mock = MockPluginDriver() + mock.addColumnHandler = { table, col in + "ALTER TABLE \(table) ADD COLUMN \(col.name) \(col.dataType)" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let column = makeColumn(name: "new_field") + let stmts = try generator.generate(changes: [.addColumn(column)]) + + #expect(stmts[0].isDestructive == false) + } + + @Test("Modify primary key is destructive") + func modifyPrimaryKeyIsDestructive() throws { + let mock = MockPluginDriver() + mock.modifyPrimaryKeyHandler = { table, _, newCols in + [ + "ALTER TABLE \(table) DROP PRIMARY KEY", + "ALTER TABLE \(table) ADD PRIMARY KEY (\(newCols.joined(separator: ", ")))" + ] + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let stmts = try generator.generate(changes: [.modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"])]) + + #expect(stmts.count == 1) + #expect(stmts[0].isDestructive == true) + } + + @Test("Empty changes produces empty result") + func emptyChanges() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let stmts = try generator.generate(changes: []) - // Add uses plugin override - let addStmt = stmts[1] - #expect(addStmt.sql.contains("PLUGIN_ADD_COL")) + #expect(stmts.isEmpty) } } diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorTests.swift deleted file mode 100644 index 289533cf..00000000 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorTests.swift +++ /dev/null @@ -1,586 +0,0 @@ -// -// SchemaStatementGeneratorTests.swift -// TableProTests -// -// Tests for SchemaStatementGenerator -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Schema Statement Generator") -struct SchemaStatementGeneratorTests { - - // MARK: - Helpers - - private func makeGenerator( - table: String = "users", - dbType: DatabaseType = .mysql, - pkConstraint: String? = nil - ) -> SchemaStatementGenerator { - SchemaStatementGenerator( - tableName: table, - databaseType: dbType, - primaryKeyConstraintName: pkConstraint - ) - } - - private func makeColumn( - name: String = "email", - dataType: String = "VARCHAR(255)", - isNullable: Bool = true, - autoIncrement: Bool = false, - unsigned: Bool = false, - isPrimaryKey: Bool = false, - defaultValue: String? = nil, - comment: String? = nil - ) -> EditableColumnDefinition { - EditableColumnDefinition( - id: UUID(), - name: name, - dataType: dataType, - isNullable: isNullable, - defaultValue: defaultValue, - autoIncrement: autoIncrement, - unsigned: unsigned, - comment: comment, - collation: nil, - onUpdate: nil, - charset: nil, - extra: nil, - isPrimaryKey: isPrimaryKey - ) - } - - private func makeIndex( - name: String = "idx_email", - columns: [String] = ["email"], - isUnique: Bool = false, - type: EditableIndexDefinition.IndexType = .btree - ) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), - name: name, - columns: columns, - type: type, - isUnique: isUnique, - isPrimary: false, - comment: nil - ) - } - - private func makeFK( - name: String = "fk_user_role", - columns: [String] = ["role_id"], - refTable: String = "roles", - refColumns: [String] = ["id"] - ) -> EditableForeignKeyDefinition { - EditableForeignKeyDefinition( - id: UUID(), - name: name, - columns: columns, - referencedTable: refTable, - referencedColumns: refColumns, - onDelete: .cascade, - onUpdate: .noAction - ) - } - - private func normalizeSQL(_ sql: String) -> String { - sql.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespaces) - } - - // MARK: - Column Tests - - @Test("Add column MySQL with basic properties") - func addColumnMySQL() throws { - let generator = makeGenerator() - let column = makeColumn(name: "age", dataType: "INT") - let changes: [SchemaChange] = [.addColumn(column)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ALTER TABLE")) - #expect(sql.contains("ADD COLUMN")) - #expect(sql.contains("`age`")) - #expect(sql.contains("INT")) - #expect(sql.hasSuffix(";")) - #expect(statements[0].isDestructive == false) - } - - @Test("Add column with all properties MySQL") - func addColumnWithAllPropertiesMySQL() throws { - let generator = makeGenerator() - let column = makeColumn( - name: "score", - dataType: "INT", - isNullable: false, - autoIncrement: true, - unsigned: true, - defaultValue: "0", - comment: "User score" - ) - let changes: [SchemaChange] = [.addColumn(column)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("UNSIGNED")) - #expect(sql.contains("NOT NULL")) - #expect(sql.contains("DEFAULT 0")) - #expect(sql.contains("AUTO_INCREMENT")) - #expect(sql.contains("COMMENT 'User score'")) - } - - @Test("Add column PostgreSQL with AUTO_INCREMENT becomes SERIAL") - func addColumnPostgreSQLAutoIncrement() throws { - let generator = makeGenerator(dbType: .postgresql) - let column = makeColumn(name: "id", dataType: "INT", autoIncrement: true) - let changes: [SchemaChange] = [.addColumn(column)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("SERIAL") || sql.contains("AUTO_INCREMENT")) - } - - @Test("Delete column is destructive") - func deleteColumn() throws { - let generator = makeGenerator() - let column = makeColumn(name: "old_field") - let changes: [SchemaChange] = [.deleteColumn(column)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP COLUMN")) - #expect(sql.contains("`old_field`")) - #expect(sql.hasSuffix(";")) - #expect(statements[0].isDestructive == true) - } - - @Test("Modify column MySQL uses MODIFY COLUMN") - func modifyColumnMySQL() throws { - let generator = makeGenerator() - let oldColumn = makeColumn(name: "name", dataType: "VARCHAR(100)") - let newColumn = makeColumn(name: "name", dataType: "VARCHAR(255)") - let changes: [SchemaChange] = [.modifyColumn(old: oldColumn, new: newColumn)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("MODIFY COLUMN")) - #expect(sql.contains("`name`")) - #expect(sql.contains("VARCHAR(255)")) - } - - @Test("Modify column PostgreSQL uses separate ALTER statements") - func modifyColumnPostgreSQL() throws { - let generator = makeGenerator(dbType: .postgresql) - let oldColumn = makeColumn(name: "email", dataType: "VARCHAR(100)", isNullable: false) - let newColumn = makeColumn(name: "email_new", dataType: "VARCHAR(255)", isNullable: true, defaultValue: "''") - let changes: [SchemaChange] = [.modifyColumn(old: oldColumn, new: newColumn)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count >= 1) - let allSQL = statements.map { $0.sql }.joined(separator: " ") - #expect(allSQL.contains("ALTER COLUMN") || allSQL.contains("RENAME COLUMN")) - } - - @Test("Modify column SQLite throws unsupported operation") - func modifyColumnSQLiteThrows() throws { - let generator = makeGenerator(dbType: .sqlite) - let oldColumn = makeColumn(name: "field", dataType: "TEXT") - let newColumn = makeColumn(name: "field", dataType: "INTEGER") - let changes: [SchemaChange] = [.modifyColumn(old: oldColumn, new: newColumn)] - - #expect(throws: DatabaseError.self) { - try generator.generate(changes: changes) - } - } - - @Test("Modify column with type change is destructive") - func modifyColumnTypeChangeDestructive() throws { - let generator = makeGenerator() - let oldColumn = makeColumn(name: "count", dataType: "INT") - let newColumn = makeColumn(name: "count", dataType: "BIGINT") - let changes: [SchemaChange] = [.modifyColumn(old: oldColumn, new: newColumn)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - #expect(statements[0].isDestructive == true) - } - - // MARK: - Index Tests - - @Test("Add index MySQL with USING clause") - func addIndexMySQL() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_email", columns: ["email"], type: .btree) - let changes: [SchemaChange] = [.addIndex(index)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("CREATE INDEX")) - #expect(sql.contains("`idx_email`")) - #expect(sql.contains("ON `users`")) - #expect(sql.contains("USING")) - #expect(sql.hasSuffix(";")) - } - - @Test("Add index PostgreSQL skips USING for BTREE") - func addIndexPostgreSQLBTree() throws { - let generator = makeGenerator(dbType: .postgresql) - let index = makeIndex(name: "idx_name", columns: ["name"], type: .btree) - let changes: [SchemaChange] = [.addIndex(index)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("CREATE INDEX")) - #expect(!sql.contains("USING BTREE") || sql.contains("USING")) - } - - @Test("Add unique index") - func addUniqueIndex() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_unique_email", columns: ["email"], isUnique: true) - let changes: [SchemaChange] = [.addIndex(index)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("UNIQUE INDEX")) - } - - @Test("Delete index MySQL uses DROP INDEX ON table") - func deleteIndexMySQL() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_old") - let changes: [SchemaChange] = [.deleteIndex(index)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP INDEX")) - #expect(sql.contains("`idx_old`")) - #expect(sql.contains("ON `users`")) - } - - @Test("Delete index PostgreSQL uses DROP INDEX without ON") - func deleteIndexPostgreSQL() throws { - let generator = makeGenerator(dbType: .postgresql) - let index = makeIndex(name: "idx_old") - let changes: [SchemaChange] = [.deleteIndex(index)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP INDEX")) - #expect(sql.contains("\"idx_old\"")) - #expect(!sql.contains("ON")) - } - - @Test("Modify index generates drop and create in single statement") - func modifyIndex() throws { - let generator = makeGenerator() - let oldIndex = makeIndex(name: "idx_email", columns: ["email"]) - let newIndex = makeIndex(name: "idx_email", columns: ["email", "name"], isUnique: true) - let changes: [SchemaChange] = [.modifyIndex(old: oldIndex, new: newIndex)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP INDEX")) - #expect(sql.contains("CREATE")) - #expect(sql.contains("UNIQUE")) - } - - // MARK: - Foreign Key Tests - - @Test("Add foreign key with all clauses") - func addForeignKey() throws { - let generator = makeGenerator() - let fk = makeFK(name: "fk_user_role", columns: ["role_id"], refTable: "roles", refColumns: ["id"]) - let changes: [SchemaChange] = [.addForeignKey(fk)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("ALTER TABLE")) - #expect(sql.contains("ADD CONSTRAINT")) - #expect(sql.contains("`fk_user_role`")) - #expect(sql.contains("FOREIGN KEY")) - #expect(sql.contains("`role_id`")) - #expect(sql.contains("REFERENCES")) - #expect(sql.contains("`roles`")) - #expect(sql.contains("ON DELETE")) - #expect(sql.contains("ON UPDATE")) - } - - @Test("Delete foreign key MySQL uses DROP FOREIGN KEY") - func deleteForeignKeyMySQL() throws { - let generator = makeGenerator() - let fk = makeFK(name: "fk_old") - let changes: [SchemaChange] = [.deleteForeignKey(fk)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP FOREIGN KEY")) - #expect(sql.contains("`fk_old`")) - } - - @Test("Delete foreign key PostgreSQL uses DROP CONSTRAINT") - func deleteForeignKeyPostgreSQL() throws { - let generator = makeGenerator(dbType: .postgresql) - let fk = makeFK(name: "fk_old") - let changes: [SchemaChange] = [.deleteForeignKey(fk)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP CONSTRAINT")) - #expect(sql.contains("\"fk_old\"")) - } - - @Test("Modify foreign key generates drop and create in single statement") - func modifyForeignKey() throws { - let generator = makeGenerator() - let oldFK = makeFK(name: "fk_role", columns: ["role_id"], refTable: "roles", refColumns: ["id"]) - let newFK = makeFK(name: "fk_role", columns: ["role_id"], refTable: "roles", refColumns: ["role_id"]) - let changes: [SchemaChange] = [.modifyForeignKey(old: oldFK, new: newFK)] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP")) - #expect(sql.contains("ADD CONSTRAINT")) - } - - // MARK: - Primary Key Tests - - @Test("Modify primary key MySQL uses DROP and ADD PRIMARY KEY") - func modifyPrimaryKeyMySQL() throws { - let generator = makeGenerator() - let changes: [SchemaChange] = [.modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"])] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP PRIMARY KEY")) - #expect(sql.contains("ADD PRIMARY KEY")) - #expect(sql.contains("`id`")) - #expect(sql.contains("`tenant_id`")) - } - - @Test("Modify primary key PostgreSQL with custom constraint name") - func modifyPrimaryKeyPostgreSQLCustom() throws { - let generator = makeGenerator(dbType: .postgresql, pkConstraint: "users_pk") - let changes: [SchemaChange] = [.modifyPrimaryKey(old: ["id"], new: ["uuid"])] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP CONSTRAINT")) - #expect(sql.contains("\"users_pk\"")) - #expect(sql.contains("ADD PRIMARY KEY")) - } - - @Test("Modify primary key PostgreSQL with default pkey name") - func modifyPrimaryKeyPostgreSQLDefault() throws { - let generator = makeGenerator(table: "orders", dbType: .postgresql) - let changes: [SchemaChange] = [.modifyPrimaryKey(old: ["id"], new: ["order_id"])] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DROP CONSTRAINT")) - #expect(sql.contains("\"orders_pkey\"")) - #expect(sql.contains("ADD PRIMARY KEY")) - } - - @Test("Modify primary key SQLite throws unsupported") - func modifyPrimaryKeySQLiteThrows() throws { - let generator = makeGenerator(dbType: .sqlite) - let changes: [SchemaChange] = [.modifyPrimaryKey(old: ["id"], new: ["new_id"])] - - #expect(throws: DatabaseError.self) { - try generator.generate(changes: changes) - } - } - - // MARK: - Ordering Tests - - @Test("Dependency ordering FK drops before column drops") - func dependencyOrderingFKBeforeColumn() throws { - let generator = makeGenerator() - let column = makeColumn(name: "role_id") - let fk = makeFK(name: "fk_role", columns: ["role_id"]) - let changes: [SchemaChange] = [ - .deleteColumn(column), - .deleteForeignKey(fk) - ] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 2) - #expect(statements[0].sql.contains("DROP FOREIGN KEY")) - #expect(statements[1].sql.contains("DROP COLUMN")) - } - - @Test("Dependency ordering index adds after PK changes") - func dependencyOrderingIndexAfterPK() throws { - let generator = makeGenerator() - let index = makeIndex(name: "idx_name", columns: ["name"]) - let changes: [SchemaChange] = [ - .addIndex(index), - .modifyPrimaryKey(old: ["id"], new: ["id", "tenant_id"]) - ] - - let statements = try generator.generate(changes: changes) - - let pkIndex = statements.firstIndex { $0.sql.contains("PRIMARY KEY") } - let indexIndex = statements.firstIndex { $0.sql.contains("CREATE INDEX") } - - if let pkIdx = pkIndex, let idxIdx = indexIndex { - #expect(pkIdx < idxIdx) - } - } - - // MARK: - Miscellaneous Tests - - @Test("All statements end with semicolon") - func allStatementsEndWithSemicolon() throws { - let generator = makeGenerator() - let column = makeColumn(name: "field1") - let index = makeIndex(name: "idx_field1", columns: ["field1"]) - let fk = makeFK(name: "fk_field1", columns: ["field1"], refTable: "other", refColumns: ["id"]) - let changes: [SchemaChange] = [ - .addColumn(column), - .addIndex(index), - .addForeignKey(fk) - ] - - let statements = try generator.generate(changes: changes) - - for statement in statements { - #expect(statement.sql.hasSuffix(";")) - } - } - - @Test("Add column is not destructive") - func addColumnNotDestructive() throws { - let generator = makeGenerator() - let column = makeColumn(name: "new_field") - let changes: [SchemaChange] = [.addColumn(column)] - - let statements = try generator.generate(changes: changes) - - #expect(statements[0].isDestructive == false) - } - - // MARK: - S-02: SQLite FK Delete Must Throw - - @Test("Delete foreign key SQLite throws unsupported operation") - func deleteForeignKeySQLiteThrows() throws { - let generator = makeGenerator(dbType: .sqlite) - let fk = makeFK(name: "fk_role") - let changes: [SchemaChange] = [.deleteForeignKey(fk)] - - #expect(throws: DatabaseError.self) { - try generator.generate(changes: changes) - } - } - - @Test("Modify foreign key SQLite throws unsupported operation (contains drop)") - func modifyForeignKeySQLiteThrows() throws { - let generator = makeGenerator(dbType: .sqlite) - let oldFK = makeFK(name: "fk_role", columns: ["role_id"], refTable: "roles", refColumns: ["id"]) - let newFK = makeFK(name: "fk_role", columns: ["role_id"], refTable: "roles", refColumns: ["role_id"]) - let changes: [SchemaChange] = [.modifyForeignKey(old: oldFK, new: newFK)] - - #expect(throws: DatabaseError.self) { - try generator.generate(changes: changes) - } - } - - @Test("Delete foreign key MySQL still works (not affected by SQLite fix)") - func deleteForeignKeyMySQLStillWorks() throws { - let generator = makeGenerator(dbType: .mysql) - let fk = makeFK(name: "fk_role") - let changes: [SchemaChange] = [.deleteForeignKey(fk)] - - let statements = try generator.generate(changes: changes) - #expect(statements.count == 1) - #expect(statements[0].sql.contains("DROP FOREIGN KEY")) - } - - @Test("Delete foreign key PostgreSQL still works (not affected by SQLite fix)") - func deleteForeignKeyPostgreSQLStillWorks() throws { - let generator = makeGenerator(dbType: .postgresql) - let fk = makeFK(name: "fk_role") - let changes: [SchemaChange] = [.deleteForeignKey(fk)] - - let statements = try generator.generate(changes: changes) - #expect(statements.count == 1) - #expect(statements[0].sql.contains("DROP CONSTRAINT")) - } - - @Test("Complex schema change ordering") - func complexSchemaChangeOrdering() throws { - let generator = makeGenerator() - let column = makeColumn(name: "status") - let index = makeIndex(name: "idx_status", columns: ["status"]) - let fk = makeFK(name: "fk_status", columns: ["status"], refTable: "statuses", refColumns: ["id"]) - - let changes: [SchemaChange] = [ - .addColumn(column), - .addIndex(index), - .addForeignKey(fk), - .deleteColumn(makeColumn(name: "old_col")), - .deleteIndex(makeIndex(name: "idx_old")), - .deleteForeignKey(makeFK(name: "fk_old")) - ] - - let statements = try generator.generate(changes: changes) - - #expect(statements.count == 6) - - let fkDropIndex = statements.firstIndex { $0.sql.contains("DROP FOREIGN KEY") } - let colDropIndex = statements.firstIndex { $0.sql.contains("DROP COLUMN") } - let fkAddIndex = statements.firstIndex { $0.sql.contains("ADD CONSTRAINT") } - - if let fkDrop = fkDropIndex, let colDrop = colDropIndex { - #expect(fkDrop < colDrop) - } - - if let colDrop = colDropIndex, let fkAdd = fkAddIndex { - #expect(colDrop < fkAdd) - } - } -} From 935f9e27c5fa540541483d2469eb81b2f321465a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 18:21:46 +0700 Subject: [PATCH 14/15] fix: address PR review feedback for plugin extensibility --- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 49 +---------- .../MongoDBPluginDriver.swift | 26 +++--- Plugins/OracleDriverPlugin/OraclePlugin.swift | 23 +++-- .../RedisDriverPlugin/RedisPluginDriver.swift | 38 +++++++-- .../PluginDatabaseDriver.swift | 83 ++++++++++++++++++- .../SQLStatementGenerator.swift | 26 ++---- .../Formatting/SQLFormatterService.swift | 4 +- .../Services/Query/SQLDialectProvider.swift | 11 +-- .../Views/Main/MainContentCoordinator.swift | 25 +++++- 9 files changed, 177 insertions(+), 108 deletions(-) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 2785d179..d51530f1 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -512,16 +512,9 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } if !deleteChanges.isEmpty { - let hasPk = deleteChanges.allSatisfy { change in - change.cellChanges.contains { $0.columnName == columns.first } - } - if hasPk, let batchStmt = generateMssqlBatchDelete(table: table, columns: columns, changes: deleteChanges) { - statements.append(batchStmt) - } else { - for change in deleteChanges { - if let stmt = generateMssqlDelete(table: table, columns: columns, change: change) { - statements.append(stmt) - } + for change in deleteChanges { + if let stmt = generateMssqlDelete(table: table, columns: columns, change: change) { + statements.append(stmt) } } } @@ -594,42 +587,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (statement: sql, parameters: parameters) } - private func generateMssqlBatchDelete( - table: String, - columns: [String], - changes: [PluginRowChange] - ) -> (statement: String, parameters: [String?])? { - guard !changes.isEmpty else { return nil } - - let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" - var parameters: [String?] = [] - var conditions: [String] = [] - - for change in changes { - guard let originalRow = change.originalRow else { return nil } - var rowConditions: [String] = [] - for (index, columnName) in columns.enumerated() { - guard index < originalRow.count else { continue } - let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" - if let value = originalRow[index] { - parameters.append(value) - rowConditions.append("\(col) = ?") - } else { - rowConditions.append("\(col) IS NULL") - } - } - if !rowConditions.isEmpty { - conditions.append("(\(rowConditions.joined(separator: " AND ")))") - } - } - - guard !conditions.isEmpty else { return nil } - - let whereClause = conditions.joined(separator: " OR ") - let sql = "DELETE FROM \(escapedTable) WHERE \(whereClause)" - return (statement: sql, parameters: parameters) - } - private func generateMssqlDelete( table: String, columns: [String], diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index a05a904a..93ddd970 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -440,12 +440,12 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { func buildExplainQuery(_ sql: String) -> String? { guard let operation = try? MongoShellParser.parse(sql) else { - return "db.runCommand({\"explain\": \"\(sql)\", \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": \"\(escapeJsonString(sql))\", \"verbosity\": \"executionStats\"})" } switch operation { case .find(let collection, let filter, let options): - var findDoc = "\"find\": \"\(collection)\", \"filter\": \(filter)" + var findDoc = "\"find\": \"\(escapeJsonString(collection))\", \"filter\": \(filter)" if let sort = options.sort { findDoc += ", \"sort\": \(sort)" } @@ -461,40 +461,40 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { return "db.runCommand({\"explain\": {\(findDoc)}, \"verbosity\": \"executionStats\"})" case .findOne(let collection, let filter): - return "db.runCommand({\"explain\": {\"find\": \"\(collection)\", \"filter\": \(filter), \"limit\": 1}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"find\": \"\(escapeJsonString(collection))\", \"filter\": \(filter), \"limit\": 1}, \"verbosity\": \"executionStats\"})" case .aggregate(let collection, let pipeline): - return "db.runCommand({\"explain\": {\"aggregate\": \"\(collection)\", \"pipeline\": \(pipeline), \"cursor\": {}}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"aggregate\": \"\(escapeJsonString(collection))\", \"pipeline\": \(pipeline), \"cursor\": {}}, \"verbosity\": \"executionStats\"})" case .countDocuments(let collection, let filter): - return "db.runCommand({\"explain\": {\"count\": \"\(collection)\", \"query\": \(filter)}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"count\": \"\(escapeJsonString(collection))\", \"query\": \(filter)}, \"verbosity\": \"executionStats\"})" case .deleteOne(let collection, let filter): - return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 1}]}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"delete\": \"\(escapeJsonString(collection))\", \"deletes\": [{\"q\": \(filter), \"limit\": 1}]}, \"verbosity\": \"executionStats\"})" case .deleteMany(let collection, let filter): - return "db.runCommand({\"explain\": {\"delete\": \"\(collection)\", \"deletes\": [{\"q\": \(filter), \"limit\": 0}]}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"delete\": \"\(escapeJsonString(collection))\", \"deletes\": [{\"q\": \(filter), \"limit\": 0}]}, \"verbosity\": \"executionStats\"})" case .updateOne(let collection, let filter, let update): - return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": false}]}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"update\": \"\(escapeJsonString(collection))\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": false}]}, \"verbosity\": \"executionStats\"})" case .updateMany(let collection, let filter, let update): - return "db.runCommand({\"explain\": {\"update\": \"\(collection)\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": true}]}, \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": {\"update\": \"\(escapeJsonString(collection))\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": true}]}, \"verbosity\": \"executionStats\"})" case .findOneAndUpdate(let collection, let filter, let update): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(update)" + let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(update)" return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" case .findOneAndReplace(let collection, let filter, let replacement): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"update\": \(replacement)" + let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(replacement)" return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" case .findOneAndDelete(let collection, let filter): - let cmd = "\"findAndModify\": \"\(collection)\", \"query\": \(filter), \"remove\": true" + let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"remove\": true" return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" default: - return "db.runCommand({\"explain\": \"\(sql)\", \"verbosity\": \"executionStats\"})" + return "db.runCommand({\"explain\": \"\(escapeJsonString(sql))\", \"verbosity\": \"executionStats\"})" } } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 6c2f5472..912c30fa 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -630,21 +630,26 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], values: [String?] ) -> (statement: String, parameters: [String?])? { - var nonDefaultColumns: [String] = [] + var insertColumns: [String] = [] + var valuesSQL: [String] = [] var parameters: [String?] = [] for (index, value) in values.enumerated() { - if value == "__DEFAULT__" { continue } guard index < columns.count else { continue } - nonDefaultColumns.append(escapeOracleIdentifier(columns[index])) - parameters.append(value) + insertColumns.append(escapeOracleIdentifier(columns[index])) + if value == "__DEFAULT__" { + valuesSQL.append("DEFAULT") + } else { + valuesSQL.append("?") + parameters.append(value) + } } - guard !nonDefaultColumns.isEmpty else { return nil } + guard !insertColumns.isEmpty else { return nil } - let columnList = nonDefaultColumns.joined(separator: ", ") - let placeholders = parameters.map { _ in "?" }.joined(separator: ", ") - let sql = "INSERT INTO \(escapeOracleIdentifier(table)) (\(columnList)) VALUES (\(placeholders))" + let columnList = insertColumns.joined(separator: ", ") + let valueList = valuesSQL.joined(separator: ", ") + let sql = "INSERT INTO \(escapeOracleIdentifier(table)) (\(columnList)) VALUES (\(valueList))" return (statement: sql, parameters: parameters) } @@ -910,7 +915,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "\(quoted) BETWEEN \(v1) AND \(v2)" case "REGEX": let escaped = value.replacingOccurrences(of: "'", with: "''") - return "\(quoted) LIKE '%\(escaped)%'" + return "REGEXP_LIKE(\(quoted), '\(escaped)')" default: return nil } } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 009edc82..98fde221 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -362,15 +362,39 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - EXPLAIN func buildExplainQuery(_ sql: String) -> String? { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty } - - if parts.count >= 2 { - let key = parts[1] - return "DEBUG OBJECT \(key)" + guard let operation = try? RedisCommandParser.parse(sql) else { + return nil } - return "INFO commandstats" + let key: String? = { + switch operation { + case .get(let k), .type(let k), .ttl(let k), .pttl(let k), + .expire(let k, _), .persist(let k), + .hget(let k, _), .hgetall(let k), .hdel(let k, _), + .lrange(let k, _, _), .llen(let k), + .smembers(let k), .scard(let k), + .zrange(let k, _, _, _), .zcard(let k), + .xrange(let k, _, _, _), .xlen(let k): + return k + case .set(let k, _, _): + return k + case .hset(let k, _): + return k + case .lpush(let k, _), .rpush(let k, _): + return k + case .sadd(let k, _), .srem(let k, _): + return k + case .zadd(let k, _), .zrem(let k, _): + return k + case .del(let keys) where keys.count == 1: + return keys[0] + default: + return nil + } + }() + + guard let key else { return nil } + return "DEBUG OBJECT \(key)" } // MARK: - Query Building diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index a824605a..4662da83 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -222,6 +222,19 @@ public extension PluginDatabaseDriver { guard !parameters.isEmpty else { return try await execute(query: query) } + + let sql: String + switch parameterStyle { + case .questionMark: + sql = Self.substituteQuestionMarks(query: query, parameters: parameters) + case .dollar: + sql = Self.substituteDollarParams(query: query, parameters: parameters) + } + + return try await execute(query: sql) + } + + private static func substituteQuestionMarks(query: String, parameters: [String?]) -> String { var sql = "" var paramIndex = 0 var inSingleQuote = false @@ -249,7 +262,7 @@ public extension PluginDatabaseDriver { if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { if let value = parameters[paramIndex] { - sql.append(Self.escapedParameterValue(value)) + sql.append(escapedParameterValue(value)) } else { sql.append("NULL") } @@ -259,7 +272,73 @@ public extension PluginDatabaseDriver { } } - return try await execute(query: sql) + return sql + } + + private static func substituteDollarParams(query: String, parameters: [String?]) -> String { + let nsQuery = query as NSString + let length = nsQuery.length + var sql = "" + var i = 0 + var inSingleQuote = false + var inDoubleQuote = false + var isEscaped = false + + while i < length { + let char = nsQuery.character(at: i) + + if isEscaped { + isEscaped = false + sql.append(Character(UnicodeScalar(char)!)) + i += 1 + continue + } + + let backslash: UInt16 = 0x5C // \\ + if char == backslash && (inSingleQuote || inDoubleQuote) { + isEscaped = true + sql.append(Character(UnicodeScalar(char)!)) + i += 1 + continue + } + + let singleQuote: UInt16 = 0x27 // ' + let doubleQuote: UInt16 = 0x22 // " + if char == singleQuote && !inDoubleQuote { + inSingleQuote.toggle() + } else if char == doubleQuote && !inSingleQuote { + inDoubleQuote.toggle() + } + + let dollar: UInt16 = 0x24 // $ + if char == dollar && !inSingleQuote && !inDoubleQuote { + var numStr = "" + var j = i + 1 + while j < length { + let digitChar = nsQuery.character(at: j) + if digitChar >= 0x30 && digitChar <= 0x39 { // 0-9 + numStr.append(Character(UnicodeScalar(digitChar)!)) + j += 1 + } else { + break + } + } + if !numStr.isEmpty, let paramNum = Int(numStr), paramNum >= 1, paramNum <= parameters.count { + if let value = parameters[paramNum - 1] { + sql.append(escapedParameterValue(value)) + } else { + sql.append("NULL") + } + i = j + continue + } + } + + sql.append(Character(UnicodeScalar(char)!)) + i += 1 + } + + return sql } /// Escape a parameter value for safe interpolation into SQL. diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 587ae460..57bec156 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -147,10 +147,10 @@ struct SQLStatementGenerator { -> ParameterizedStatement? { var nonDefaultColumns: [String] = [] - var parameters: [Any?] = [] + var placeholderParts: [String] = [] + var bindParameters: [Any?] = [] for (index, value) in values.enumerated() { - // Skip DEFAULT columns - let DB handle them if value == "__DEFAULT__" { continue } guard index < columns.count else { continue } @@ -160,35 +160,25 @@ struct SQLStatementGenerator { if let val = value { if isSQLFunctionExpression(val) { - // SQL function - cannot parameterize, use literal - // This is safe because we validate it's a known SQL function - parameters.append( - SQLFunctionLiteral(val.trimmingCharacters(in: .whitespaces).uppercased())) + placeholderParts.append(val.trimmingCharacters(in: .whitespaces).uppercased()) } else { - parameters.append(val) + bindParameters.append(val) + placeholderParts.append(placeholder(at: bindParameters.count - 1)) } } else { - parameters.append(nil) + bindParameters.append(nil) + placeholderParts.append(placeholder(at: bindParameters.count - 1)) } } - // If all columns are DEFAULT, don't generate INSERT guard !nonDefaultColumns.isEmpty else { return nil } let columnList = nonDefaultColumns.joined(separator: ", ") - let placeholders = parameters.enumerated().map { index, param in - if let funcLiteral = param as? SQLFunctionLiteral { - return funcLiteral.value - } - return placeholder(at: index) - }.joined(separator: ", ") + let placeholders = placeholderParts.joined(separator: ", ") let sql = "INSERT INTO \(databaseType.quoteIdentifier(tableName)) (\(columnList)) VALUES (\(placeholders))" - // Filter out SQL function literals from parameters - let bindParameters = parameters.filter { !($0 is SQLFunctionLiteral) } - return ParameterizedStatement(sql: sql, parameters: bindParameters) } diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 5774aa50..df79650a 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -110,7 +110,7 @@ struct SQLFormatterService: SQLFormatterProtocol { return cached } - let provider = SQLDialectFactory.createDialect(for: dialect) + let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } let allKeywords = provider.keywords.union(provider.functions).union(provider.dataTypes) let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) } let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b" @@ -149,7 +149,7 @@ struct SQLFormatterService: SQLFormatterProtocol { } // Get dialect provider - let dialectProvider = SQLDialectFactory.createDialect(for: dialect) + let dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } // Format the SQL let formatted = formatSQL(sql, dialect: dialectProvider, databaseType: dialect, options: options) diff --git a/TablePro/Core/Services/Query/SQLDialectProvider.swift b/TablePro/Core/Services/Query/SQLDialectProvider.swift index dcaa8554..e64ef7b4 100644 --- a/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -36,16 +36,11 @@ private struct EmptyDialect: SQLDialectProvider { // MARK: - Dialect Factory struct SQLDialectFactory { + @MainActor static func createDialect(for databaseType: DatabaseType) -> SQLDialectProvider { - if Thread.isMainThread { - return MainActor.assumeIsolated { - if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { - return PluginDialectAdapter(descriptor: descriptor) - } - return EmptyDialect() - } + if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { + return PluginDialectAdapter(descriptor: descriptor) } - return EmptyDialect() } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 4e341a2f..d18431a8 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -673,9 +673,29 @@ final class MainContentCoordinator { let statements = SQLStatementScanner.allStatements(in: trimmed) guard let stmt = statements.first else { return } + let level = connection.safeModeLevel + let needsConfirmation = level.appliesToAllQueries && level.requiresConfirmation + // ClickHouse interactive explain gets special handling if connection.type == .clickhouse { - runClickHouseExplain(variant: .plan) + if needsConfirmation { + Task { @MainActor in + let window = NSApp.keyWindow + let permission = await SafeModeGuard.checkPermission( + level: level, + isWriteOperation: false, + sql: "EXPLAIN", + operationDescription: String(localized: "Execute Query"), + window: window, + databaseType: connection.type + ) + if case .allowed = permission { + runClickHouseExplain(variant: .plan) + } + } + } else { + runClickHouseExplain(variant: .plan) + } return } @@ -684,8 +704,7 @@ final class MainContentCoordinator { return } - let level = connection.safeModeLevel - if level.appliesToAllQueries && level.requiresConfirmation { + if needsConfirmation { Task { @MainActor in let window = NSApp.keyWindow let permission = await SafeModeGuard.checkPermission( From 3295bee5fac7f9172e37fdda7390d1f39c55a0a7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 18:29:01 +0700 Subject: [PATCH 15/15] fix: address PR review feedback for plugin extensibility --- .../PluginDatabaseDriver.swift | 4 +- .../Core/Plugins/PluginDriverAdapter.swift | 4 +- .../SchemaStatementGenerator.swift | 8 ++- .../Services/Query/TableQueryBuilder.swift | 8 --- .../ClickHouse/ClickHouseDialectTests.swift | 26 ++++++-- .../SchemaStatementGeneratorPluginTests.swift | 60 ++++++++++--------- 6 files changed, 63 insertions(+), 47 deletions(-) diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 4662da83..d7848038 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -100,7 +100,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func generateDropIndexSQL(table: String, indexName: String) -> String? func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? func generateDropForeignKeySQL(table: String, constraintName: String) -> String? - func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? // Table operations (optional — return nil to use app-level fallback) func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? @@ -209,7 +209,7 @@ public extension PluginDatabaseDriver { func generateDropIndexSQL(table: String, indexName: String) -> String? { nil } func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil } func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } - func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { nil } + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { nil } func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 24cc0665..e110db79 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -328,8 +328,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.generateDropForeignKeySQL(table: table, constraintName: constraintName) } - func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { - pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns) + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { + pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns, constraintName: constraintName) } // MARK: - Table Operations diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 39261715..3ceae1cc 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -45,7 +45,11 @@ struct SchemaStatementGenerator { for change in sortedChanges { guard let stmt = try generateStatement(for: change) else { - continue + throw NSError( + domain: "SchemaStatementGenerator", + code: -1, + userInfo: [NSLocalizedDescriptionKey: String(localized: "Unsupported schema operation: \(change.description)")] + ) } let sql = stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";" statements.append(SchemaStatement(sql: sql, description: stmt.description, isDestructive: stmt.isDestructive)) @@ -222,7 +226,7 @@ struct SchemaStatementGenerator { private func generateModifyPrimaryKey(old: [String], new: [String]) -> SchemaStatement? { guard let sqls = pluginDriver.generateModifyPrimaryKeySQL( - table: tableName, oldColumns: old, newColumns: new + table: tableName, oldColumns: old, newColumns: new, constraintName: primaryKeyConstraintName ) else { return nil } diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index c82a1254..e46e3c5f 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -141,10 +141,6 @@ struct TableQueryBuilder { columnName: String, ascending: Bool ) -> String { - if pluginDriver != nil { - return baseQuery - } - var query = removeOrderBy(from: baseQuery) let direction = ascending ? "ASC" : "DESC" let quotedColumn = databaseType.quoteIdentifier(columnName) @@ -175,10 +171,6 @@ struct TableQueryBuilder { sortState: SortState, columns: [String] ) -> String { - if pluginDriver != nil { - return baseQuery - } - var query = removeOrderBy(from: baseQuery) if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { diff --git a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift index 16fd4e03..ec37f193 100644 --- a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift +++ b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift @@ -2,20 +2,38 @@ // ClickHouseDialectTests.swift // TableProTests // -// Tests for ClickHouse dialect via plugin-provided SQLDialectFactory +// Tests for ClickHouse dialect descriptor structure // import Foundation import Testing @testable import TablePro +import TableProPluginKit @Suite("ClickHouse Dialect") struct ClickHouseDialectTests { - @Test("Factory creates dialect for .clickhouse") + @Test("SQLDialectDescriptor with ClickHouse-style config") + func testClickHouseDialectDescriptor() { + let descriptor = SQLDialectDescriptor( + identifierQuote: "`", + keywords: ["SELECT", "FINAL", "PREWHERE", "SAMPLE", "ENGINE"], + functions: ["UNIQ", "ARGMIN", "TOPK"], + dataTypes: ["UInt32", "String", "DateTime"] + ) + let adapter = PluginDialectAdapter(descriptor: descriptor) + + #expect(adapter.identifierQuote == "`") + #expect(adapter.keywords.contains("FINAL")) + #expect(adapter.functions.contains("UNIQ")) + #expect(adapter.dataTypes.contains("UInt32")) + } + + @Test("Factory returns empty dialect when plugin not loaded") @MainActor - func testFactoryCreatesDialect() { + func testFactoryFallbackWithoutPlugin() { let dialect = SQLDialectFactory.createDialect(for: .clickhouse) - #expect(dialect.identifierQuote == "`" || dialect.identifierQuote == "\"") + // Without plugin loaded, factory returns empty fallback + #expect(dialect.keywords.isEmpty) } } diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift index b3e4cc7c..53f15d33 100644 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -52,7 +52,7 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable dropForeignKeyHandler?(table, constraintName) } - func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { modifyPrimaryKeyHandler?(table, oldColumns, newColumns) } @@ -144,36 +144,39 @@ struct SchemaStatementGeneratorPluginTests { ) } - // MARK: - Nil Return Tests (plugin returns nil -> change skipped) + // MARK: - Nil Return Tests (plugin returns nil -> throws error) - @Test("Add column is skipped when plugin returns nil") - func addColumnSkippedWhenNil() throws { + @Test("Add column throws when plugin returns nil") + func addColumnThrowsWhenNil() throws { let mock = MockPluginDriver() let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() - let stmts = try generator.generate(changes: [.addColumn(column)]) - #expect(stmts.isEmpty) + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.addColumn(column)]) + } } - @Test("Drop column is skipped when plugin returns nil") - func dropColumnSkippedWhenNil() throws { + @Test("Drop column throws when plugin returns nil") + func dropColumnThrowsWhenNil() throws { let mock = MockPluginDriver() let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let column = makeColumn() - let stmts = try generator.generate(changes: [.deleteColumn(column)]) - #expect(stmts.isEmpty) + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.deleteColumn(column)]) + } } - @Test("Add index is skipped when plugin returns nil") - func addIndexSkippedWhenNil() throws { + @Test("Add index throws when plugin returns nil") + func addIndexThrowsWhenNil() throws { let mock = MockPluginDriver() let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let index = makeIndex() - let stmts = try generator.generate(changes: [.addIndex(index)]) - #expect(stmts.isEmpty) + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.addIndex(index)]) + } } // MARK: - Plugin Override Tests @@ -304,27 +307,25 @@ struct SchemaStatementGeneratorPluginTests { // MARK: - Mixed Override/Nil - @Test("Plugin overrides some operations while others are skipped") - func mixedPluginAndSkipped() throws { + @Test("Mixed operations: throws when any plugin returns nil") + func mixedPluginAndNil() throws { let mock = MockPluginDriver() mock.addColumnHandler = { _, col in "PLUGIN_ADD_COL \(col.name)" } - // dropColumnHandler is nil, so drop is skipped + // dropColumnHandler is nil, so drop throws let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let addCol = makeColumn(name: "age", dataType: "INT") let dropCol = makeColumn(name: "old_field") - let stmts = try generator.generate(changes: [ - .addColumn(addCol), - .deleteColumn(dropCol) - ]) - - // Only the add column statement is generated (drop was skipped) - #expect(stmts.count == 1) - #expect(stmts[0].sql.contains("PLUGIN_ADD_COL")) + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [ + .addColumn(addCol), + .deleteColumn(dropCol) + ]) + } } // MARK: - Modify Index/FK (drop+recreate via plugin) @@ -349,8 +350,8 @@ struct SchemaStatementGeneratorPluginTests { #expect(stmts[0].sql.contains("CREATE INDEX")) } - @Test("Modify index is skipped when drop returns nil") - func modifyIndexSkippedWhenDropNil() throws { + @Test("Modify index throws when drop returns nil") + func modifyIndexThrowsWhenDropNil() throws { let mock = MockPluginDriver() mock.addIndexHandler = { table, idx in "CREATE INDEX \(idx.name) ON \(table)" @@ -360,9 +361,10 @@ struct SchemaStatementGeneratorPluginTests { let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) let oldIndex = makeIndex(name: "idx_email") let newIndex = makeIndex(name: "idx_email", columns: ["email", "name"]) - let stmts = try generator.generate(changes: [.modifyIndex(old: oldIndex, new: newIndex)]) - #expect(stmts.isEmpty) + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.modifyIndex(old: oldIndex, new: newIndex)]) + } } @Test("Modify foreign key generates drop and create via plugin")