diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf3af79..2e765b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ 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 +- DDL schema generation protocol in TableProPluginKit: plugins can now optionally provide database-specific ALTER TABLE syntax (ADD/MODIFY/DROP COLUMN, ADD/DROP INDEX, ADD/DROP FK, MODIFY PK) via `PluginDatabaseDriver`, with `SchemaStatementGenerator` trying plugin methods first before falling back to built-in logic +- Plugin-provided table operations: `truncateTableStatements`, `dropObjectStatement`, `foreignKeyDisableStatements`, `foreignKeyEnableStatements` in `PluginDatabaseDriver` protocol, allowing plugins to override TRUNCATE, DROP, and FK handling SQL +- `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 @@ -21,6 +25,12 @@ 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 + +### 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 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 378a9ba5..00757c4a 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) } @@ -499,6 +544,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() @@ -525,6 +692,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..94de4d07 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) } @@ -324,6 +370,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 @@ -764,6 +811,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/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 899914cf..d51530f1 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) } @@ -435,6 +478,144 @@ 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 { + 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 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() } @@ -967,6 +1148,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/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/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index ecbddb24..93ddd970 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\": \"\(escapeJsonString(sql))\", \"verbosity\": \"executionStats\"})" + } + + switch operation { + case .find(let collection, let filter, let options): + var findDoc = "\"find\": \"\(escapeJsonString(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\": \"\(escapeJsonString(collection))\", \"filter\": \(filter), \"limit\": 1}, \"verbosity\": \"executionStats\"})" + + case .aggregate(let collection, let pipeline): + return "db.runCommand({\"explain\": {\"aggregate\": \"\(escapeJsonString(collection))\", \"pipeline\": \(pipeline), \"cursor\": {}}, \"verbosity\": \"executionStats\"})" + + case .countDocuments(let collection, let filter): + return "db.runCommand({\"explain\": {\"count\": \"\(escapeJsonString(collection))\", \"query\": \(filter)}, \"verbosity\": \"executionStats\"})" + + case .deleteOne(let collection, let filter): + 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\": \"\(escapeJsonString(collection))\", \"deletes\": [{\"q\": \(filter), \"limit\": 0}]}, \"verbosity\": \"executionStats\"})" + + case .updateOne(let collection, let filter, let update): + 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\": \"\(escapeJsonString(collection))\", \"updates\": [{\"q\": \(filter), \"u\": \(update), \"multi\": true}]}, \"verbosity\": \"executionStats\"})" + + case .findOneAndUpdate(let collection, let filter, let 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\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(replacement)" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + + case .findOneAndDelete(let collection, let filter): + let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"remove\": true" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + + default: + return "db.runCommand({\"explain\": \"\(escapeJsonString(sql))\", \"verbosity\": \"executionStats\"})" + } + } + // MARK: - Query Building func buildBrowseQuery( 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/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/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 142c6c5c..912c30fa 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) } @@ -541,6 +585,138 @@ 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 insertColumns: [String] = [] + var valuesSQL: [String] = [] + var parameters: [String?] = [] + + for (index, value) in values.enumerated() { + guard index < columns.count else { continue } + insertColumns.append(escapeOracleIdentifier(columns[index])) + if value == "__DEFAULT__" { + valuesSQL.append("DEFAULT") + } else { + valuesSQL.append("?") + parameters.append(value) + } + } + + guard !insertColumns.isEmpty else { return nil } + + let columnList = insertColumns.joined(separator: ", ") + let valueList = valuesSQL.joined(separator: ", ") + let sql = "INSERT INTO \(escapeOracleIdentifier(table)) (\(columnList)) VALUES (\(valueList))" + 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 { @@ -549,6 +725,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 "REGEXP_LIKE(\(quoted), '\(escaped)')" + default: return nil + } + } + // MARK: - Private Helpers private func buildOracleFullType( 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/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 35994236..60746992 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 @@ -159,6 +160,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..6b68a72c 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 @@ -158,6 +159,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/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/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index cd12c1fb..98fde221 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -359,6 +359,44 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await conn.selectDatabase(dbIndex) } + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + guard let operation = try? RedisCommandParser.parse(sql) else { + return nil + } + + 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 func buildBrowseQuery( diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3d0b7e6c..4c619571 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) } @@ -350,6 +387,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/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/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d0a9492d..d7848038 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? @@ -85,6 +91,25 @@ 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], constraintName: String?) -> [String]? + + // 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]? + + // EXPLAIN query building (optional) + func buildExplainQuery(_ sql: String) -> String? } public extension PluginDatabaseDriver { @@ -120,6 +145,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]] { @@ -175,10 +202,39 @@ 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], 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 } + func foreignKeyDisableStatements() -> [String]? { nil } + func foreignKeyEnableStatements() -> [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) } + + 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 @@ -206,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") } @@ -216,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/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/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/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..57bec156 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 @@ -66,8 +91,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 +119,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 ? + switch parameterStyle { + case .dollar: + return "$\(index + 1)" + case .questionMark: + return "?" } } @@ -124,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 } @@ -137,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) } @@ -259,16 +272,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 +299,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 +332,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 +367,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) } 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/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 614354c7..45f096a4 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,14 @@ final class DatabaseManager { driver: driver ) - // Generate SQL statements + guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { + throw DatabaseError.unsupportedOperation + } + 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..e110db79 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -13,10 +13,31 @@ 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 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 + ) + } + + /// The underlying plugin driver, exposed for DDL schema generation delegation. + var schemaPluginDriver: any PluginDatabaseDriver { pluginDriver } + + 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 { @@ -273,6 +294,68 @@ 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], constraintName: String?) -> [String]? { + pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns, constraintName: constraintName) + } + + // 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: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + pluginDriver.buildExplainQuery(sql) + } + // MARK: - Result Mapping private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { 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/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 93972e7a..3ceae1cc 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -3,10 +3,11 @@ // 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 +import TableProPluginKit /// A schema SQL statement with metadata struct SchemaStatement { @@ -15,32 +16,41 @@ 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? - init(tableName: String, databaseType: DatabaseType, primaryKeyConstraintName: String? = nil) { + /// Plugin driver for database-specific DDL generation. + private let pluginDriver: any PluginDatabaseDriver + + init( + tableName: String, + primaryKeyConstraintName: String? = nil, + pluginDriver: any PluginDatabaseDriver + ) { self.tableName = tableName - self.databaseType = databaseType self.primaryKeyConstraintName = primaryKeyConstraintName + self.pluginDriver = pluginDriver } /// Generate all SQL statements from schema changes 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 { + 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)) } @@ -60,8 +70,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] = [] @@ -72,10 +82,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) @@ -97,364 +105,77 @@ 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 { - 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 - ) - } - - private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) throws -> SchemaStatement { - 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: - // 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 - ) - - - 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 generateAddColumn(_ column: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: toPluginColumnDefinition(column)) else { + return nil } + return SchemaStatement(sql: sql, description: "Add column '\(column.name)'", isDestructive: false) } - private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement { - let tableQuoted = databaseType.quoteIdentifier(tableName) - let columnQuoted = databaseType.quoteIdentifier(column.name) - - let sql = "ALTER TABLE \(tableQuoted) DROP COLUMN \(columnQuoted)" + private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateModifyColumnSQL( + table: tableName, + oldColumn: toPluginColumnDefinition(old), + newColumn: toPluginColumnDefinition(new) + ) else { + return nil + } return SchemaStatement( sql: sql, - description: "Drop column '\(column.name)'", - isDestructive: true + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType ) } - // 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") - } + private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateDropColumnSQL(table: tableName, columnName: column.name) else { + return nil } - - // 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 - } - } - - 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 { - 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)'", @@ -462,60 +183,31 @@ struct SchemaStatementGenerator { ) } - private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement { - 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 { - 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 - ) + private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) -> SchemaStatement? { + guard let sql = pluginDriver.generateAddForeignKeySQL( + table: tableName, + fk: toPluginForeignKeyDefinition(fk) + ) else { + return nil + } + 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)'", @@ -523,74 +215,62 @@ struct SchemaStatementGenerator { ) } - private func generateDeleteForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { - 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 { - 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 + private func generateModifyPrimaryKey(old: [String], new: [String]) -> SchemaStatement? { + guard let sqls = pluginDriver.generateModifyPrimaryKeySQL( + table: tableName, oldColumns: old, newColumns: new, constraintName: primaryKeyConstraintName + ) else { + return nil } - + 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 ) } + + // 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/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 7330f923..e64ef7b4 100644 --- a/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -6,497 +6,41 @@ // import Foundation +import TableProPluginKit -// MARK: - MySQL/MariaDB Dialect +// MARK: - Plugin Dialect Adapter -struct MySQLDialect: SQLDialectProvider { - let identifierQuote = "`" +struct PluginDialectAdapter: SQLDialectProvider { + let identifierQuote: String + let keywords: Set + let functions: Set + let dataTypes: Set - 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" - ] + init(descriptor: SQLDialectDescriptor) { + self.identifierQuote = descriptor.identifierQuote + self.keywords = descriptor.keywords + self.functions = descriptor.functions + self.dataTypes = descriptor.dataTypes + } } -// MARK: - DuckDB Dialect +// MARK: - Empty Dialect -struct DuckDBDialect: SQLDialectProvider { +private struct EmptyDialect: 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" - ] + let keywords: Set = [] + let functions: Set = [] + let dataTypes: Set = [] } // MARK: - Dialect Factory struct SQLDialectFactory { - /// Create a dialect provider for the given database type + @MainActor static func createDialect(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() + if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { + return PluginDialectAdapter(descriptor: descriptor) } + return EmptyDialect() } } diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index e889d469..e46e3c5f 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 MongoDB, Redis, and any future NoSQL plugins) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildBrowseQuery( @@ -55,24 +46,9 @@ 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)" - // Add ORDER BY if sort state is valid if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { query += " \(orderBy)" } @@ -81,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], @@ -100,7 +66,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -115,58 +80,10 @@ 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)" - - // 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, @@ -175,7 +92,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildQuickSearchQuery( @@ -186,55 +102,10 @@ 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)" - - // 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], @@ -246,7 +117,6 @@ struct TableQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - // Try plugin dispatch first (handles MongoDB, Redis, and any future NoSQL plugins) if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) let filterTuples = filters @@ -262,92 +132,29 @@ 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)" - - // 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 - } - var query = removeOrderBy(from: baseQuery) let direction = ascending ? "ASC" : "DESC" 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 - } - 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 } @@ -411,7 +204,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 } @@ -427,7 +219,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 @@ -437,257 +228,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 '\\'" - } - } - - // 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/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 d4e01e3f..aae7310f 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. @@ -30,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 } @@ -45,7 +49,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 +62,13 @@ 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)) + 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 @@ -69,75 +81,47 @@ extension MainContentCoordinator { // MARK: - Foreign Key Handling - /// Returns SQL statements to disable foreign key checks for the database type. - /// - Note: PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead. func fkDisableStatements(for dbType: DatabaseType) -> [String] { - 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. func fkEnableStatements(for dbType: DatabaseType) -> [String] { - 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. - /// - Note: SQLite uses DELETE and resets auto-increment via sqlite_sequence. - private func truncateStatements(tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType) -> [String] { - 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"] + private func truncateStatements( + tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType + ) -> [String] { + 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. - private func dropTableStatement(tableName: String, quotedName: String, isView: Bool, options: TableOperationOptions, dbType: DatabaseType) -> String { + private func dropTableStatement( + tableName: String, quotedName: String, isView: Bool, + options: TableOperationOptions, dbType: DatabaseType + ) -> String { let keyword = isView ? "VIEW" : "TABLE" - 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 0394519f..d18431a8 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 } @@ -673,26 +673,38 @@ 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: + let level = connection.safeModeLevel + let needsConfirmation = level.appliesToAllQueries && level.requiresConfirmation + + // ClickHouse interactive explain gets special handling + if connection.type == .clickhouse { + 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 - case .clickhouse: - runClickHouseExplain(variant: .plan) + } + + guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, + let explainSQL = adapter.buildExplainQuery(stmt) else { 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) } - let level = connection.safeModeLevel - if level.appliesToAllQueries && level.requiresConfirmation { + if needsConfirmation { Task { @MainActor in let window = NSApp.keyWindow let permission = await SafeModeGuard.checkPermission( diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index f01e512c..191d7edd 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,15 @@ struct TableStructureView: View { return } + 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 ) do { 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("?")) + } +} diff --git a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift index 4c25c934..ec37f193 100644 --- a/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift +++ b/TableProTests/Core/ClickHouse/ClickHouseDialectTests.swift @@ -2,107 +2,38 @@ // ClickHouseDialectTests.swift // TableProTests // -// Tests for ClickHouseDialect and SQLDialectFactory integration +// Tests for ClickHouse dialect descriptor structure // import Foundation import Testing @testable import TablePro +import TableProPluginKit @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("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 testFactoryFallbackWithoutPlugin() { + let dialect = SQLDialectFactory.createDialect(for: .clickhouse) + // Without plugin loaded, factory returns empty fallback + #expect(dialect.keywords.isEmpty) } } 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) + } +} diff --git a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift new file mode 100644 index 00000000..1846c0b0 --- /dev/null +++ b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift @@ -0,0 +1,83 @@ +// +// 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")) + } +} 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 new file mode 100644 index 00000000..53f15d33 --- /dev/null +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -0,0 +1,499 @@ +// +// 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 (operation skipped), 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], constraintName: 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: - Nil Return Tests (plugin returns nil -> throws error) + + @Test("Add column throws when plugin returns nil") + func addColumnThrowsWhenNil() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let column = makeColumn() + + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.addColumn(column)]) + } + } + + @Test("Drop column throws when plugin returns nil") + func dropColumnThrowsWhenNil() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let column = makeColumn() + + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.deleteColumn(column)]) + } + } + + @Test("Add index throws when plugin returns nil") + func addIndexThrowsWhenNil() throws { + let mock = MockPluginDriver() + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let index = makeIndex() + + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.addIndex(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", pluginDriver: mock) + let column = makeColumn() + let stmts = try generator.generate(changes: [.addColumn(column)]) + + #expect(stmts.count == 1) + #expect(stmts[0].sql.contains("CUSTOM_SYNTAX")) + } + + @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", 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", 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", 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", 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", 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", 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", 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/Nil + + @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 throws + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + + let addCol = makeColumn(name: "age", dataType: "INT") + let dropCol = makeColumn(name: "old_field") + + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [ + .addColumn(addCol), + .deleteColumn(dropCol) + ]) + } + } + + // 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 throws when drop returns nil") + func modifyIndexThrowsWhenDropNil() 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"]) + + #expect(throws: (any Error).self) { + _ = try generator.generate(changes: [.modifyIndex(old: oldIndex, new: newIndex)]) + } + } + + @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"]) + + 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: []) + + #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) - } - } -} 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`"]) + } +}