From 1a8200b1a468de8996e6d74a2752b379d2ed4d2b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 11:25:56 +0700 Subject: [PATCH 1/2] feat(editor): configurable SQL formatter rules --- .../Services/Formatting/QueryFormatter.swift | 29 +- .../Formatting/SQLFormatterRulesFile.swift | 137 +++++ .../Formatting/SQLFormatterService.swift | 543 ++++++++++++------ .../Formatting/SQLFormatterTypes.swift | 132 +++-- .../Services/Query/SQLDialectProvider.swift | 3 + .../Core/Storage/AppSettingsManager.swift | 14 + .../Core/Storage/AppSettingsStorage.swift | 12 + .../Extensions/SyncCoordinator+Settings.swift | 9 +- .../Models/Settings/AppSettingsCategory.swift | 4 +- .../Settings/SQLFormatterSettings.swift | 93 +++ .../Views/Editor/QueryEditorView.swift | 2 + .../Views/Editor/SQLEditorCoordinator.swift | 17 +- .../TablePro/Views/Editor/SQLEditorView.swift | 2 + .../Main/Child/MainEditorContentView.swift | 1 + .../Views/Settings/EditorSettingsView.swift | 5 +- .../Settings/Sections/FormatterSection.swift | 276 +++++++++ .../Settings/SettingsWindowController.swift | 1 + .../Formatting/SQLFormatterRulesTests.swift | 305 ++++++++++ .../Services/SQLFormatterServiceTests.swift | 370 +++++++++++- .../Core/Sync/SyncCategoryRefetchTests.swift | 1 + 20 files changed, 1715 insertions(+), 241 deletions(-) create mode 100644 apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift create mode 100644 apple/TablePro/Models/Settings/SQLFormatterSettings.swift create mode 100644 apple/TablePro/Views/Settings/Sections/FormatterSection.swift create mode 100644 apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift 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/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 9c0f01e43d..58806632a6 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)) } From c95da31dd65ff24c20eb5165df0864f7ce622194 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 12:54:13 +0700 Subject: [PATCH 2/2] chore(l10n): formatter strings and changelog fragments --- .../TablePro/Resources/Localizable.xcstrings | 100 ++++++++++++++++++ apple/changelog.d/3740.added.md | 1 + apple/changelog.d/3740.fixed.md | 1 + 3 files changed, 102 insertions(+) create mode 100644 apple/changelog.d/3740.added.md create mode 100644 apple/changelog.d/3740.fixed.md diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 189f6c3bb6..ddad32559b 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -8388,6 +8388,9 @@ } } } + }, + "%d characters" : { + }, "%d columns" : { "localizations" : { @@ -9903,6 +9906,9 @@ } } } + }, + "%d spaces" : { + }, "%d statements applied" : { "extractionState" : "manual", @@ -14573,6 +14579,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", @@ -16237,6 +16246,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" : { @@ -21535,6 +21547,9 @@ } } } + }, + "Align column aliases" : { + }, "All" : { "localizations" : { @@ -27008,6 +27023,9 @@ } } } + }, + "As written" : { + }, "Ascending" : { "localizations" : { @@ -27626,6 +27644,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" : { @@ -43863,6 +43887,9 @@ } } } + }, + "Commas:" : { + }, "Comment" : { "extractionState" : "manual", @@ -53360,9 +53387,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" : { @@ -70516,6 +70549,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", @@ -83510,6 +83546,9 @@ } } } + }, + "Formatter" : { + }, "Formatter error: %@" : { "localizations" : { @@ -84035,6 +84074,9 @@ } } } + }, + "Function case:" : { + }, "Function: %@" : { "localizations" : { @@ -91928,6 +91970,12 @@ } } } + }, + "Indent width:" : { + + }, + "Indent with:" : { + }, "Index" : { @@ -95645,6 +95693,9 @@ } } } + }, + "Join condition:" : { + }, "Joins this column with the one to its right." : { "localizations" : { @@ -100691,6 +100742,9 @@ } } } + }, + "Line width:" : { + }, "Link" : { "extractionState" : "manual", @@ -123291,6 +123345,9 @@ } } } + }, + "On its own line" : { + }, "On Quit" : { "extractionState" : "stale", @@ -123368,6 +123425,9 @@ } } } + }, + "On the JOIN line" : { + }, "On the Server" : { "localizations" : { @@ -123560,6 +123620,9 @@ } } } + }, + "One item per line" : { + }, "One JSON object" : { "extractionState" : "manual", @@ -147129,6 +147192,9 @@ } } } + }, + "Restore Defaults" : { + }, "Restore Dump Cancelled" : { "comment" : "A title for a result sheet that indicates a restore operation was cancelled.", @@ -151342,6 +151408,9 @@ } } } + }, + "Rules file:" : { + }, "Rules for this query result are not saved." : { "localizations" : { @@ -152723,6 +152792,9 @@ } } } + }, + "Same as editor (%@)" : { + }, "same element" : { "localizations" : { @@ -167211,6 +167283,9 @@ } } } + }, + "Spaces" : { + }, "Spacing" : { "extractionState" : "stale", @@ -175024,6 +175099,9 @@ } } } + }, + "Tab width (%d)" : { + }, "Tab width:" : { "localizations" : { @@ -183796,6 +183874,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", @@ -184060,6 +184144,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, @@ -186756,6 +186850,9 @@ } } } + }, + "The rules file is not a regular file." : { + }, "The run button sits in the gutter, which needs line numbers." : { @@ -190272,6 +190369,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/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.