diff --git a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift index 9bce6278a4..17451b68eb 100644 --- a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift +++ b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift @@ -1,5 +1,4 @@ import Foundation -import TableProAutocomplete import TableProPluginKit struct QueryFormatResult { @@ -13,17 +12,15 @@ protocol QueryFormatting { struct SQLQueryFormatter: QueryFormatting { private let dialect: DatabaseType - private let keywordCase: SQLKeywordCase + private let options: SQLFormatterOptions private let formatter = SQLFormatterService() - init(dialect: DatabaseType, keywordCase: SQLKeywordCase = .default) { + init(dialect: DatabaseType, options: SQLFormatterOptions = .default) { self.dialect = dialect - self.keywordCase = keywordCase + self.options = options } func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { - var options = SQLFormatterOptions.default - options.keywordCase = keywordCase.prefersUppercase ? .upper : .lower let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: options) return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset) } @@ -31,10 +28,22 @@ struct SQLQueryFormatter: QueryFormatting { @MainActor enum QueryFormatterFactory { - /// Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as - /// `user :1`, an etcd path as `/ config / name` and a SurrealQL record id `person:tobie` as two tokens, - /// so running it there changes what the command does. static func make(for databaseType: DatabaseType?) -> QueryFormatting? { + make(for: databaseType) { AppSettingsManager.shared.formatterOptions } + } + + // Throws when the linked folder's rules file can't be read, rather than format with Settings. + static func make(for databaseType: DatabaseType?, sourceFileURL: URL?) throws -> QueryFormatting? { + try make(for: databaseType) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) } + } + + // Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as + // `user :1`, an etcd path as `/ config / name` and a SurrealQL record id `person:tobie` as two tokens, + // so running it there changes what the command does. + private static func make( + for databaseType: DatabaseType?, + sqlOptions: () throws -> SQLFormatterOptions + ) rethrows -> QueryFormatting? { let dialect = databaseType ?? .mysql // Elasticsearch, Typesense and Weaviate highlight as JavaScript for their JSON bodies, and the @@ -45,7 +54,7 @@ enum QueryFormatterFactory { switch PluginManager.shared.editorLanguage(for: dialect) { case .sql: - return SQLQueryFormatter(dialect: dialect, keywordCase: AppSettingsManager.shared.editor.keywordCase) + return SQLQueryFormatter(dialect: dialect, options: try sqlOptions()) case .javascript: return ConsoleRequestFormatter() case .bash, .custom: diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift new file mode 100644 index 0000000000..7800cecfc4 --- /dev/null +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift @@ -0,0 +1,137 @@ +// +// SQLFormatterRulesFile.swift +// TablePro +// + +import Foundation + +enum SQLFormatterRulesError: LocalizedError, Equatable { + case tooLarge + case notJSON + case invalidValue(key: String) + case unreadable(String) + + var errorDescription: String? { + switch self { + case .tooLarge: + return String( + format: String(localized: "The file is larger than %@, too large for a rules file."), + ByteCountFormatter.string(fromByteCount: Int64(SQLFormatterRulesFile.maxSize), countStyle: .file) + ) + case .notJSON: + return String(localized: "The file is not a JSON object.") + case .invalidValue(let key): + return String(format: String(localized: "The value of “%@” is not valid."), key) + case .unreadable(let reason): + return reason + } + } +} + +struct SQLFormatterFolderRulesError: LocalizedError { + let fileURL: URL + let reason: SQLFormatterRulesError + + var errorDescription: String? { + String( + format: String(localized: "The formatter rules in “%@” could not be read. %@"), + fileURL.path, + reason.localizedDescription + ) + } +} + +enum SQLFormatterRulesFile { + static let fileName = ".sql-formatter.json" + static let exportFileName = "sql-formatter.json" + static let maxSize = 64 * 1_024 + + static func encode(_ options: SQLFormatterOptions) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(options) + } + + static func decode(_ data: Data) throws -> SQLFormatterOptions { + guard data.count <= maxSize else { throw SQLFormatterRulesError.tooLarge } + do { + return try JSONDecoder().decode(SQLFormatterOptions.self, from: data) + } catch let error as DecodingError { + throw rulesError(for: error) + } + } + + // A rules file comes with a repository and may link to a device or a pipe, so only a regular file is read. + static func load(from url: URL) throws -> SQLFormatterOptions { + do { + let target = url.resolvingSymlinksInPath() + let values = try target.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw SQLFormatterRulesError.unreadable(String(localized: "The rules file is not a regular file.")) + } + guard (values.fileSize ?? 0) <= maxSize else { throw SQLFormatterRulesError.tooLarge } + let handle = try FileHandle(forReadingFrom: target) + defer { try? handle.close() } + return try decode(try handle.read(upToCount: maxSize + 1) ?? Data()) + } catch let error as SQLFormatterRulesError { + throw error + } catch { + throw SQLFormatterRulesError.unreadable(error.localizedDescription) + } + } + + // Nearest rules file from the SQL file's folder up to its enabled linked folder's root, never above. + static func url( + forFileAt fileURL: URL, + in folders: [LinkedSQLFolder], + fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) } + ) -> URL? { + let file = fileURL.resolvingSymlinksInPath().pathComponents + let roots = folders + .filter(\.isEnabled) + .map { $0.expandedURL.resolvingSymlinksInPath().pathComponents } + .filter { $0.count < file.count && Array(file.prefix($0.count)) == $0 } + guard let root = roots.max(by: { $0.count < $1.count }) else { return nil } + + var directory = Array(file.dropLast()) + while directory.count >= root.count { + let candidate = URL(fileURLWithPath: NSString.path(withComponents: directory + [fileName])) + if fileExists(candidate) { return candidate } + directory.removeLast() + } + return nil + } + + private static func rulesError(for error: DecodingError) -> SQLFormatterRulesError { + let path: [CodingKey] + switch error { + case .typeMismatch(_, let context), .valueNotFound(_, let context), .dataCorrupted(let context): + path = context.codingPath + case .keyNotFound(_, let context): + path = context.codingPath + @unknown default: + path = [] + } + guard let key = path.first?.stringValue else { return .notJSON } + return .invalidValue(key: key) + } +} + +@MainActor +enum SQLFormatterRuleSource { + // A rules file in the linked folder wins over Settings, so every Mac formats that folder alike. + static func options( + forFileAt fileURL: URL?, + settings: @autoclosure () -> SQLFormatterOptions = AppSettingsManager.shared.formatterOptions, + folders: [LinkedSQLFolder] = LinkedSQLFolderStorage.shared.loadFolders() + ) throws -> SQLFormatterOptions { + guard let fileURL, let rulesURL = SQLFormatterRulesFile.url(forFileAt: fileURL, in: folders) else { + return settings() + } + do { + return try SQLFormatterRulesFile.load(from: rulesURL) + } catch let error as SQLFormatterRulesError { + throw SQLFormatterFolderRulesError(fileURL: rulesURL, reason: error) + } + } +} diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 444d8d13ec..59e894d9ba 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -2,14 +2,9 @@ // SQLFormatterService.swift // TablePro // -// Token-based SQL formatter. Tokenizes input into a stream, then walks tokens -// with clause/nesting context to produce properly indented, readable SQL. -// import Foundation -// MARK: - Formatter Protocol - protocol SQLFormatterProtocol { func format( _ sql: String, @@ -19,8 +14,6 @@ protocol SQLFormatterProtocol { ) throws -> SQLFormatterResult } -// MARK: - Main Formatter Service - struct SQLFormatterService: SQLFormatterProtocol { private static let maxInputSize = 10 * 1_024 * 1_024 @@ -54,7 +47,8 @@ struct SQLFormatterService: SQLFormatterProtocol { var tokenFormatter = SQLTokenFormatter( options: options, dialectFunctions: dialectProvider.functions, - dialectDataTypes: dialectProvider.dataTypes + dialectDataTypes: dialectProvider.dataTypes, + rewritesFunctionCase: dialectProvider.functionNamesAreCaseInsensitive ) let formatted = tokenFormatter.format(tokens) @@ -72,10 +66,8 @@ struct SQLFormatterService: SQLFormatterProtocol { } } -// MARK: - Token Formatter (Stateful Walker) - -/// Walks the token stream with clause/nesting context to produce formatted output. -/// Extracted from SQLFormatterService to keep each function under lint limits. +// SELECT, SET and WHERE lists are written inline and kept open until their clause ends, when one that +// overflows the line width gets its separators rewritten broken and its aliases aligned. internal struct SQLTokenFormatter { let options: SQLFormatterOptions @@ -94,26 +86,38 @@ internal struct SQLTokenFormatter { "SERIAL", "BIGSERIAL", ] + // Width of `SELECT ` and `SET `, which the items after the first line up under. + private static let selectAlignment = 7 + private static let setAlignment = 4 + private let functions: Set private let dataTypes: Set - - init(options: SQLFormatterOptions, dialectFunctions: Set = [], dialectDataTypes: Set = []) { + private let rewritesFunctionCase: Bool + private let indentUnit: String + + init( + options: SQLFormatterOptions, + dialectFunctions: Set = [], + dialectDataTypes: Set = [], + rewritesFunctionCase: Bool + ) { self.options = options self.functions = Self.builtinFunctions.union(dialectFunctions) self.dataTypes = Self.builtinDataTypes.union(dialectDataTypes) + self.rewritesFunctionCase = rewritesFunctionCase + self.indentUnit = options.indentUnit } - private var output = "" + private var output = FormatterOutput() private var clauseStack: [ClauseContext] = [] private var afterNewline = true - private var isFirstClause = true - private var selectColumnIndent = 0 + private var selectColumnPad = Pad(levels: 0) private var inSelectColumns = false private var skipCount = 0 private var suppressNextSpace = false - private var caseBaseIndent = 0 - /// Stack to save/restore selectColumnIndent across subquery boundaries - private var selectColumnIndentStack: [Int] = [] + private var caseBasePad = Pad(levels: 0) + private var selectColumnPadStack: [Pad] = [] + private var openLists: [PendingList] = [] private enum ClauseContext { case select, from, where_, join, caseExpr, subquery, with @@ -121,7 +125,30 @@ internal struct SQLTokenFormatter { case inlineParen, windowParen } - /// True after BETWEEN keyword — suppresses the next AND from being a clause break + // Indent levels render as the indent unit, alignment spaces as spaces after them. + private struct Pad { + var levels: Int + var spaces: Int = 0 + } + + private enum ListKind { + case select, set, conditions + } + + private struct Separator { + let range: Range + let broken: String + } + + private struct PendingList { + let kind: ListKind + let depth: Int + let lineStart: Int + var separators: [Separator] = [] + var aliasOffsets: [Int: Int] = [:] + } + + // True after BETWEEN, so the next AND stays inline. private var afterBetween = false private var newlinesAfterSemicolon: [Int: Int] = [:] @@ -162,70 +189,49 @@ internal struct SQLTokenFormatter { processToken(token, mi: mi, prev: prev, next: next, next2: next2) } - return output.trimmingCharacters(in: .whitespacesAndNewlines) + closeLists(atOrDeeperThan: 0) + return output.string.trimmingCharacters(in: .whitespacesAndNewlines) } // MARK: - Token Processing private mutating func processToken(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { - if token.type == .comment { + switch token.type { + case .comment: handleComment(token) - return - } - - if token.type == .punctuation { + case .punctuation: handlePunctuation(token, mi: mi, prev: prev, next: next) - return - } - - if token.type == .keyword { + case .keyword: handleKeyword(token, prev: prev, next: next, next2: next2) - return + default: + appendToken(token.value) } - - // All other tokens: identifiers, numbers, strings, operators, placeholders - appendToken(token.value) } - // MARK: - Comment Handling - private mutating func handleComment(_ token: SQLToken) { - guard options.preserveComments else { return } if !afterNewline { newline() } output += token.value output += "\n" afterNewline = true } - // MARK: - Punctuation Handling + // MARK: - Punctuation private mutating func handlePunctuation(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?) { switch token.value { case ";": output += ";" + closeLists(atOrDeeperThan: 0) let originalNewlines = newlinesAfterSemicolon[mi] ?? 0 let newlineCount = min(max(originalNewlines, 1), 3) output += String(repeating: "\n", count: newlineCount) afterNewline = true - isFirstClause = true clauseStack.removeAll() - selectColumnIndentStack.removeAll() + selectColumnPadStack.removeAll() inSelectColumns = false case ",": - output += "," - if inSelectColumns { - output += "\n" + String(repeating: " ", count: selectColumnIndent) - afterNewline = false - suppressNextSpace = true - } else if currentContext == .set { - output += "\n" + indentStr() + " " - afterNewline = false - suppressNextSpace = true - } else if currentContext == .createTableBody { - output += "\n" - afterNewline = true - } + handleComma() case "(": handleOpenParen(next: next, prev: prev) @@ -234,7 +240,6 @@ internal struct SQLTokenFormatter { handleCloseParen() case ".": - // Qualified name dot — no spaces around it output += "." afterNewline = false suppressNextSpace = true @@ -244,34 +249,50 @@ internal struct SQLTokenFormatter { } } + private mutating func handleComma() { + if inSelectColumns && currentContext == .select { + appendCommaSeparator(kind: .select, alignedTo: selectColumnPad) + } else if currentContext == .set { + appendCommaSeparator(kind: .set, alignedTo: Pad(levels: indent, spaces: Self.setAlignment)) + } else if currentContext == .createTableBody { + switch options.commaPosition { + case .trailing: + output += ",\n" + afterNewline = true + case .leading: + output += "\n" + render(Pad(levels: indent)) + "," + afterNewline = false + suppressNextSpace = false + } + } else { + output += "," + } + } + + private mutating func appendCommaSeparator(kind: ListKind, alignedTo pad: Pad) { + let broken: String + switch options.commaPosition { + case .trailing: + broken = ",\n" + render(pad) + case .leading: + broken = "\n" + render(Pad(levels: pad.levels, spaces: max(pad.spaces - 2, 0))) + ", " + } + appendSeparator(kind: kind, inline: ", ", broken: broken) + afterNewline = false + suppressNextSpace = true + } + private mutating func handleOpenParen(next: SQLToken?, prev: SQLToken?) { - // Subquery: ( SELECT ... if next?.upperValue == "SELECT" { - if suppressNextSpace { - output += "(" - } else { - output += " (" - } - output += "\n" - selectColumnIndentStack.append(selectColumnIndent) - afterNewline = true - isFirstClause = true - suppressNextSpace = false - clauseStack.append(.subquery) + output += suppressNextSpace ? "(" : " (" + openBlock() return } - // CTE body: AS ( if prev?.upperValue == "AS" && clauseStack.contains(.with) { output += " (" - output += "\n" - selectColumnIndentStack.append(selectColumnIndent) - afterNewline = true - isFirstClause = true - suppressNextSpace = false - clauseStack.append(.subquery) + openBlock() return } - // CREATE TABLE columns: table_name ( if clauseStack.contains(.createTable) && !clauseStack.contains(.createTableBody) { output += " (" output += "\n" @@ -280,7 +301,6 @@ internal struct SQLTokenFormatter { clauseStack.append(.createTableBody) return } - // Window function: OVER(...) if prev?.upperValue == "OVER" { output += "(" afterNewline = false @@ -288,8 +308,8 @@ internal struct SQLTokenFormatter { clauseStack.append(.windowParen) return } - // Structural keyword before paren needs space: VALUES (...), IN (...) - // Function calls and data type parens get no space: COUNT(*), VARCHAR(255) + // A structural keyword takes a space before its paren (`VALUES (`, `IN (`); a function call + // or a sized type does not (`COUNT(*)`, `VARCHAR(255)`). if let prevUpper = prev?.upperValue, prev?.type == .keyword, !functions.contains(prevUpper), !dataTypes.contains(prevUpper) { output += suppressNextSpace ? "(" : " (" @@ -303,8 +323,15 @@ internal struct SQLTokenFormatter { clauseStack.append(.inlineParen) } + private mutating func openBlock() { + output += "\n" + selectColumnPadStack.append(selectColumnPad) + afterNewline = true + suppressNextSpace = false + clauseStack.append(.subquery) + } + private mutating func handleCloseParen() { - // Inline or window paren: just close it if currentContext == .inlineParen || currentContext == .windowParen { clauseStack.removeLast() output += ")" @@ -312,68 +339,66 @@ internal struct SQLTokenFormatter { suppressNextSpace = false return } - // Block paren (subquery or CREATE TABLE body): pop back to the block opener if let idx = clauseStack.lastIndex(where: Self.isBlockContext) { + closeLists(atOrDeeperThan: indent) clauseStack.removeSubrange(idx...) output += "\n" + indentStr() + ")" afterNewline = false - isFirstClause = false - // Restore outer SELECT state after leaving subquery inSelectColumns = clauseStack.contains(.select) - if let saved = selectColumnIndentStack.popLast() { - selectColumnIndent = saved + if let saved = selectColumnPadStack.popLast() { + selectColumnPad = saved } suppressNextSpace = false return } - // Unmatched paren — just append output += ")" afterNewline = false suppressNextSpace = false } - // MARK: - Keyword Handling + // MARK: - Keywords private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { + // A word beside a dot is part of a qualified name (`e.end`, `dbo.len(x)`, `key.id`), and a + // name keeps its spelling on an engine with case-sensitive identifiers. + if prev?.value == "." || next?.value == "." { + appendToken(token.value) + return + } let upper = token.upperValue let kw = options.keywordCase.applied(upper: upper, original: token.value) switch upper { case "SELECT": - handleSelect(kw: kw) + handleSelect(kw: kw, prev: prev) case "FROM": handleFrom(kw: kw, prev: prev) case "WHERE": - handleClauseKeyword(kw: kw, context: .where_) + handleClauseKeyword(kw: kw, context: .where_, list: .conditions) case "AND", "OR": handleAndOr(kw: kw, upper: upper) case "JOIN": handleClauseKeyword(kw: kw, context: .join) case "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL": - handleJoinPrefix(upper: upper, kw: kw, next: next, next2: next2) + handleJoinPrefix(token, kw: kw, next: next, next2: next2) case "OUTER": - break // absorbed by JOIN prefix handler + break // absorbed by the JOIN prefix handler case "ON": handleOn(kw: kw) - case "OVER": + case "OVER", "PARTITION", "BY", "ALL", "INTO", "TABLE": appendToken(kw) - case "PARTITION": + case "AS": + recordAliasIfTopLevel() appendToken(kw) case "BETWEEN": afterBetween = true appendToken(kw) case "ORDER", "GROUP": - handleOrderGroup(upper: upper, kw: kw, next: next) - case "BY": - // standalone BY (not consumed by ORDER/GROUP) — should not happen normally - appendToken(kw) + handleOrderGroup(kw: kw, next: next) case "HAVING", "LIMIT", "OFFSET": handleClauseKeyword(kw: kw, context: nil) case "UNION", "INTERSECT", "EXCEPT", "MINUS": handleSetOperation(upper: upper, kw: kw, next: next) - case "ALL": - // standalone ALL (not consumed by UNION ALL) - appendToken(kw) case "CASE": handleCase(kw: kw) case "WHEN", "ELSE": @@ -384,120 +409,150 @@ internal struct SQLTokenFormatter { case "END": handleEnd(kw: kw) case "WITH": - handleWith(kw: kw) + handleWith(kw: kw, prev: prev) case "INSERT": - handleInsert(kw: kw, next: next) - case "INTO": - // standalone INTO (not consumed by INSERT INTO) - appendToken(kw) + handleInsert(kw: kw, prev: prev, next: next) case "VALUES": - newline() + // `a = VALUES(a)` in MySQL's ON DUPLICATE KEY UPDATE is a function call. + if prev?.type != .operator { newline() } appendToken(kw) case "UPDATE": - handleStatementStart(kw: kw, context: .update) + handleStatementStart(kw: kw, prev: prev, context: .update) case "SET": - handleClauseKeyword(kw: kw, context: .set) + handleClauseKeyword(kw: kw, context: .set, list: .set) case "DELETE": - handleStatementStart(kw: kw, context: nil) + handleStatementStart(kw: kw, prev: prev, context: nil) case "CREATE": - handleStatementStart(kw: kw, context: .createTable) - case "TABLE", "AS": - appendToken(kw) + handleStatementStart(kw: kw, prev: prev, context: .createTable) default: - if options.keywordCase.rewritesKeywords, functions.contains(upper) || dataTypes.contains(upper) { - appendToken(token.value) - } else { - appendToken(kw) - } + appendToken(cased(token, next: next)) } } - // MARK: - Specific Keyword Handlers + // `count` and `date` can name columns, so only a call takes the function case, and only where the + // engine accepts any spelling of the function. + private func cased(_ token: SQLToken, next: SQLToken?) -> String { + let upper = token.upperValue + if functions.contains(upper) { + guard rewritesFunctionCase, next?.value == "(" else { return token.value } + return options.functionCase.applied(upper: upper, original: token.value) + } + if dataTypes.contains(upper) { + return token.value + } + return options.keywordCase.applied(upper: upper, original: token.value) + } - private mutating func handleSelect(kw: String) { - if !isFirstClause { newline() } - output += (afterNewline ? indentStr() : "") + kw - isFirstClause = false + private mutating func handleSelect(kw: String, prev: SQLToken?) { + // `GRANT SELECT, INSERT ON t`, `CREATE POLICY p ON t FOR SELECT` + if let prev, Self.statementKeywordModifiers.contains(prev.upperValue) { + appendToken(kw) + return + } + closeLists(atOrDeeperThan: indent) + if !afterNewline { newline() } + let lineStart = output.count + output += indentStr() + kw afterNewline = false inSelectColumns = true - selectColumnIndent = indent * options.indentSize + 7 + selectColumnPad = Pad(levels: indent, spaces: Self.selectAlignment) clauseStack.append(.select) + openLists.append(PendingList(kind: .select, depth: indent, lineStart: lineStart)) } private mutating func handleFrom(kw: String, prev: SQLToken?) { - if prev?.upperValue == "DELETE" { - newline() + // `EXTRACT(YEAR FROM d)`, `SUBSTRING(s FROM 2)`, `TRIM(BOTH ' ' FROM s)` + if isInsideNestedExpression { appendToken(kw) - inSelectColumns = false return } + closeLists(atOrDeeperThan: indent) inSelectColumns = false newline() appendToken(kw) - replaceTop(with: .from) + if prev?.upperValue != "DELETE" { + replaceTop(with: .from) + } } - private mutating func handleClauseKeyword(kw: String, context: ClauseContext?) { + private mutating func handleClauseKeyword(kw: String, context: ClauseContext?, list: ListKind? = nil) { + // `FILTER (WHERE ...)`, `(a JOIN b ON ...)`, `name TEXT CHARACTER SET utf8mb4` + if isInsideNestedExpression { + appendToken(kw) + return + } + closeLists(atOrDeeperThan: indent) inSelectColumns = false newline() + let lineStart = output.count appendToken(kw) if let ctx = context { replaceTop(with: ctx) } + if let list { + openLists.append(PendingList(kind: list, depth: indent, lineStart: lineStart)) + } } private mutating func handleAndOr(kw: String, upper: String) { - // BETWEEN x AND y — AND stays inline if upper == "AND" && afterBetween { afterBetween = false output += " " + kw afterNewline = false return } - if currentContext == .where_ { - newline() - output += indentStr() + " " + kw - afterNewline = false - } else { + guard currentContext == .where_ else { output += " " + kw afterNewline = false + return } + let pad = Pad(levels: indent + 1) + if afterNewline { + output += render(pad) + kw + } else { + appendSeparator(kind: .conditions, inline: " ", broken: "\n" + render(pad)) + output += kw + } + afterNewline = false } - private mutating func handleJoinPrefix(upper: String, kw: String, next: SQLToken?, next2: SQLToken?) { - // LEFT OUTER JOIN → skip 2 tokens (OUTER, JOIN) + private mutating func handleJoinPrefix(_ token: SQLToken, kw: String, next: SQLToken?, next2: SQLToken?) { + let upper = token.upperValue + let joinWords: String if next?.upperValue == "OUTER" && next2?.upperValue == "JOIN" { - inSelectColumns = false - let joinKw = options.keywordCase.synthesized("\(upper) OUTER JOIN") - newline() - appendToken(joinKw) - replaceTop(with: .join) + joinWords = "\(upper) OUTER JOIN" skipCount = 2 - // LEFT JOIN → skip 1 token (JOIN) } else if next?.upperValue == "JOIN" { - inSelectColumns = false - let joinKw = options.keywordCase.synthesized("\(upper) JOIN") - newline() - appendToken(joinKw) - replaceTop(with: .join) + joinWords = "\(upper) JOIN" skipCount = 1 } else { - appendToken(kw) + // `LEFT(name, 3)`, `RIGHT(code, 2)` + appendToken(cased(token, next: next)) + return + } + let joinKw = options.keywordCase.synthesized(joinWords) + if isInsideNestedExpression { + appendToken(joinKw) + return } + closeLists(atOrDeeperThan: indent) + inSelectColumns = false + newline() + appendToken(joinKw) + replaceTop(with: .join) } private mutating func handleOn(kw: String) { - if currentContext == .join { + if currentContext == .join && options.joinCondition == .newLine { newline() - output += indentStr() + " " + kw - afterNewline = false + output += render(Pad(levels: indent + 1)) + kw } else { output += " " + kw - afterNewline = false } + afterNewline = false } - private mutating func handleOrderGroup(upper: String, kw: String, next: SQLToken?) { - // Inside window function parens — stay inline - if clauseStack.contains(.windowParen) { + private mutating func handleOrderGroup(kw: String, next: SQLToken?) { + // Window specs and aggregates keep it inline: `OVER (ORDER BY x)`, `STRING_AGG(a, ',' ORDER BY a)` + if clauseStack.contains(.windowParen) || isInsideNestedExpression { if next?.upperValue == "BY" { let byKw = options.keywordCase.synthesized("BY") output += " " + kw + " " + byKw @@ -508,6 +563,7 @@ internal struct SQLTokenFormatter { } return } + closeLists(atOrDeeperThan: indent) inSelectColumns = false if next?.upperValue == "BY" { let byKw = options.keywordCase.synthesized("BY") @@ -521,6 +577,7 @@ internal struct SQLTokenFormatter { } private mutating func handleSetOperation(upper: String, kw: String, next: SQLToken?) { + closeLists(atOrDeeperThan: indent) inSelectColumns = false if let blockIdx = clauseStack.lastIndex(where: Self.isBlockContext) { clauseStack.removeSubrange((blockIdx + 1)...) @@ -532,7 +589,7 @@ internal struct SQLTokenFormatter { if upper == "UNION" && next?.upperValue == "ALL" { let allKw = options.keywordCase.synthesized("ALL") line += " " + allKw - skipCount = 1 // skip ALL + skipCount = 1 } if clauseStack.contains(where: Self.isBlockContext) { @@ -541,68 +598,153 @@ internal struct SQLTokenFormatter { output += "\n\n" + line + "\n\n" } afterNewline = true - isFirstClause = true } private mutating func handleCase(kw: String) { - if inSelectColumns { - caseBaseIndent = selectColumnIndent - } else { - caseBaseIndent = indent * options.indentSize - } + caseBasePad = inSelectColumns ? selectColumnPad : Pad(levels: indent) appendToken(kw) clauseStack.append(.caseExpr) } private mutating func handleWhenElse(kw: String) { - let whenIndent = caseBaseIndent + options.indentSize - output += "\n" + String(repeating: " ", count: whenIndent) + kw + let whenPad = Pad(levels: caseBasePad.levels + 1, spaces: caseBasePad.spaces) + output += "\n" + render(whenPad) + kw afterNewline = false suppressNextSpace = false } private mutating func handleEnd(kw: String) { if clauseStack.last == .caseExpr { clauseStack.removeLast() } - // Align END at same level as CASE - output += "\n" + String(repeating: " ", count: caseBaseIndent) - output += kw + output += "\n" + render(caseBasePad) + kw afterNewline = false } - private mutating func handleWith(kw: String) { - if !isFirstClause { output += "\n" } - output += kw - isFirstClause = false - afterNewline = false + private mutating func handleWith(kw: String, prev: SQLToken?) { + // `TIMESTAMP WITH TIME ZONE` in a column list or a cast is a type, not a CTE. + guard startStatement(kw: kw, prev: prev) else { return } clauseStack.append(.with) } - private mutating func handleInsert(kw: String, next: SQLToken?) { - if !isFirstClause { output += "\n" } + private mutating func handleInsert(kw: String, prev: SQLToken?, next: SQLToken?) { + guard startStatement(kw: kw, prev: prev) else { return } if next?.upperValue == "INTO" { - let intoKw = options.keywordCase.synthesized("INTO") - output += kw + " " + intoKw - skipCount = 1 // skip INTO - } else { - output += kw + output += " " + options.keywordCase.synthesized("INTO") + skipCount = 1 } - isFirstClause = false - afterNewline = false clauseStack.append(.insert) } - private mutating func handleStatementStart(kw: String, context: ClauseContext?) { - if !isFirstClause { output += "\n" } - output += kw - isFirstClause = false - afterNewline = false + private mutating func handleStatementStart(kw: String, prev: SQLToken?, context: ClauseContext?) { + guard startStatement(kw: kw, prev: prev) else { return } if let ctx = context { clauseStack.append(ctx) } } + // Inside parentheses, a column list or after a word it modifies, a statement keyword is part of a + // clause: `ON DELETE CASCADE`, `FOR UPDATE`, `ON DUPLICATE KEY UPDATE`, `BEFORE INSERT ON t`. + private static let statementKeywordModifiers: Set = [ + "ON", "FOR", "KEY", "BEFORE", "AFTER", "OF", "GRANT", "REVOKE", "WITH", ",", + ] + + private mutating func startStatement(kw: String, prev: SQLToken?) -> Bool { + if isInsideNestedExpression || prev.map({ Self.statementKeywordModifiers.contains($0.upperValue) }) == true { + appendToken(kw) + return false + } + if !afterNewline { newline() } + output += indentStr() + kw + afterNewline = false + suppressNextSpace = false + return true + } + + // MARK: - Lists + + private mutating func appendSeparator(kind: ListKind, inline: String, broken: String) { + let start = output.count + guard let index = openLists.lastIndex(where: { $0.kind == kind && $0.depth == indent }) else { + output += broken + return + } + output += inline + openLists[index].separators.append(Separator(range: start..= depth { + openLists.removeLast() + close(list) + } + } + + private mutating func close(_ list: PendingList) { + guard !list.separators.isEmpty else { return } + let end = output.count + if options.lineWidth > 0, fitsOnOneLine(list.lineStart.. 0 { + rebuilt.append(contentsOf: output.bytes[cursor..) -> Bool { + !output.containsNewline(in: range) && columns(of: output.text(in: range)) <= options.lineWidth + } + + // A column whose expression spans lines keeps its alias where it is. + private func aliasPadding(for list: PendingList) -> [Int: Int] { + guard options.alignAliases, list.kind == .select, list.aliasOffsets.count > 1 else { return [:] } + var widths: [Int: Int] = [:] + for (column, offset) in list.aliasOffsets { + let start = column == 0 ? list.lineStart : list.separators[column - 1].range.upperBound + guard !output.containsNewline(in: start.. Substring { + guard let newline = text.lastIndex(of: "\n") else { return Substring(text) } + return text[text.index(after: newline)...] + } + + private func columns(of text: S) -> Int { + text.reduce(0) { $0 + ($1 == "\t" ? options.indentWidth : 1) } + } + // MARK: - Helpers private var currentContext: ClauseContext? { clauseStack.last } + private var isInsideNestedExpression: Bool { + currentContext == .inlineParen || currentContext == .windowParen || currentContext == .createTableBody + } + private static func isBlockContext(_ ctx: ClauseContext) -> Bool { ctx == .subquery || ctx == .createTableBody } @@ -618,8 +760,12 @@ internal struct SQLTokenFormatter { clauseStack.append(ctx) } + private func render(_ pad: Pad) -> String { + String(repeating: indentUnit, count: max(pad.levels, 0)) + String(repeating: " ", count: max(pad.spaces, 0)) + } + private func indentStr() -> String { - String(repeating: " ", count: indent * options.indentSize) + render(Pad(levels: indent)) } private mutating func newline() { @@ -639,3 +785,28 @@ internal struct SQLTokenFormatter { suppressNextSpace = false } } + +// Bytes, so rewriting a list costs the list's length. Splices fall between tokens, so the UTF-8 stays valid. +private struct FormatterOutput { + private(set) var bytes: [UInt8] = [] + + var count: Int { bytes.count } + + var string: String { String(decoding: bytes, as: UTF8.self) } // swiftlint:disable:this optional_data_string_conversion + + static func += (lhs: inout FormatterOutput, rhs: String) { + lhs.bytes.append(contentsOf: rhs.utf8) + } + + func containsNewline(in range: Range) -> Bool { + bytes[range].contains(UInt8(ascii: "\n")) + } + + func text(in range: Range) -> String { + String(decoding: bytes[range], as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + } + + mutating func replace(_ range: Range, with replacement: [UInt8]) { + bytes.replaceSubrange(range, with: replacement) + } +} diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift index 0bbfef3d2c..5549c5e8b0 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift @@ -2,27 +2,15 @@ // 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 - -/// The case the formatter writes a keyword in. -/// -/// `preserve` keeps the spelling that is already in the statement wherever one exists; a keyword -/// the formatter synthesizes, such as the `BY` of a split `ORDER BY`, has no such spelling and is -/// written lower case. -enum SQLFormatterKeywordCase { +// A word the formatter writes itself, the `BY` of `ORDER BY`, has no spelling to preserve: lower case. +enum SQLFormatterCase: String, Codable, CaseIterable, Sendable { case upper case lower case preserve - /// `upper` is the token's uppercased form, `original` the spelling the statement already had. func applied(upper: String, original: String) -> String { switch self { case .upper: return upper @@ -31,31 +19,110 @@ enum SQLFormatterKeywordCase { } } - /// A keyword the formatter writes from nothing, so there is no original spelling to preserve. func synthesized(_ upper: String) -> String { switch self { case .upper: return upper case .lower, .preserve: return upper.lowercased() } } +} + +enum SQLFormatterIndentStyle: String, Codable, CaseIterable, Sendable { + case spaces + case tabs +} - /// Whether a token that is really a function or type name keeps the spelling it was written - /// with. Only `preserve` has nothing to decide, because it keeps every spelling anyway. - var rewritesKeywords: Bool { self != .preserve } +enum SQLFormatterCommaPosition: String, Codable, CaseIterable, Sendable { + case trailing + case leading } -/// Configuration for SQL formatting behavior -struct SQLFormatterOptions { - var keywordCase: SQLFormatterKeywordCase = .upper - var indentSize: Int = 2 - var preserveComments: Bool = true +enum SQLFormatterJoinCondition: String, Codable, CaseIterable, Sendable { + case newLine + case sameLine +} + +// The rule set the formatter runs with, and the JSON a rules file holds. +struct SQLFormatterOptions: Equatable, Sendable { + static let indentWidthRange = 1...16 + static let lineWidthRange = 0...1_000 + + var indentStyle: SQLFormatterIndentStyle = .spaces + var indentWidth: Int = 2 + var keywordCase: SQLFormatterCase = .upper + var functionCase: SQLFormatterCase = .preserve + var commaPosition: SQLFormatterCommaPosition = .trailing + // 0 puts each item of a SELECT list, SET list or WHERE chain on its own line. + var lineWidth: Int = 0 + var joinCondition: SQLFormatterJoinCondition = .newLine + var alignAliases: Bool = false static let `default` = SQLFormatterOptions() + + var indentUnit: String { + indentStyle == .tabs ? "\t" : String(repeating: " ", count: indentWidth) + } } -// MARK: - Formatter Result +extension SQLFormatterOptions: Codable { + enum CodingKeys: String, CodingKey, CaseIterable { + case indentStyle + case indentWidth + case keywordCase + case functionCase + case commaPosition + case lineWidth + case joinCondition + case alignAliases + } + + // Strict, for files: a value this build can't read throws, a key it doesn't know is ignored. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = Self.default + indentStyle = try container.decodeIfPresent(SQLFormatterIndentStyle.self, forKey: .indentStyle) + ?? fallback.indentStyle + indentWidth = try Self.decodeInt(container, .indentWidth, in: Self.indentWidthRange) ?? fallback.indentWidth + keywordCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .keywordCase) ?? fallback.keywordCase + functionCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .functionCase) + ?? fallback.functionCase + commaPosition = try container.decodeIfPresent(SQLFormatterCommaPosition.self, forKey: .commaPosition) + ?? fallback.commaPosition + lineWidth = try Self.decodeInt(container, .lineWidth, in: Self.lineWidthRange) ?? fallback.lineWidth + joinCondition = try container.decodeIfPresent(SQLFormatterJoinCondition.self, forKey: .joinCondition) + ?? fallback.joinCondition + alignAliases = try container.decodeIfPresent(Bool.self, forKey: .alignAliases) ?? fallback.alignAliases + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(indentStyle, forKey: .indentStyle) + try container.encode(indentWidth, forKey: .indentWidth) + try container.encode(keywordCase, forKey: .keywordCase) + try container.encode(functionCase, forKey: .functionCase) + try container.encode(commaPosition, forKey: .commaPosition) + try container.encode(lineWidth, forKey: .lineWidth) + try container.encode(joinCondition, forKey: .joinCondition) + try container.encode(alignAliases, forKey: .alignAliases) + } + + private static func decodeInt( + _ container: KeyedDecodingContainer, + _ key: CodingKeys, + in range: ClosedRange + ) throws -> Int? { + guard let value = try container.decodeIfPresent(Int.self, forKey: key) else { return nil } + guard range.contains(value) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must be between \(range.lowerBound) and \(range.upperBound)" + ) + } + return value + } +} -/// Result of a formatting operation with cursor mapping struct SQLFormatterResult { let formattedSQL: String let cursorOffset: Int? @@ -66,9 +133,6 @@ struct SQLFormatterResult { } } -// MARK: - Formatter Error - -/// Errors that can occur during SQL formatting enum SQLFormatterError: LocalizedError { case emptyInput case dialectUnsupported(DatabaseType) @@ -89,27 +153,19 @@ enum SQLFormatterError: LocalizedError { } } -// 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 } + // False where the engine rejects a built-in function spelled in another case (ClickHouse). + var functionNamesAreCaseInsensitive: Bool { 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()) diff --git a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift index c9baa97ab5..3967c77aa8 100644 --- a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -15,12 +15,14 @@ struct PluginDialectAdapter: SQLDialectProvider { let keywords: Set let functions: Set let dataTypes: Set + let functionNamesAreCaseInsensitive: Bool init(descriptor: SQLDialectDescriptor) { self.identifierQuote = descriptor.identifierQuote self.keywords = descriptor.keywords self.functions = descriptor.functions self.dataTypes = descriptor.dataTypes + self.functionNamesAreCaseInsensitive = descriptor.functionNamesAreCaseInsensitive } } @@ -31,6 +33,7 @@ private struct EmptyDialect: SQLDialectProvider { let keywords: Set = [] let functions: Set = [] let dataTypes: Set = [] + let functionNamesAreCaseInsensitive = false } // MARK: - Dialect Factory diff --git a/apple/TablePro/Core/Storage/AppSettingsManager.swift b/apple/TablePro/Core/Storage/AppSettingsManager.swift index 798646aefb..f17d83f2a4 100644 --- a/apple/TablePro/Core/Storage/AppSettingsManager.swift +++ b/apple/TablePro/Core/Storage/AppSettingsManager.swift @@ -50,6 +50,18 @@ final class AppSettingsManager: ObservableObject { } } + @Published var formatter: SQLFormatterSettings { + didSet { + guard formatter != oldValue else { return } + storage.saveFormatter(formatter) + syncTracker.markDirty(.settings, id: AppSettingsCategory.formatter) + } + } + + var formatterOptions: SQLFormatterOptions { + formatter.options(for: editor) + } + /// Device-local: it is deliberately absent from `AppSettingsCategory.synced`, so a zoom press /// never pushes an iCloud record and a Mac on an older build cannot reset it. @Published var typography: TypographySettings { @@ -207,6 +219,7 @@ final class AppSettingsManager: ObservableObject { self.appearance = storage.loadAppearance() self.appearanceMode = storage.loadAppearanceMode() self.editor = storage.loadEditor() + self.formatter = storage.loadFormatter() self.dataGrid = storage.loadDataGrid() self.history = storage.loadHistory() self.tabs = storage.loadTabs() @@ -254,6 +267,7 @@ final class AppSettingsManager: ObservableObject { appearance = .default appearanceMode = .auto editor = .default + formatter = .default dataGrid = .default history = .default tabs = .default diff --git a/apple/TablePro/Core/Storage/AppSettingsStorage.swift b/apple/TablePro/Core/Storage/AppSettingsStorage.swift index b602cd435c..add50ed0f8 100644 --- a/apple/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/apple/TablePro/Core/Storage/AppSettingsStorage.swift @@ -25,6 +25,7 @@ final class AppSettingsStorage: Sendable { static let appearance = "com.TablePro.settings.appearance" static let appearanceMode = "com.TablePro.settings.appearanceMode" static let editor = "com.TablePro.settings.editor" + static let formatter = "com.TablePro.settings.formatter" static let dataGrid = "com.TablePro.settings.dataGrid" static let history = "com.TablePro.settings.history" static let tabs = "com.TablePro.settings.tabs" @@ -164,6 +165,16 @@ final class AppSettingsStorage: Sendable { save(settings, key: Keys.editor) } + // MARK: - Formatter Settings + + func loadFormatter() -> SQLFormatterSettings { + load(key: Keys.formatter, default: .default) + } + + func saveFormatter(_ settings: SQLFormatterSettings) { + save(settings, key: Keys.formatter) + } + // MARK: - Data Grid Settings func loadDataGrid() -> DataGridSettings { @@ -289,6 +300,7 @@ final class AppSettingsStorage: Sendable { saveAppearance(.default) saveAppearanceMode(.auto) saveEditor(.default) + saveFormatter(.default) saveDataGrid(.default) saveHistory(.default) saveTabs(.default) diff --git a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift index 1ed7b9e32a..13f872250d 100644 --- a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift +++ b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift @@ -20,6 +20,7 @@ extension SyncCoordinator { case AppSettingsCategory.keyboard: return try encoder.encode(storage.loadKeyboard()) case AppSettingsCategory.ai: return try encoder.encode(storage.loadAI()) case AppSettingsCategory.notifications: return try encoder.encode(storage.loadNotifications()) + case AppSettingsCategory.formatter: return try encoder.encode(storage.loadFormatter()) case CustomSlashCommandStorage.syncCategory: return try encoder.encode(CustomSlashCommandStorage.shared.commands) case let category where category.hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix): @@ -53,6 +54,8 @@ extension SyncCoordinator { case AppSettingsCategory.ai: manager.ai = try decoder.decode(AISettings.self, from: data) case AppSettingsCategory.notifications: manager.notifications = try decoder.decode(NotificationSettings.self, from: data) + case AppSettingsCategory.formatter: + manager.formatter = try decoder.decode(SQLFormatterSettings.self, from: data) case CustomSlashCommandStorage.syncCategory: CustomSlashCommandStorage.shared.applyRemote(try decoder.decode([CustomSlashCommand].self, from: data)) case let category where category.hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix): @@ -75,8 +78,11 @@ extension SyncCoordinator { // picks up the theme records an older build skipped. static let themeContentKind = "\(SyncRecordType.settings.rawValue)/theme.v\(ThemeSchema.current)" + // Fetches the zone once for a formatter record this Mac skipped while on an older build. + static let formatterContentKind = "\(SyncRecordType.settings.rawValue)/\(AppSettingsCategory.formatter).v1" + static var readableContent: Set { - Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind]) + Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind, Self.formatterContentKind]) } // Only what this Mac applies: a pull skips a category that is off and still moves the token past it. @@ -84,6 +90,7 @@ extension SyncCoordinator { var content = Set(SyncRecordType.verifiedInProduction.filter { settings.syncs($0) }.map(\.rawValue)) if settings.syncs(.settings) { content.insert(Self.themeContentKind) + content.insert(Self.formatterContentKind) } return content } diff --git a/apple/TablePro/Models/Settings/AppSettingsCategory.swift b/apple/TablePro/Models/Settings/AppSettingsCategory.swift index e4826e3f1e..eaab17b259 100644 --- a/apple/TablePro/Models/Settings/AppSettingsCategory.swift +++ b/apple/TablePro/Models/Settings/AppSettingsCategory.swift @@ -18,6 +18,8 @@ internal enum AppSettingsCategory { internal static let keyboard = "keyboard" internal static let ai = "ai" internal static let notifications = "notifications" + // Not a field of `editor`: an older build re-uploads that blob without it, which would reset it here. + internal static let formatter = "formatter" internal static let appearanceMode = "appearanceMode" // `sync` holds the switch that turns syncing on, `mcp` a port and server setup for one machine, @@ -25,6 +27,6 @@ internal enum AppSettingsCategory { internal static let deviceLocal: Set = ["sync", "mcp", appearanceMode] internal static let synced: [String] = [ - general, appearance, editor, dataGrid, history, tabs, keyboard, ai, notifications + general, appearance, editor, dataGrid, history, tabs, keyboard, ai, notifications, formatter ] } diff --git a/apple/TablePro/Models/Settings/SQLFormatterSettings.swift b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift new file mode 100644 index 0000000000..9fa00e6e35 --- /dev/null +++ b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift @@ -0,0 +1,93 @@ +// +// SQLFormatterSettings.swift +// TablePro +// + +import Foundation +import TableProAutocomplete + +// The defaults reproduce the formatter's output from before it had settings. +struct SQLFormatterSettings: Codable, Equatable { + var rules: SQLFormatterOptions + var indentFollowsTabWidth: Bool + var keywordCaseFollowsEditor: Bool + + static let `default` = SQLFormatterSettings() + + init( + rules: SQLFormatterOptions = .default, + indentFollowsTabWidth: Bool = false, + keywordCaseFollowsEditor: Bool = true + ) { + self.rules = rules + self.indentFollowsTabWidth = indentFollowsTabWidth + self.keywordCaseFollowsEditor = keywordCaseFollowsEditor + } + + // An imported file names every rule, so nothing follows this Mac's other settings afterwards. + init(imported rules: SQLFormatterOptions) { + self.init(rules: rules, indentFollowsTabWidth: false, keywordCaseFollowsEditor: false) + } + + func options(for editor: EditorSettings) -> SQLFormatterOptions { + resolved(tabWidth: editor.clampedTabWidth, editorKeywordCase: editor.keywordCase) + } + + func resolved(tabWidth: Int, editorKeywordCase: SQLKeywordCase) -> SQLFormatterOptions { + var options = rules + if indentFollowsTabWidth { + options.indentWidth = min(max(tabWidth, SQLFormatterOptions.indentWidthRange.lowerBound), + SQLFormatterOptions.indentWidthRange.upperBound) + } + if keywordCaseFollowsEditor { + options.keywordCase = editorKeywordCase.prefersUppercase ? .upper : .lower + } + return options + } + + private enum FollowKeys: String, CodingKey { + case indentFollowsTabWidth + case keywordCaseFollowsEditor + } + + // Lenient, like every synced settings blob: a value this build cannot read costs only that field. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SQLFormatterOptions.CodingKeys.self) + let follow = try decoder.container(keyedBy: FollowKeys.self) + let fallback = SQLFormatterOptions.default + var rules = SQLFormatterOptions() + rules.indentStyle = container.decodeSetting(.indentStyle, default: fallback.indentStyle) + rules.indentWidth = Self.inRange( + container.decodeSetting(.indentWidth, default: fallback.indentWidth), + SQLFormatterOptions.indentWidthRange, + otherwise: fallback.indentWidth + ) + rules.keywordCase = container.decodeSetting(.keywordCase, default: fallback.keywordCase) + rules.functionCase = container.decodeSetting(.functionCase, default: fallback.functionCase) + rules.commaPosition = container.decodeSetting(.commaPosition, default: fallback.commaPosition) + rules.lineWidth = Self.inRange( + container.decodeSetting(.lineWidth, default: fallback.lineWidth), + SQLFormatterOptions.lineWidthRange, + otherwise: fallback.lineWidth + ) + rules.joinCondition = container.decodeSetting(.joinCondition, default: fallback.joinCondition) + rules.alignAliases = container.decodeSetting(.alignAliases, default: fallback.alignAliases) + self.rules = rules + let defaults = Self.default + indentFollowsTabWidth = follow.decodeSetting(.indentFollowsTabWidth, default: defaults.indentFollowsTabWidth) + keywordCaseFollowsEditor = follow.decodeSetting( + .keywordCaseFollowsEditor, default: defaults.keywordCaseFollowsEditor + ) + } + + func encode(to encoder: Encoder) throws { + try rules.encode(to: encoder) + var follow = encoder.container(keyedBy: FollowKeys.self) + try follow.encode(indentFollowsTabWidth, forKey: .indentFollowsTabWidth) + try follow.encode(keywordCaseFollowsEditor, forKey: .keywordCaseFollowsEditor) + } + + private static func inRange(_ value: Int, _ range: ClosedRange, otherwise fallback: Int) -> Int { + range.contains(value) ? value : fallback + } +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..47848483f6 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -8394,6 +8394,9 @@ } } } + }, + "%d characters" : { + }, "%d columns" : { "localizations" : { @@ -9909,6 +9912,9 @@ } } } + }, + "%d spaces" : { + }, "%d statements applied" : { "extractionState" : "manual", @@ -14585,6 +14591,9 @@ } } } + }, + "A .sql-formatter.json in a linked SQL folder replaces these rules for the files opened from that folder." : { + }, "A %@ attribute has a payload of the wrong shape" : { "extractionState" : "manual", @@ -16249,6 +16258,9 @@ } } } + }, + "A SELECT list, SET list or WHERE condition that fits in this width stays on one line." : { + }, "A SELECT to export. Use instead of 'tables'." : { "localizations" : { @@ -21547,6 +21559,9 @@ } } } + }, + "Align column aliases" : { + }, "All" : { "localizations" : { @@ -27026,6 +27041,9 @@ }, "As stored" : { + }, + "As written" : { + }, "Ascending" : { "localizations" : { @@ -27644,6 +27662,12 @@ } } } + }, + "At the end of the line" : { + + }, + "At the start of the line" : { + }, "At their declared lengths the columns pass InnoDB's 8,126-byte record limit, so this one is %@, which does not enforce a length." : { "localizations" : { @@ -43887,6 +43911,9 @@ } } } + }, + "Commas:" : { + }, "Comment" : { "extractionState" : "manual", @@ -53388,9 +53415,15 @@ }, "Couldn't Export the Theme" : { + }, + "Couldn't Format Query" : { + }, "Couldn't Import “%@”" : { + }, + "Couldn't Import Formatter Rules" : { + }, "Couldn't Move File to Trash" : { "localizations" : { @@ -70548,6 +70581,9 @@ } } } + }, + "Engines that match function names by exact case, such as ClickHouse, keep the case you typed." : { + }, "Enter a _key for the new row. Bigtable has no generated keys." : { "extractionState" : "manual", @@ -83548,6 +83584,9 @@ } } } + }, + "Formatter" : { + }, "Formatter error: %@" : { "localizations" : { @@ -84073,6 +84112,9 @@ } } } + }, + "Function case:" : { + }, "Function: %@" : { "localizations" : { @@ -91969,6 +92011,12 @@ } } } + }, + "Indent width:" : { + + }, + "Indent with:" : { + }, "Index" : { @@ -95686,6 +95734,9 @@ } } } + }, + "Join condition:" : { + }, "Joins this column with the one to its right." : { "localizations" : { @@ -100732,6 +100783,9 @@ } } } + }, + "Line width:" : { + }, "Link" : { "extractionState" : "manual", @@ -105363,6 +105417,9 @@ } } } + }, + "Match what I type" : { + }, "Match what I type, otherwise lowercase" : { "extractionState" : "manual", @@ -105375,9 +105432,6 @@ } } } - }, - "Match what I type" : { - }, "Match what I type, otherwise UPPERCASE" : { "extractionState" : "manual", @@ -123342,6 +123396,9 @@ } } } + }, + "On its own line" : { + }, "On Quit" : { "extractionState" : "stale", @@ -123419,6 +123476,9 @@ } } } + }, + "On the JOIN line" : { + }, "On the Server" : { "localizations" : { @@ -123611,6 +123671,9 @@ } } } + }, + "One item per line" : { + }, "One JSON object" : { "extractionState" : "manual", @@ -147180,6 +147243,9 @@ } } } + }, + "Restore Defaults" : { + }, "Restore Dump Cancelled" : { "comment" : "A title for a result sheet that indicates a restore operation was cancelled.", @@ -151393,6 +151459,9 @@ } } } + }, + "Rules file:" : { + }, "Rules for this query result are not saved." : { "localizations" : { @@ -152774,6 +152843,9 @@ } } } + }, + "Same as editor (%@)" : { + }, "same element" : { "localizations" : { @@ -167262,6 +167334,9 @@ } } } + }, + "Spaces" : { + }, "Spacing" : { "extractionState" : "stale", @@ -175075,6 +175150,9 @@ } } } + }, + "Tab width (%d)" : { + }, "Tab width:" : { "localizations" : { @@ -176645,9 +176723,6 @@ } } } - }, - "Table, column and schema names take this case in autocomplete and in Copy as INSERT and UPDATE when the server reads the new spelling as the same name. Other names are quoted as stored." : { - }, "Table-level CHECK constraints" : { "localizations" : { @@ -176658,6 +176733,9 @@ } } } + }, + "Table, column and schema names take this case in autocomplete and in Copy as INSERT and UPDATE when the server reads the new spelling as the same name. Other names are quoted as stored." : { + }, "TablePro" : { "extractionState" : "stale", @@ -183850,6 +183928,12 @@ } } } + }, + "The file is larger than %@, too large for a rules file." : { + + }, + "The file is not a JSON object." : { + }, "The file is not valid JSON." : { "extractionState" : "manual", @@ -184114,6 +184198,16 @@ } } }, + "The formatter rules in “%@” could not be read. %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The formatter rules in “%1$@” could not be read. %2$@" + } + } + } + }, "The FreeTDS configuration could not be written: %@" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -186810,6 +186904,9 @@ } } } + }, + "The rules file is not a regular file." : { + }, "The run button sits in the gutter, which needs line numbers." : { @@ -190330,6 +190427,9 @@ } } } + }, + "The value of “%@” is not valid." : { + }, "The value of %@ must be text or null. Bigtable stores bytes, so a number is written as its digits." : { "extractionState" : "manual", diff --git a/apple/TablePro/Views/Editor/QueryEditorView.swift b/apple/TablePro/Views/Editor/QueryEditorView.swift index c570121d9b..531cae6188 100644 --- a/apple/TablePro/Views/Editor/QueryEditorView.swift +++ b/apple/TablePro/Views/Editor/QueryEditorView.swift @@ -19,6 +19,7 @@ struct QueryEditorView: View { var databaseScope: DatabaseScope? var connectionId: UUID? var tabID: UUID? + var sourceFileURL: URL? var claimFocusOnAppear: Bool = false var onFocusClaimed: (() -> Void)? var restoredCursorRange: NSRange? @@ -94,6 +95,7 @@ struct QueryEditorView: View { databaseScope: databaseScope, connectionId: connectionId, tabID: tabID, + sourceFileURL: sourceFileURL, claimFocusOnAppear: claimFocusOnAppear, onFocusClaimed: onFocusClaimed, restoredCursorRange: restoredCursorRange, diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 5c71b5231c..1c5db8b394 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -120,6 +120,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie var databaseType: DatabaseType? var tabID: UUID? var connectionId: UUID? + var sourceFileURL: URL? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -473,8 +474,20 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } private func formatSQL(selectedRange: NSRange) { - guard let textView = controller?.textView, - let formatter = QueryFormatterFactory.make(for: databaseType) else { return } + guard let textView = controller?.textView else { return } + let formatter: QueryFormatting + do { + guard let made = try QueryFormatterFactory.make(for: databaseType, sourceFileURL: sourceFileURL) else { return } + formatter = made + } catch { + Self.logger.error("Formatter rules unreadable: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Format Query"), + message: error.localizedDescription, + window: textView.window + ) + return + } let scope = FormatScopeResolver.resolve( fullText: textView.string, selectedRange: selectedRange diff --git a/apple/TablePro/Views/Editor/SQLEditorView.swift b/apple/TablePro/Views/Editor/SQLEditorView.swift index 5529bb47d1..0892631e30 100644 --- a/apple/TablePro/Views/Editor/SQLEditorView.swift +++ b/apple/TablePro/Views/Editor/SQLEditorView.swift @@ -28,6 +28,7 @@ struct SQLEditorView: View { var databaseScope: DatabaseScope? var connectionId: UUID? var tabID: UUID? + var sourceFileURL: URL? var claimFocusOnAppear: Bool = false /// Called once the editor has latched a focus claim. The owner's one-shot intent is cleared /// here rather than on its own `onAppear`, which fires before this subtree renders. @@ -67,6 +68,7 @@ struct SQLEditorView: View { coordinator.databaseType = databaseType coordinator.tabID = tabID coordinator.connectionId = connectionId + coordinator.sourceFileURL = sourceFileURL if claimFocusOnAppear { coordinator.scheduleEditorFocusClaim() onFocusClaimed?() diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index ae3a7973b5..b064ca38fa 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -524,6 +524,7 @@ struct MainEditorContentView: View { databaseScope: queryScope, connectionId: coordinator.connection.id, tabID: tab.id, + sourceFileURL: tab.content.sourceFileURL, claimFocusOnAppear: claimFocus, onFocusClaimed: { if coordinator.tabManager.pendingFocusTabId == tab.id { diff --git a/apple/TablePro/Views/Settings/EditorSettingsView.swift b/apple/TablePro/Views/Settings/EditorSettingsView.swift index 376545f453..6a3aa572cb 100644 --- a/apple/TablePro/Views/Settings/EditorSettingsView.swift +++ b/apple/TablePro/Views/Settings/EditorSettingsView.swift @@ -8,6 +8,7 @@ import TableProAutocomplete struct EditorSettingsView: View { @Binding var settings: EditorSettings + @Binding var formatter: SQLFormatterSettings @Binding var typography: TypographySettings var body: some View { @@ -44,12 +45,14 @@ struct EditorSettingsView: View { Toggle("Vim mode", isOn: $settings.vimModeEnabled) .accessibilityIdentifier("vim-mode-toggle") } + + FormatterSection(settings: $formatter, editor: settings) } .formStyle(.grouped) } } #Preview { - EditorSettingsView(settings: .constant(.default), typography: .constant(.default)) + EditorSettingsView(settings: .constant(.default), formatter: .constant(.default), typography: .constant(.default)) .frame(width: 450, height: 500) } diff --git a/apple/TablePro/Views/Settings/Sections/FormatterSection.swift b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift new file mode 100644 index 0000000000..233884d11e --- /dev/null +++ b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift @@ -0,0 +1,276 @@ +// +// FormatterSection.swift +// TablePro +// + +import AppKit +import os +import SwiftUI +import TableProAutocomplete +import UniformTypeIdentifiers + +struct FormatterSection: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "FormatterSection") + private static let previewMaxLines = 18 + private static let previewSQL = """ + select u.id as user_id, u.name as user_name, count(o.id) as order_count \ + from users u left join orders o on u.id = o.user_id \ + where u.status = 'active' and o.created_at >= '2026-01-01' \ + group by u.id, u.name order by order_count desc; + """ + private static let indentWidths = [2, 4, 8] + private static let lineWidths = [0, 80, 100, 120] + + @Binding var settings: SQLFormatterSettings + let editor: EditorSettings + @ObservedObject private var themeEngine = ThemeEngine.shared + + @State private var isImporting = false + @State private var exportDocument: SQLFormatterRulesDocument? + @State private var importError: String? + + private enum IndentWidthChoice: Hashable { + case tabWidth + case fixed(Int) + } + + private enum KeywordCaseChoice: Hashable { + case editor + case fixed(SQLFormatterCase) + } + + var body: some View { + Section { + Picker("Indent with:", selection: $settings.rules.indentStyle) { + Text("Spaces").tag(SQLFormatterIndentStyle.spaces) + Text("Tabs").tag(SQLFormatterIndentStyle.tabs) + } + Picker("Indent width:", selection: indentWidthChoice) { + Text(String(format: String(localized: "Tab width (%d)"), editor.clampedTabWidth)) + .tag(IndentWidthChoice.tabWidth) + ForEach(Self.options(Self.indentWidths, including: settings.rules.indentWidth), id: \.self) { width in + Text(String(format: String(localized: "%d spaces"), width)).tag(IndentWidthChoice.fixed(width)) + } + } + Picker("Keyword case:", selection: keywordCaseChoice) { + Text(String(format: String(localized: "Same as editor (%@)"), caseName(editorKeywordCase))) + .tag(KeywordCaseChoice.editor) + ForEach(SQLFormatterCase.allCases, id: \.self) { formatterCase in + Text(caseName(formatterCase)).tag(KeywordCaseChoice.fixed(formatterCase)) + } + } + Picker("Function case:", selection: $settings.rules.functionCase) { + ForEach(SQLFormatterCase.allCases, id: \.self) { formatterCase in + Text(caseName(formatterCase)).tag(formatterCase) + } + } + .help(Text("Engines that match function names by exact case, such as ClickHouse, keep the case you typed.")) + Picker("Commas:", selection: $settings.rules.commaPosition) { + Text("At the end of the line").tag(SQLFormatterCommaPosition.trailing) + Text("At the start of the line").tag(SQLFormatterCommaPosition.leading) + } + Picker("Line width:", selection: $settings.rules.lineWidth) { + ForEach(Self.options(Self.lineWidths, including: settings.rules.lineWidth), id: \.self) { width in + Text(lineWidthName(width)).tag(width) + } + } + .help(Text("A SELECT list, SET list or WHERE condition that fits in this width stays on one line.")) + Picker("Join condition:", selection: $settings.rules.joinCondition) { + Text("On its own line").tag(SQLFormatterJoinCondition.newLine) + Text("On the JOIN line").tag(SQLFormatterJoinCondition.sameLine) + } + Toggle("Align column aliases", isOn: $settings.rules.alignAliases) + + preview + + LabeledContent("Rules file:") { + HStack { + Button("Import…") { isImporting = true } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.json]) { result in + importRules(result) + } + Button("Export…") { exportRules() } + .fileExporter( + isPresented: isExporting, + document: exportDocument, + contentType: .json, + defaultFilename: SQLFormatterRulesFile.exportFileName + ) { _ in } + Button("Restore Defaults") { settings = .default } + .disabled(settings == .default) + } + } + } header: { + Text("Formatter") + } footer: { + Text("A .sql-formatter.json in a linked SQL folder replaces these rules for the files opened from that folder.") + .font(.caption) + .foregroundStyle(.secondary) + } + .alert( + String(localized: "Couldn't Import Formatter Rules"), + isPresented: isShowingImportError, + presenting: importError + ) { _ in + Button(String(localized: "OK")) {} + } message: { message in + Text(message) + } + } + + // MARK: - Preview + + private var previewText: String { + let formatted = try? SQLFormatterService().format( + Self.previewSQL, + dialect: .mysql, + options: settings.options(for: editor) + ) + return formatted?.formattedSQL ?? Self.previewSQL + } + + // Unwrapped, since a wrapped preview would hide the line breaks the line width decides. + private var preview: some View { + let text = previewText + let lineCount = min(text.split(separator: "\n", omittingEmptySubsequences: false).count, Self.previewMaxLines) + let lineHeight = NSLayoutManager().defaultLineHeight(for: themeEngine.editorFonts.font) + return HighlightedSQLTextView( + sql: text, + databaseType: .mysql, + accessibilityIdentifier: "formatter-preview" + ) + .frame(height: CGFloat(lineCount) * lineHeight + Self.previewChromeHeight) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + } + + // The text view's top and bottom inset, plus a legacy horizontal scroller when one takes room. + private static var previewChromeHeight: CGFloat { + let scroller = NSScroller.preferredScrollerStyle == .legacy + ? NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + : 0 + return 24 + scroller + } + + // MARK: - Bindings + + private var indentWidthChoice: Binding { + Binding( + get: { + settings.indentFollowsTabWidth + ? .tabWidth + : .fixed(settings.rules.indentWidth) + }, + set: { choice in + var formatter = settings + switch choice { + case .tabWidth: + formatter.indentFollowsTabWidth = true + case .fixed(let width): + formatter.indentFollowsTabWidth = false + formatter.rules.indentWidth = width + } + settings = formatter + } + ) + } + + private var keywordCaseChoice: Binding { + Binding( + get: { + settings.keywordCaseFollowsEditor + ? .editor + : .fixed(settings.rules.keywordCase) + }, + set: { choice in + var formatter = settings + switch choice { + case .editor: + formatter.keywordCaseFollowsEditor = true + case .fixed(let formatterCase): + formatter.keywordCaseFollowsEditor = false + formatter.rules.keywordCase = formatterCase + } + settings = formatter + } + ) + } + + private var isExporting: Binding { + Binding(get: { exportDocument != nil }, set: { if !$0 { exportDocument = nil } }) + } + + private var isShowingImportError: Binding { + Binding(get: { importError != nil }, set: { if !$0 { importError = nil } }) + } + + // MARK: - Import and export + + private func importRules(_ result: Result) { + guard case .success(let url) = result else { return } + let isScoped = url.startAccessingSecurityScopedResource() + defer { if isScoped { url.stopAccessingSecurityScopedResource() } } + do { + settings = SQLFormatterSettings(imported: try SQLFormatterRulesFile.load(from: url)) + } catch { + Self.logger.error("Formatter rules import failed: \(error.publicLogShape, privacy: .public)") + importError = error.localizedDescription + } + } + + // The resolved rules, so the file formats alike on a Mac whose tab width or keyword case differs. + private func exportRules() { + do { + exportDocument = SQLFormatterRulesDocument(data: try SQLFormatterRulesFile.encode(settings.options(for: editor))) + } catch { + Self.logger.error("Formatter rules export failed: \(error.publicLogShape, privacy: .public)") + } + } + + // MARK: - Labels + + private var editorKeywordCase: SQLFormatterCase { + editor.keywordCase.prefersUppercase ? .upper : .lower + } + + private func caseName(_ formatterCase: SQLFormatterCase) -> String { + switch formatterCase { + case .upper: return String(localized: "UPPERCASE") + case .lower: return String(localized: "lowercase") + case .preserve: return String(localized: "As written") + } + } + + private func lineWidthName(_ width: Int) -> String { + width == 0 + ? String(localized: "One item per line") + : String(format: String(localized: "%d characters"), width) + } + + // A value from an imported file that the picker does not offer still shows as selected. + private static func options(_ values: [Int], including current: Int) -> [Int] { + values.contains(current) ? values : (values + [current]).sorted() + } +} + +struct SQLFormatterRulesDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } + + let data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } + self.data = data + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/apple/TablePro/Views/Settings/SettingsWindowController.swift b/apple/TablePro/Views/Settings/SettingsWindowController.swift index 4bd656edc0..54a8b7f302 100644 --- a/apple/TablePro/Views/Settings/SettingsWindowController.swift +++ b/apple/TablePro/Views/Settings/SettingsWindowController.swift @@ -168,6 +168,7 @@ private struct SettingsPaneContent: View { case .editor: EditorSettingsView( settings: $settingsManager.editor, + formatter: $settingsManager.formatter, typography: $settingsManager.typography ) case .data: diff --git a/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift b/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift new file mode 100644 index 0000000000..f471badf13 --- /dev/null +++ b/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift @@ -0,0 +1,305 @@ +// +// SQLFormatterRulesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProAutocomplete +import TableProSyncTransport +import Testing + +@MainActor +struct SQLFormatterRulesTests { + private static let custom = SQLFormatterOptions( + indentStyle: .tabs, + indentWidth: 4, + keywordCase: .lower, + functionCase: .upper, + commaPosition: .leading, + lineWidth: 100, + joinCondition: .sameLine, + alignAliases: true + ) + + private func decode(_ json: String) throws -> SQLFormatterOptions { + try SQLFormatterRulesFile.decode(Data(json.utf8)) + } + + // MARK: - Rules file + + @Test("A rules file round-trips every rule") + func fileRoundTrip() throws { + let data = try SQLFormatterRulesFile.encode(Self.custom) + #expect(try SQLFormatterRulesFile.decode(data) == Self.custom) + } + + @Test("A rules file names every rule, so another Mac reads no default of its own") + func fileWritesEveryKey() throws { + let data = try SQLFormatterRulesFile.encode(.default) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let everyKey = Set(SQLFormatterOptions.CodingKeys.allCases.map { $0.stringValue }) + #expect(Set(object.keys) == everyKey) + #expect(object["indentStyle"] as? String == "spaces") + #expect(object["commaPosition"] as? String == "trailing") + #expect(object["joinCondition"] as? String == "newLine") + } + + @Test("A key the file leaves out takes the default") + func missingKeysTakeDefaults() throws { + #expect(try decode("{}") == .default) + var expected = SQLFormatterOptions.default + expected.commaPosition = .leading + #expect(try decode(#"{"commaPosition": "leading"}"#) == expected) + } + + @Test("A key this build does not know is ignored") + func unknownKeysAreIgnored() throws { + let options = try decode(#"{"language": "postgresql", "indentWidth": 4}"#) + #expect(options.indentWidth == 4) + } + + @Test( + "A value this build cannot read names its key", + arguments: [ + (#"{"keywordCase": "title"}"#, "keywordCase"), + (#"{"indentWidth": 0}"#, "indentWidth"), + (#"{"indentWidth": 17}"#, "indentWidth"), + (#"{"indentWidth": "four"}"#, "indentWidth"), + (#"{"lineWidth": -1}"#, "lineWidth"), + (#"{"alignAliases": "yes"}"#, "alignAliases"), + ] + ) + func invalidValueNamesKey(json: String, key: String) { + #expect(throws: SQLFormatterRulesError.invalidValue(key: key)) { + try self.decode(json) + } + } + + @Test("A file that is not a JSON object is refused", arguments: ["", "[]", "not json", "{"]) + func notJSONIsRefused(json: String) { + #expect(throws: SQLFormatterRulesError.notJSON) { + try self.decode(json) + } + } + + @Test("A file over the size cap is refused before decoding") + func oversizedFileIsRefused() { + let padding = String(repeating: " ", count: SQLFormatterRulesFile.maxSize) + #expect(throws: SQLFormatterRulesError.tooLarge) { + try self.decode("{}" + padding) + } + } + + // MARK: - Settings + + @Test("Default settings format exactly as the formatter did before it had settings") + func defaultSettingsKeepOldOutput() { + for keywordCase in [SQLKeywordCase.upper, .matchTypedElseUpper] { + let resolved = SQLFormatterSettings.default.resolved(tabWidth: 4, editorKeywordCase: keywordCase) + #expect(resolved == .default) + } + } + + @Test("Following the editor reads Tab width and Keyword case") + func followsEditorSettings() { + let settings = SQLFormatterSettings(indentFollowsTabWidth: true, keywordCaseFollowsEditor: true) + let resolved = settings.resolved(tabWidth: 8, editorKeywordCase: .matchTypedElseLower) + #expect(resolved.indentWidth == 8) + #expect(resolved.keywordCase == .lower) + } + + @Test("Imported rules follow nothing on this Mac") + func importedRulesIgnoreEditorSettings() { + let settings = SQLFormatterSettings(imported: Self.custom) + #expect(settings.resolved(tabWidth: 8, editorKeywordCase: .upper) == Self.custom) + } + + @Test("A rules file exported on one Mac formats identically on another") + func exportedFileFormatsIdentically() throws { + var rules = Self.custom + rules.indentStyle = .spaces + rules.lineWidth = 0 + var firstEditor = EditorSettings.default + firstEditor.tabWidth = 8 + firstEditor.keywordCase = .upper + let firstFormatter = SQLFormatterSettings(rules: rules, indentFollowsTabWidth: true, keywordCaseFollowsEditor: true) + let first = firstFormatter.options(for: firstEditor) + let file = try SQLFormatterRulesFile.encode(first) + + var secondEditor = EditorSettings.default + secondEditor.tabWidth = 2 + secondEditor.keywordCase = .lower + let second = SQLFormatterSettings(imported: try SQLFormatterRulesFile.decode(file)).options(for: secondEditor) + + let sql = "select u.id as user_id, count(o.id) as n from users u left join orders o on u.id = o.user_id " + + "where u.status = 'active' and o.total > 10" + let service = SQLFormatterService() + let formattedFirst = try service.format(sql, dialect: .mysql, options: first).formattedSQL + let formattedSecond = try service.format(sql, dialect: .mysql, options: second).formattedSQL + #expect(formattedFirst == formattedSecond) + #expect(formattedFirst.contains("\n AND ")) + #expect(formattedFirst.hasPrefix("SELECT ")) + } + + @Test("Formatter settings sync as their own category, apart from the editor blob an older build rewrites") + func formatterSyncsAsItsOwnCategory() { + #expect(AppSettingsCategory.synced.contains(AppSettingsCategory.formatter)) + #expect(AppSettingsCategory.formatter != AppSettingsCategory.editor) + } + + @Test("A Mac upgraded from a build that skipped formatter records fetches the zone once") + func upgradeRefetchesSkippedFormatterRecords() throws { + let pool = EphemeralDefaultsPool() + let metadata = SyncMetadataStorage(userDefaults: try pool.make().defaults) + var older = SyncCoordinator.readableContent + older.remove(SyncCoordinator.formatterContentKind) + _ = metadata.adoptReadableRecordTypes(older) + #expect(metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) + #expect(!metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) + } + + @Test("Formatter settings persist under their own key and reset with the rest") + func storageRoundTripAndReset() throws { + let pool = EphemeralDefaultsPool() + let storage = AppSettingsStorage(userDefaults: try pool.make().defaults) + let custom = SQLFormatterSettings(imported: Self.custom) + storage.saveFormatter(custom) + #expect(storage.loadFormatter() == custom) + storage.resetToDefaults() + #expect(storage.loadFormatter() == .default) + } + + @Test("Stored formatter settings with nothing in them decode to the defaults") + func emptyStoredSettingsDecode() throws { + #expect(try JSONDecoder().decode(SQLFormatterSettings.self, from: Data("{}".utf8)) == .default) + } + + @Test("A stored formatter value this build cannot read costs only that field") + func storedInvalidValueFallsBackAlone() throws { + let json = #"{"commaPosition": "leading", "indentWidth": 99, "keywordCase": "title"}"# + let settings = try JSONDecoder().decode(SQLFormatterSettings.self, from: Data(json.utf8)) + #expect(settings.rules.commaPosition == .leading) + #expect(settings.rules.indentWidth == SQLFormatterOptions.default.indentWidth) + #expect(settings.rules.keywordCase == SQLFormatterOptions.default.keywordCase) + } + + @Test("Formatter settings round-trip with both follow choices") + func settingsRoundTrip() throws { + let settings = SQLFormatterSettings(rules: Self.custom, indentFollowsTabWidth: true, keywordCaseFollowsEditor: false) + let decoded = try JSONDecoder().decode(SQLFormatterSettings.self, from: JSONEncoder().encode(settings)) + #expect(decoded == settings) + } + + // MARK: - Linked folder rules + + private final class Sandbox { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("SQLFormatterRulesTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + @discardableResult + func write(_ relativePath: String, _ contents: String = "") throws -> URL { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(contents.utf8).write(to: url) + return url + } + + func folder(_ relativePath: String, isEnabled: Bool = true) -> LinkedSQLFolder { + LinkedSQLFolder(path: root.appendingPathComponent(relativePath).path, isEnabled: isEnabled) + } + } + + @Test("The rules file at the linked folder's root applies to a file in a subfolder") + func rootRulesApplyToSubfolders() throws { + let sandbox = try Sandbox() + let rules = try sandbox.write("queries/.sql-formatter.json", "{}") + let file = try sandbox.write("queries/reports/daily.sql") + let found = SQLFormatterRulesFile.url(forFileAt: file, in: [sandbox.folder("queries")]) + #expect(found?.resolvingSymlinksInPath() == rules.resolvingSymlinksInPath()) + } + + @Test("The rules file nearest the SQL file wins") + func nearestRulesWin() throws { + let sandbox = try Sandbox() + try sandbox.write("queries/.sql-formatter.json", "{}") + let nearer = try sandbox.write("queries/reports/.sql-formatter.json", "{}") + let file = try sandbox.write("queries/reports/daily.sql") + let found = SQLFormatterRulesFile.url(forFileAt: file, in: [sandbox.folder("queries")]) + #expect(found?.resolvingSymlinksInPath() == nearer.resolvingSymlinksInPath()) + } + + @Test("Nothing above the linked folder is read, and a disabled or unrelated folder gives no rules") + func lookupStaysInsideEnabledFolder() throws { + let sandbox = try Sandbox() + try sandbox.write(".sql-formatter.json", "{}") + let file = try sandbox.write("queries/daily.sql") + let outside = try sandbox.write("elsewhere/other.sql") + #expect(SQLFormatterRulesFile.url(forFileAt: file, in: [sandbox.folder("queries")]) == nil) + try sandbox.write("queries/.sql-formatter.json", "{}") + #expect(SQLFormatterRulesFile.url(forFileAt: file, in: [sandbox.folder("queries", isEnabled: false)]) == nil) + #expect(SQLFormatterRulesFile.url(forFileAt: outside, in: [sandbox.folder("queries")]) == nil) + } + + @Test("A file in a linked folder formats with the folder's rules, and other files with Settings") + func ruleSourcePrefersFolderRules() throws { + let sandbox = try Sandbox() + try sandbox.write("queries/.sql-formatter.json", #"{"commaPosition": "leading"}"#) + let file = try sandbox.write("queries/daily.sql") + let folders = [sandbox.folder("queries")] + var settings = SQLFormatterOptions.default + settings.alignAliases = true + + let folderOptions = try SQLFormatterRuleSource.options(forFileAt: file, settings: settings, folders: folders) + #expect(folderOptions.commaPosition == .leading) + #expect(folderOptions.alignAliases == false) + + let scratch = try SQLFormatterRuleSource.options(forFileAt: nil, settings: settings, folders: folders) + #expect(scratch == settings) + } + + @Test("A rules file that is a symlink to a device is refused without reading it") + func symlinkToDeviceIsRefused() throws { + let sandbox = try Sandbox() + let link = sandbox.root.appendingPathComponent(".sql-formatter.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: URL(fileURLWithPath: "/dev/zero")) + #expect(throws: SQLFormatterRulesError.self) { + try SQLFormatterRulesFile.load(from: link) + } + } + + @Test("A rules file that is a symlink to a regular file is read") + func symlinkToFileIsRead() throws { + let sandbox = try Sandbox() + let target = try sandbox.write("shared/rules.json", #"{"indentWidth": 4}"#) + let link = sandbox.root.appendingPathComponent(".sql-formatter.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + #expect(try SQLFormatterRulesFile.load(from: link).indentWidth == 4) + } + + @Test("An unreadable folder rules file refuses to format instead of using Settings") + func unreadableFolderRulesThrow() throws { + let sandbox = try Sandbox() + try sandbox.write("queries/.sql-formatter.json", #"{"indentWidth": "wide"}"#) + let file = try sandbox.write("queries/daily.sql") + #expect(throws: SQLFormatterFolderRulesError.self) { + try SQLFormatterRuleSource.options( + forFileAt: file, + settings: .default, + folders: [sandbox.folder("queries")] + ) + } + } +} diff --git a/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift index d8983aefb4..165362d51d 100644 --- a/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -15,7 +15,13 @@ struct SQLFormatterServiceTests { let formatter = SQLFormatterService() private func format(_ sql: String, options: SQLFormatterOptions = .default) throws -> String { - try formatter.format(sql, dialect: .mysql).formattedSQL + try formatter.format(sql, dialect: .mysql, options: options).formattedSQL + } + + private func options(_ change: (inout SQLFormatterOptions) -> Void) -> SQLFormatterOptions { + var options = SQLFormatterOptions.default + change(&options) + return options } // MARK: - Simple SELECT @@ -330,7 +336,7 @@ struct SQLFormatterServiceTests { func keywordsNotUppercased() throws { var options = SQLFormatterOptions.default options.keywordCase = .preserve - let result = try formatter.format("select * from users", dialect: .mysql, options: options).formattedSQL + let result = try format("select * from users", options: options) #expect(result.contains("select")) #expect(result.contains("from")) } @@ -652,4 +658,364 @@ struct SQLFormatterServiceTests { let formatted = try format("select 1; /* note") #expect(formatted.hasSuffix("/* note")) } + + // MARK: - Expressions Inside Parentheses + + @Test("Commas inside a function call in the SELECT list stay inline") + func functionArgumentsStayInline() throws { + let result = try format("select concat(first_name, ' ', last_name) as name, id from users") + #expect(result == """ + SELECT concat(first_name, ' ', last_name) AS name, + id + FROM users + """) + } + + @Test("FROM, WHERE and ORDER BY inside parentheses stay inline") + func clauseKeywordsInsideParentheses() throws { + let result = try format( + "select substring(name from 2) as s, count(*) filter (where x > 0), " + + "string_agg(name, ', ' order by name) from t" + ) + #expect(result == """ + SELECT substring(name FROM 2) AS s, + count(*) FILTER (WHERE x > 0), + string_agg(name, ', ' ORDER BY name) + FROM t + """) + } + + @Test("A column list keeps CHARACTER SET, WITH TIME ZONE and ON DELETE inline") + func createTableColumnClauses() throws { + let result = try format( + "create table t (id int, ts timestamp with time zone, name text CHARACTER set utf8mb4, " + + "parent int references p(id) on delete cascade)" + ) + #expect(result == """ + CREATE TABLE t ( + id int, + ts timestamp WITH time zone, + name text CHARACTER SET utf8mb4, + parent int REFERENCES p(id) ON DELETE CASCADE + ) + """) + } + + @Test("A keyword beside a dot is a name and keeps its spelling") + func qualifiedNamesKeepSpelling() throws { + let result = try format( + "select e.end, t.key, dbo.count(x), count(*) from events e", + options: options { $0.functionCase = .upper } + ) + #expect(result == """ + SELECT e.end, + t.key, + dbo.count(x), + COUNT(*) + FROM events e + """) + } + + @Test("A statement keyword after the word it modifies stays in its clause") + func statementKeywordsAsModifiers() throws { + #expect(try format("select * from t where id = 1 for update") == """ + SELECT * + FROM t + WHERE id = 1 for UPDATE + """) + #expect(try format("insert into t (a) values (1) on DUPLICATE key update a = values(a)") == """ + INSERT INTO t (a) + VALUES (1) ON DUPLICATE KEY UPDATE a = VALUES (a) + """) + #expect(try format("grant select, insert on t to u") == "GRANT SELECT, INSERT ON t to u") + } + + @Test("A statement keyword after EXPLAIN starts its own line instead of joining it") + func explainKeepsKeywordsApart() throws { + #expect(try format("explain select * from t") == """ + EXPLAIN + SELECT * + FROM t + """) + #expect(try format("explain update t set a = 1") == """ + EXPLAIN + UPDATE t + SET a = 1 + """) + } + + // MARK: - Indentation Options + + @Test("Indent width sets the step of every nested level") + func indentWidth() throws { + let result = try format( + "select * from (select id from users where a = 1 and b = 2) as t", + options: options { $0.indentWidth = 4 } + ) + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM users + WHERE a = 1 + AND b = 2 + ) AS t + """) + } + + @Test("Tab indentation puts tabs before the alignment spaces") + func tabIndentation() throws { + let result = try format( + "select * from (select id, name from users) as t", + options: options { $0.indentStyle = .tabs } + ) + #expect(result == "SELECT *\nFROM (\n\tSELECT id,\n\t name\n\tFROM users\n) AS t") + } + + @Test("ON follows the indent width") + func joinConditionIndent() throws { + let result = try format( + "select * from users u left join roles r on u.role_id = r.id", + options: options { $0.indentWidth = 4 } + ) + #expect(result == """ + SELECT * + FROM users u + LEFT JOIN roles r + ON u.role_id = r.id + """) + } + + // MARK: - Case Options + + @Test("Keyword case lower writes keywords and synthesized words lower case") + func keywordCaseLower() throws { + let result = try format( + "SELECT id FROM users LEFT JOIN roles ON 1 = 1 ORDER BY id", + options: options { $0.keywordCase = .lower } + ) + #expect(result == """ + select id + from users + left join roles + on 1 = 1 + order by id + """) + } + + @Test("Function case rewrites a call and leaves a column with a function's name alone") + func functionCase() throws { + let upper = try format("select count(*), max(id), count from t", options: options { $0.functionCase = .upper }) + #expect(upper == """ + SELECT COUNT(*), + MAX(id), + count + FROM t + """) + let lower = try format("select COUNT(*) from t", options: options { $0.functionCase = .lower }) + #expect(lower == """ + SELECT count(*) + FROM t + """) + } + + @Test("Function case leaves calls alone where the engine matches function names by exact case") + func functionCaseOnCaseSensitiveEngine() throws { + var tokenFormatter = SQLTokenFormatter( + options: options { $0.functionCase = .upper }, + dialectFunctions: ["toString"], + rewritesFunctionCase: false + ) + let tokens = SQLTokenizer(dialectKeywords: ["toString"]).tokenize("select toString(x), count(*) from t") + #expect(tokenFormatter.format(tokens) == """ + SELECT toString(x), + count(*) + FROM t + """) + } + + // MARK: - Comma Position + + @Test("Leading commas line up the items of a SELECT list") + func leadingCommasSelect() throws { + let result = try format( + "select id, name, email from users", + options: options { $0.commaPosition = .leading } + ) + #expect(result == """ + SELECT id + , name + , email + FROM users + """) + } + + @Test("Leading commas in a SET list and a column list") + func leadingCommasSetAndColumns() throws { + let leading = options { $0.commaPosition = .leading } + #expect(try format("update users set name = 'a', email = 'b' where id = 1", options: leading) == """ + UPDATE users + SET name = 'a' + , email = 'b' + WHERE id = 1 + """) + #expect(try format("create table t (id int, name text)", options: leading) == """ + CREATE TABLE t ( + id int + , name text + ) + """) + } + + @Test("Leading commas leave a function call's arguments inline") + func leadingCommasKeepArgumentsInline() throws { + let result = try format("select concat(a, b), c from t", options: options { $0.commaPosition = .leading }) + #expect(result == """ + SELECT concat(a, b) + , c + FROM t + """) + } + + // MARK: - Line Width + + @Test("A list that fits the line width stays on one line") + func lineWidthKeepsShortListsInline() throws { + let result = try format( + "select id, name from users where a = 1 and b = 2", + options: options { $0.lineWidth = 40 } + ) + #expect(result == """ + SELECT id, name + FROM users + WHERE a = 1 AND b = 2 + """) + } + + @Test("A list longer than the line width breaks one item per line") + func lineWidthBreaksLongLists() throws { + let result = try format( + "select first_column, second_column, third_column from t", + options: options { $0.lineWidth = 40 } + ) + #expect(result == """ + SELECT first_column, + second_column, + third_column + FROM t + """) + } + + @Test("A line exactly at the width fits, one character more does not") + func lineWidthBoundary() throws { + let sql = "select aaaa, bbbb from t" + #expect(try format(sql, options: options { $0.lineWidth = 17 }).hasPrefix("SELECT aaaa, bbbb\n")) + #expect(try format(sql, options: options { $0.lineWidth = 16 }).hasPrefix("SELECT aaaa,\n")) + } + + @Test("A list holding a subquery never stays on one line") + func lineWidthBreaksAroundSubquery() throws { + let result = try format( + "select a, (select max(b) from u) as m from t", + options: options { $0.lineWidth = 200 } + ) + #expect(result.hasPrefix("SELECT a,\n")) + } + + @Test("Line width applies to a nested SELECT on its own") + func lineWidthNested() throws { + let result = try format( + "select * from (select id, name from users) as t", + options: options { $0.lineWidth = 80 } + ) + #expect(result == """ + SELECT * + FROM ( + SELECT id, name + FROM users + ) AS t + """) + } + + // MARK: - Join Condition + + @Test("Join condition on the JOIN line") + func joinConditionSameLine() throws { + let result = try format( + "select * from users u inner join roles r on u.role_id = r.id left join teams t on u.team_id = t.id", + options: options { $0.joinCondition = .sameLine } + ) + #expect(result == """ + SELECT * + FROM users u + INNER JOIN roles r ON u.role_id = r.id + LEFT JOIN teams t ON u.team_id = t.id + """) + } + + // MARK: - Alias Alignment + + @Test("Aliases line up in one column") + func alignAliases() throws { + let result = try format( + "select u.id as user_id, u.name as name, count(o.id) as order_count, u.email from users u", + options: options { $0.alignAliases = true } + ) + #expect(result == """ + SELECT u.id AS user_id, + u.name AS name, + count(o.id) AS order_count, + u.email + FROM users u + """) + } + + @Test("Alias alignment with leading commas") + func alignAliasesLeadingCommas() throws { + let result = try format( + "select id as a, created_at as b from t", + options: options { + $0.alignAliases = true + $0.commaPosition = .leading + } + ) + #expect(result == """ + SELECT id AS a + , created_at AS b + FROM t + """) + } + + @Test("A column spanning lines and an AS inside a call are not aligned") + func alignAliasesSkipsMultilineAndNested() throws { + let result = try format( + "select cast(x as int) as xi, case when a then 1 end as flag, id as identifier from t", + options: options { $0.alignAliases = true } + ) + #expect(result == """ + SELECT cast(x AS int) AS xi, + CASE + WHEN a THEN 1 + END AS flag, + id AS identifier + FROM t + """) + } + + // MARK: - Idempotency Under Options + + @Test( + "Formatting twice gives the same result under every option", + arguments: [ + SQLFormatterOptions(indentStyle: .tabs, commaPosition: .leading, alignAliases: true), + SQLFormatterOptions(indentWidth: 4, keywordCase: .lower, functionCase: .upper, lineWidth: 60), + SQLFormatterOptions(lineWidth: 120, joinCondition: .sameLine, alignAliases: true), + ] + ) + func idempotentUnderOptions(_ options: SQLFormatterOptions) throws { + let sql = "select u.id as user_id, count(o.id) as n from users u left join orders o on u.id = o.user_id " + + "where u.status = 'active' and o.total > 10 group by u.id order by n desc" + let first = try format(sql, options: options) + #expect(try format(first, options: options) == first) + } } diff --git a/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift b/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift index 26aed2c215..ea96a263cc 100644 --- a/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift +++ b/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift @@ -26,6 +26,7 @@ struct SyncCategoryRefetchTests { #expect(!content.contains(SyncRecordType.favoriteFolder.rawValue)) #expect(!content.contains(SyncRecordType.settings.rawValue)) #expect(!content.contains(SyncCoordinator.themeContentKind)) + #expect(!content.contains(SyncCoordinator.formatterContentKind)) #expect(content.contains(SyncRecordType.connection.rawValue)) } diff --git a/apple/changelog.d/3740.added.md b/apple/changelog.d/3740.added.md new file mode 100644 index 0000000000..33fa84231a --- /dev/null +++ b/apple/changelog.d/3740.added.md @@ -0,0 +1 @@ +Format Query follows rules from Settings > Editor > Formatter, or from a .sql-formatter.json in a linked SQL folder. diff --git a/apple/changelog.d/3740.fixed.md b/apple/changelog.d/3740.fixed.md new file mode 100644 index 0000000000..1c7d2191c1 --- /dev/null +++ b/apple/changelog.d/3740.fixed.md @@ -0,0 +1 @@ +Format Query broke a function's arguments, EXPLAIN, FOR UPDATE and CREATE TABLE column clauses across lines.