diff --git a/TablePro/Core/Services/SQLDialectProvider.swift b/TablePro/Core/Services/SQLDialectProvider.swift new file mode 100644 index 000000000..008617635 --- /dev/null +++ b/TablePro/Core/Services/SQLDialectProvider.swift @@ -0,0 +1,233 @@ +// +// SQLDialectProvider.swift +// TablePro +// +// Created by OpenCode on 1/17/26. +// + +import Foundation + +// MARK: - MySQL/MariaDB Dialect + +struct MySQLDialect: SQLDialectProvider { + let identifierQuote = "`" + + let keywords: Set = [ + // Core DML keywords + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + + // DDL keywords + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", + + // Data types + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + + // Control flow + "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", + + // Set operations + "UNION", "INTERSECT", "EXCEPT", + + // MySQL-specific + "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", + "SHOW", "DESCRIBE", "DESC", "EXPLAIN" + ] + + let functions: Set = [ + // Aggregate + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", + + // String + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + + // Date/Time + "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", + "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", + + // Math + "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", + + // Conversion + "CAST", "CONVERT" + ] + + let dataTypes: Set = [ + // Integer types + "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + + // Decimal types + "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", + + // String types + "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", + + // Date/Time types + "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", + + // Other types + "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" + ] +} + +// MARK: - PostgreSQL Dialect + +struct PostgreSQLDialect: SQLDialectProvider { + let identifierQuote = "\"" + + let keywords: Set = [ + // Core DML keywords + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + + // DDL keywords + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + + // Data attributes + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + + // Control flow + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + + // Set operations + "UNION", "INTERSECT", "EXCEPT", + + // PostgreSQL-specific + "RETURNING", "WITH", "RECURSIVE", "AS", "MATERIALIZED", + "EXPLAIN", "ANALYZE", "VERBOSE", + "WINDOW", "OVER", "PARTITION", + "LATERAL", "ORDINALITY" + ] + + let functions: Set = [ + // Aggregate + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", + + // String + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + + // Date/Time + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + + // Math + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + + // Conversion + "CAST", "TO_NUMBER", "TO_TIMESTAMP", + + // JSON + "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" + ] + + let dataTypes: Set = [ + // Integer types + "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", + + // Decimal types + "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", + + // String types + "CHAR", "CHARACTER", "VARCHAR", "TEXT", + + // Date/Time types + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + + // Other types + "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" + ] +} + +// MARK: - SQLite Dialect + +struct SQLiteDialect: SQLDialectProvider { + let identifierQuote = "`" + + let keywords: Set = [ + // Core DML keywords + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + + // DDL keywords + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + + // Data attributes + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + + // Control flow + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + + // Set operations + "UNION", "INTERSECT", "EXCEPT", + + // SQLite-specific + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ] + + let functions: Set = [ + // Aggregate + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + + // String + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + + // Date/Time + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + + // Math + "ABS", "ROUND", "RANDOM", "MIN", "MAX", + + // Conversion + "CAST", "TYPEOF", + + // Other + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ] + + let dataTypes: Set = [ + // SQLite's storage classes + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + + // Type affinities + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ] +} + +// MARK: - Dialect Factory + +struct SQLDialectFactory { + /// Create a dialect provider for the given database type + static func createDialect(for databaseType: DatabaseType) -> SQLDialectProvider { + switch databaseType { + case .mysql, .mariadb: + return MySQLDialect() + case .postgresql: + return PostgreSQLDialect() + case .sqlite: + return SQLiteDialect() + } + } +} diff --git a/TablePro/Core/Services/SQLFormatterService.swift b/TablePro/Core/Services/SQLFormatterService.swift new file mode 100644 index 000000000..a290674de --- /dev/null +++ b/TablePro/Core/Services/SQLFormatterService.swift @@ -0,0 +1,463 @@ +// +// SQLFormatterService.swift +// TablePro +// +// Created by OpenCode on 1/17/26. +// + +import Foundation + +// MARK: - Formatter Protocol + +protocol SQLFormatterProtocol { + /// Format SQL with optional cursor position preservation + func format( + _ sql: String, + dialect: DatabaseType, + cursorOffset: Int?, + options: SQLFormatterOptions + ) throws -> SQLFormatterResult +} + +// MARK: - Main Formatter Service + +struct SQLFormatterService: SQLFormatterProtocol { + + // MARK: - Constants + + /// Maximum input size: 10MB (protection against DoS) + private static let maxInputSize = 10 * 1024 * 1024 + + /// Alignment for SELECT columns (length of "SELECT ") + private static let selectKeywordLength = 7 + + // MARK: - Public API + + func format( + _ sql: String, + dialect: DatabaseType, + cursorOffset: Int? = nil, + options: SQLFormatterOptions = .default + ) throws -> SQLFormatterResult { + + // Fix #4: Input size limit (DoS protection) + guard sql.utf8.count <= Self.maxInputSize else { + throw SQLFormatterError.internalError("SQL too large (max \(Self.maxInputSize / 1024 / 1024)MB)") + } + + // Validate input + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw SQLFormatterError.emptyInput + } + + if let cursor = cursorOffset, cursor > sql.count { + throw SQLFormatterError.invalidCursorPosition(cursor, max: sql.count) + } + + // Get dialect provider + let dialectProvider = SQLDialectFactory.createDialect(for: dialect) + + // Format the SQL + let formatted = formatSQL(sql, dialect: dialectProvider, options: options) + + // Cursor preservation + let newCursor = cursorOffset.map { original in + preserveCursorPosition(original: original, oldText: sql, newText: formatted) + } + + return SQLFormatterResult( + formattedSQL: formatted, + cursorOffset: newCursor + ) + } + + // MARK: - Core Formatting Logic + + private func formatSQL( + _ sql: String, + dialect: SQLDialectProvider, + options: SQLFormatterOptions + ) -> String { + var result = sql + + // Step 1: Preserve comments (replace with UUID placeholders) + let (sqlWithoutComments, comments) = options.preserveComments + ? extractComments(from: result) + : (result, []) + + result = sqlWithoutComments + + // Step 2: Extract string literals (to protect from keyword replacement) + let (sqlWithoutStrings, stringLiterals) = extractStringLiterals(from: result, dialect: dialect) + result = sqlWithoutStrings + + // Step 3: Uppercase keywords (now safe - strings removed) + if options.uppercaseKeywords { + result = uppercaseKeywords(result, dialect: dialect) + } + + // Step 4: Restore string literals + result = restoreStringLiterals(result, literals: stringLiterals) + + // Step 5: Add line breaks before major keywords + result = addLineBreaks(result, dialect: dialect) + + // Step 6: Add indentation based on nesting + if options.indentSize > 0 { + result = addIndentation(result, indentSize: options.indentSize) + } + + // Step 7: Align SELECT columns + if options.alignColumns { + result = alignSelectColumns(result) + } + + // Step 8: Format JOINs (handled by line breaks) + if options.formatJoins { + result = formatJoins(result) + } + + // Step 9: Align WHERE conditions + if options.alignWhere { + result = alignWhereConditions(result) + } + + // Step 10: Restore comments + if options.preserveComments { + result = restoreComments(result, comments: comments) + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - String Literal Protection (Fix #2) + + /// Extract string literals to protect from keyword replacement + /// Handles: 'single quotes', "double quotes", `backticks` + private func extractStringLiterals(from sql: String, dialect: SQLDialectProvider) -> (String, [(placeholder: String, content: String)]) { + var result = sql + var literals: [(String, String)] = [] + + // Determine quote characters based on dialect + // MySQL/SQLite: single quotes and backticks + // PostgreSQL: single quotes and double quotes + let quoteChars: [String] + switch dialect.identifierQuote { + case "\"": + quoteChars = ["'", "\""] // PostgreSQL + default: + quoteChars = ["'", "`"] // MySQL, SQLite + } + + // Extract each type of string literal + for quoteChar in quoteChars { + let pattern = "\(NSRegularExpression.escapedPattern(for: quoteChar))((?:\\\\\\\\\(quoteChar)|[^\(quoteChar)])*?)\(NSRegularExpression.escapedPattern(for: quoteChar))" + + if let regex = createRegex(pattern) { + let matches = regex.matches(in: result, range: NSRange(result.startIndex..., in: result)) + + // Process in reverse to maintain valid indices + for match in matches.reversed() { + if let range = safeRange(from: match.range, in: result) { + let literal = String(result[range]) + let placeholder = "__STRING_\(UUID().uuidString)__" + literals.insert((placeholder, literal), at: 0) + result.replaceSubrange(range, with: placeholder) + } + } + } + } + + return (result, literals) + } + + /// Restore string literals after formatting + private func restoreStringLiterals(_ sql: String, literals: [(placeholder: String, content: String)]) -> String { + var result = sql + for (placeholder, content) in literals { + result = result.replacingOccurrences(of: placeholder, with: content) + } + return result + } + + // MARK: - Comment Handling (Fix #6: UUID placeholders) + + /// Extract comments with UUID-based placeholders (prevents collisions) + private func extractComments(from sql: String) -> (String, [(placeholder: String, content: String)]) { + var result = sql + var comments: [(String, String)] = [] + + // Extract line comments (-- ...) + let lineCommentPattern = "--[^\\n]*" + if let regex = createRegex(lineCommentPattern) { + let matches = regex.matches(in: result, range: NSRange(result.startIndex..., in: result)) + for match in matches.reversed() { + if let range = safeRange(from: match.range, in: result) { + let comment = String(result[range]) + let placeholder = "__COMMENT_\(UUID().uuidString)__" // Fix #6: UUID + comments.insert((placeholder, comment), at: 0) + result.replaceSubrange(range, with: placeholder) + } + } + } + + // Extract block comments (/* ... */) + // Note: This doesn't handle nested block comments (SQL doesn't officially support them) + let blockCommentPattern = "/\\*.*?\\*/" + if let regex = createRegex(blockCommentPattern, options: .dotMatchesLineSeparators) { + let matches = regex.matches(in: result, range: NSRange(result.startIndex..., in: result)) + for match in matches.reversed() { + if let range = safeRange(from: match.range, in: result) { + let comment = String(result[range]) + let placeholder = "__COMMENT_\(UUID().uuidString)__" // Fix #6: UUID + comments.insert((placeholder, comment), at: 0) + result.replaceSubrange(range, with: placeholder) + } + } + } + + return (result, comments) + } + + /// Restore comments after formatting + private func restoreComments(_ sql: String, comments: [(placeholder: String, content: String)]) -> String { + var result = sql + for (placeholder, content) in comments { + result = result.replacingOccurrences(of: placeholder, with: content) + } + return result + } + + // MARK: - Keyword Uppercasing (Fix #1: Single-pass optimization) + + /// Uppercase keywords using single regex pass (much faster than per-keyword) + private func uppercaseKeywords(_ sql: String, dialect: SQLDialectProvider) -> String { + let allKeywords = dialect.keywords.union(dialect.functions).union(dialect.dataTypes) + + // Build alternation pattern: \b(SELECT|FROM|WHERE|...)\b + let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) } + let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b" + + guard let regex = createRegex(pattern, options: .caseInsensitive) else { + return sql + } + + var result = sql + let matches = regex.matches(in: sql, range: NSRange(sql.startIndex..., in: sql)) + + // Process in reverse to maintain valid indices (Fix #3) + for match in matches.reversed() { + if let range = safeRange(from: match.range, in: result) { + let keyword = String(result[range]) + result.replaceSubrange(range, with: keyword.uppercased()) + } + } + + return result + } + + // MARK: - Line Breaks + + private func addLineBreaks(_ sql: String, dialect: SQLDialectProvider) -> String { + var result = sql + + // Keywords that should start on a new line + let lineBreakKeywords = [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", + "FULL JOIN", "CROSS JOIN", "ORDER BY", "GROUP BY", "HAVING", + "UNION", "UNION ALL", "INTERSECT", "EXCEPT", "LIMIT", "OFFSET" + ] + + // Sort by length (longest first) to handle multi-word keywords correctly + for keyword in lineBreakKeywords.sorted(by: { $0.count > $1.count }) { + let escapedKeyword = NSRegularExpression.escapedPattern(for: keyword) + let pattern = "\\s+\(escapedKeyword)\\b" + + if let regex = createRegex(pattern, options: .caseInsensitive) { + result = regex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "\n\(keyword.uppercased())" + ) + } + } + + return result + } + + // MARK: - Indentation (Fix #5: Word boundaries instead of contains) + + private func addIndentation(_ sql: String, indentSize: Int) -> String { + let lines = sql.components(separatedBy: "\n") + var indentLevel = 0 + var result: [String] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { continue } + + // Decrease indent before processing closing parens or END + if trimmed.starts(with: ")") || hasWordBoundary(trimmed, word: "END") { + indentLevel = max(0, indentLevel - 1) + } + + // Add indentation + let indent = String(repeating: " ", count: indentLevel * indentSize) + result.append(indent + trimmed) + + // Increase indent after opening parens or CASE keyword + if trimmed.hasSuffix("(") || hasWordBoundary(trimmed, word: "CASE") { + indentLevel += 1 + } + + // Special handling for subqueries: (SELECT + if let regex = createRegex("\\(\\s*SELECT\\b", options: .caseInsensitive), + regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)) != nil { + indentLevel += 1 + } + + // Decrease after closing paren (if not at start) + if trimmed.hasSuffix(")") && !trimmed.starts(with: ")") { + indentLevel = max(0, indentLevel - 1) + } + } + + return result.joined(separator: "\n") + } + + /// Check if a word appears with word boundaries (Fix #5) + private func hasWordBoundary(_ text: String, word: String) -> Bool { + let pattern = "\\b\(NSRegularExpression.escapedPattern(for: word))\\b" + guard let regex = createRegex(pattern, options: .caseInsensitive) else { + return false + } + return regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil + } + + // MARK: - Column Alignment + + /// Align SELECT columns vertically + /// + /// Example: + /// SELECT id, name, email FROM users + /// Becomes: + /// SELECT id, + /// name, + /// email + /// FROM users + private func alignSelectColumns(_ sql: String) -> String { + // Find SELECT...FROM region + guard let selectRange = sql.range(of: "SELECT", options: .caseInsensitive), + let fromRange = sql.range(of: "FROM", options: .caseInsensitive, range: selectRange.upperBound.. 1 else { + return sql // Only one column, no alignment needed + } + + // Align columns with proper spacing + let alignedColumns = columns.enumerated().map { index, column in + let trimmed = column.trimmingCharacters(in: .whitespacesAndNewlines) + if index == 0 { + return trimmed + } else { + return String(repeating: " ", count: Self.selectKeywordLength) + trimmed + } + }.joined(separator: ",\n") + + // Rebuild SQL (Fix #3: Use string concatenation instead of replaceSubrange) + let before = String(sql[.. String { + // Already handled by addLineBreaks + return sql + } + + // MARK: - WHERE Condition Alignment + + private func alignWhereConditions(_ sql: String) -> String { + // Find WHERE clause + guard let whereRange = sql.range(of: "WHERE", options: .caseInsensitive) else { + return sql + } + + // Find end of WHERE clause + let majorKeywords = ["ORDER", "GROUP", "HAVING", "LIMIT", "UNION", "INTERSECT"] + var endIndex = sql.endIndex + + for keyword in majorKeywords { + if let range = sql.range(of: keyword, options: .caseInsensitive, range: whereRange.upperBound.. Int { + guard !oldText.isEmpty else { return 0 } + + let ratio = Double(original) / Double(oldText.count) + let newPosition = Int(ratio * Double(newText.count)) + + return min(newPosition, newText.count) + } + + // MARK: - Helper Methods + + /// Create regex with error logging (instead of silent failures) + private func createRegex(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression? { + do { + return try NSRegularExpression(pattern: pattern, options: options) + } catch { + assertionFailure("Failed to create regex '\(pattern)': \(error)") + return nil + } + } + + /// Safe NSRange to Range conversion (Fix #7: Unicode handling) + /// + /// NSRange uses UTF-16 code units, Swift String.Index uses Unicode scalars. + /// This can cause issues with emoji and other multi-byte characters. + private func safeRange(from nsRange: NSRange, in string: String) -> Range? { + // Use proper Range initializer that handles UTF-16 conversion + return Range(nsRange, in: string) + } +} diff --git a/TablePro/Core/Services/SQLFormatterTypes.swift b/TablePro/Core/Services/SQLFormatterTypes.swift new file mode 100644 index 000000000..38219ed80 --- /dev/null +++ b/TablePro/Core/Services/SQLFormatterTypes.swift @@ -0,0 +1,97 @@ +// +// SQLFormatterTypes.swift +// TablePro +// +// Created by OpenCode on 1/17/26. +// + +import Foundation + +// Note: DatabaseType is defined in Models/DatabaseConnection.swift +// Swift doesn't require explicit imports within the same module + +// MARK: - Formatter Options + +/// Configuration for SQL formatting behavior +struct SQLFormatterOptions { + var uppercaseKeywords: Bool = true + var indentSize: Int = 2 // spaces per indent level + var alignColumns: Bool = true + var preserveComments: Bool = true + var formatJoins: Bool = true + var alignWhere: Bool = true + + /// Default options with all features enabled + static let `default` = SQLFormatterOptions() +} + +// MARK: - Formatter Result + +/// Result of a formatting operation with cursor mapping +struct SQLFormatterResult { + let formattedSQL: String + let cursorOffset: Int? // New cursor position (nil if no cursor provided) + + init(formattedSQL: String, cursorOffset: Int? = nil) { + self.formattedSQL = formattedSQL + self.cursorOffset = cursorOffset + } +} + +// MARK: - Formatter Error + +/// Errors that can occur during SQL formatting +enum SQLFormatterError: LocalizedError { + case emptyInput + case dialectUnsupported(DatabaseType) + case invalidCursorPosition(Int, max: Int) + case internalError(String) + + var errorDescription: String? { + switch self { + case .emptyInput: + return "Cannot format empty SQL" + case .dialectUnsupported(let type): + return "Formatting not supported for \(type.rawValue)" + case .invalidCursorPosition(let pos, let max): + return "Cursor position \(pos) exceeds SQL length (\(max))" + case .internalError(let message): + return "Formatter error: \(message)" + } + } +} + +// MARK: - Dialect Provider Protocol + +/// Provides dialect-specific SQL formatting rules +protocol SQLDialectProvider { + var keywords: Set { get } + var functions: Set { get } + var dataTypes: Set { get } + var identifierQuote: String { get } + + /// Check if a token is a keyword for this dialect + func isKeyword(_ token: String) -> Bool + + /// Check if a token is a function for this dialect + func isFunction(_ token: String) -> Bool + + /// Check if a token is a data type for this dialect + func isDataType(_ token: String) -> Bool +} + +// MARK: - Default Protocol Implementations + +extension SQLDialectProvider { + func isKeyword(_ token: String) -> Bool { + keywords.contains(token.uppercased()) + } + + func isFunction(_ token: String) -> Bool { + functions.contains(token.uppercased()) + } + + func isDataType(_ token: String) -> Bool { + dataTypes.contains(token.uppercased()) + } +} diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 1a8deef54..aa68e75ef 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -617,4 +617,31 @@ final class EditorTextView: NSTextView { return glyphRect } + + // MARK: - Context Menu + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = super.menu(for: event) ?? NSMenu() + + // Add separator if menu already has items + if menu.items.count > 0 { + menu.addItem(NSMenuItem.separator()) + } + + // Add "Format SQL" menu item + let formatItem = NSMenuItem( + title: "Format SQL", + action: #selector(formatSQLAction), + keyEquivalent: "" + ) + formatItem.target = self + menu.addItem(formatItem) + + return menu + } + + @objc private func formatSQLAction() { + // Post notification to trigger formatting + NotificationCenter.default.post(name: .formatQueryRequested, object: nil) + } } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 0495c970e..e0a2a429a 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -7,6 +7,10 @@ import SwiftUI +extension Notification.Name { + static let formatQueryRequested = Notification.Name("formatQueryRequested") +} + /// SQL query editor view with execute button struct QueryEditorView: View { @Binding var queryText: String @@ -28,6 +32,9 @@ struct QueryEditorView: View { .clipped() } .background(Color(nsColor: .textBackgroundColor)) + .onReceive(NotificationCenter.default.publisher(for: .formatQueryRequested)) { _ in + formatQuery() + } } // MARK: - Toolbar @@ -53,7 +60,8 @@ struct QueryEditorView: View { Image(systemName: "text.alignleft") } .buttonStyle(.borderless) - .help("Format Query") + .help("Format Query (⌥⌘F)") + .keyboardShortcut("f", modifiers: [.option, .command]) Divider() .frame(height: 16) @@ -77,24 +85,31 @@ struct QueryEditorView: View { // MARK: - Helpers private func formatQuery() { - // Basic formatting: uppercase keywords - let keywords = ["SELECT", "FROM", "WHERE", "ORDER BY", "GROUP BY", "HAVING", - "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", - "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", - "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "LIMIT", "OFFSET", "DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", - "NULL", "IS", "ASC", "DESC", "SET", "VALUES", "INTO", "TABLE"] - - var formatted = queryText - for keyword in keywords { - // Match word boundaries - let pattern = "\\b\(keyword.lowercased())\\b" - if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { - let range = NSRange(formatted.startIndex..., in: formatted) - formatted = regex.stringByReplacingMatches(in: formatted, range: range, withTemplate: keyword) + // Get current database type from active session + let dbType = DatabaseManager.shared.currentSession?.connection.type ?? .mysql + + // Create formatter service + let formatter = SQLFormatterService() + let options = SQLFormatterOptions.default + + do { + // Format SQL with cursor preservation + let result = try formatter.format( + queryText, + dialect: dbType, + cursorOffset: cursorPosition, + options: options + ) + + // Update text and cursor position + queryText = result.formattedSQL + if let newCursor = result.cursorOffset { + cursorPosition = newCursor } + } catch { + // Show error to user (could enhance with an alert later) + print("SQL Formatting error: \(error.localizedDescription)") } - queryText = formatted } }