From 0c9ffeb3d4a959cf3c8ffbde47c8be6f253ce759 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 22:37:24 +0700 Subject: [PATCH] fix(editor): keep nested indentation for set operations in subqueries and CTEs (#1698) --- CHANGELOG.md | 1 + .../Formatting/SQLFormatterService.swift | 38 ++-- .../Services/SQLFormatterServiceTests.swift | 192 ++++++++++++++++++ 3 files changed, 219 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc26afaae..3e6d04ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701) - DuckDB VARIANT columns now show their value as text instead of an empty cell. - A new database group now appears in the connection list right away instead of only after restarting the app. (#1704) +- The SQL formatter keeps nested indentation for UNION, UNION ALL, INTERSECT, and EXCEPT inside a derived table or CTE, and puts the closing parenthesis of a subquery on its own line instead of collapsing it onto the last SELECT. (#1698) ## [0.51.1] - 2026-06-16 diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index eaba2cf93..0fd59c845 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -105,7 +105,6 @@ internal struct SQLTokenFormatter { // Mutable state private var output = "" - private var indent = 0 private var clauseStack: [ClauseContext] = [] private var afterNewline = true private var isFirstClause = true @@ -210,8 +209,8 @@ internal struct SQLTokenFormatter { output += String(repeating: "\n", count: newlineCount) afterNewline = true isFirstClause = true - indent = 0 clauseStack.removeAll() + selectColumnIndentStack.removeAll() inSelectColumns = false case ",": @@ -256,7 +255,6 @@ internal struct SQLTokenFormatter { } output += "\n" selectColumnIndentStack.append(selectColumnIndent) - indent += 1 afterNewline = true isFirstClause = true suppressNextSpace = false @@ -268,7 +266,6 @@ internal struct SQLTokenFormatter { output += " (" output += "\n" selectColumnIndentStack.append(selectColumnIndent) - indent += 1 afterNewline = true isFirstClause = true suppressNextSpace = false @@ -279,7 +276,6 @@ internal struct SQLTokenFormatter { if clauseStack.contains(.createTable) && !clauseStack.contains(.createTableBody) { output += " (" output += "\n" - indent += 1 afterNewline = true suppressNextSpace = false clauseStack.append(.createTableBody) @@ -318,9 +314,8 @@ internal struct SQLTokenFormatter { return } // Block paren (subquery or CREATE TABLE body): pop back to the block opener - if let idx = clauseStack.lastIndex(where: { $0 == .subquery || $0 == .createTableBody }) { + if let idx = clauseStack.lastIndex(where: Self.isBlockContext) { clauseStack.removeSubrange(idx...) - indent = max(0, indent - 1) output += "\n" + indentStr() + ")" afterNewline = false isFirstClause = false @@ -375,7 +370,7 @@ internal struct SQLTokenFormatter { appendToken(kw) case "HAVING", "LIMIT", "OFFSET": handleClauseKeyword(kw: kw, context: nil) - case "UNION", "INTERSECT", "EXCEPT": + case "UNION", "INTERSECT", "EXCEPT", "MINUS": handleSetOperation(upper: upper, kw: kw, next: next) case "ALL": // standalone ALL (not consumed by UNION ALL) @@ -528,14 +523,23 @@ internal struct SQLTokenFormatter { private mutating func handleSetOperation(upper: String, kw: String, next: SQLToken?) { inSelectColumns = false - indent = 0 - clauseStack.removeAll() + if let blockIdx = clauseStack.lastIndex(where: Self.isBlockContext) { + clauseStack.removeSubrange((blockIdx + 1)...) + } else { + clauseStack.removeAll() + } + + var line = kw if upper == "UNION" && next?.upperValue == "ALL" { let allKw = options.uppercaseKeywords ? "ALL" : "all" - output += "\n\n" + kw + " " + allKw + "\n\n" + line += " " + allKw skipCount = 1 // skip ALL + } + + if clauseStack.contains(where: Self.isBlockContext) { + output += "\n" + indentStr() + line + "\n" } else { - output += "\n\n" + kw + "\n\n" + output += "\n\n" + line + "\n\n" } afterNewline = true isFirstClause = true @@ -600,6 +604,16 @@ internal struct SQLTokenFormatter { private var currentContext: ClauseContext? { clauseStack.last } + private static func isBlockContext(_ ctx: ClauseContext) -> Bool { + ctx == .subquery || ctx == .createTableBody + } + + private var indent: Int { + clauseStack.reduce(into: 0) { depth, ctx in + if Self.isBlockContext(ctx) { depth += 1 } + } + } + private mutating func replaceTop(with ctx: ClauseContext) { if !clauseStack.isEmpty { clauseStack.removeLast() } clauseStack.append(ctx) diff --git a/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/TableProTests/Core/Services/SQLFormatterServiceTests.swift index de8cf237e..a846f6f32 100644 --- a/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -438,4 +438,196 @@ struct SQLFormatterServiceTests { FROM employees """) } + + @Test("Window frame ROWS BETWEEN stays inline") + func windowFrameInline() throws { + let result = try format("select sum(x) over (order by id rows between unbounded preceding and current row) as running from t") + let lines = result.split(separator: "\n", omittingEmptySubsequences: false) + #expect(lines.count == 2) + #expect(lines[0].contains("ROWS BETWEEN")) + #expect(lines[1] == "FROM t") + } + + // MARK: - Set Operations Inside Subqueries and CTEs + + @Test("UNION inside a derived table keeps nesting and closes on its own line") + func unionInDerivedTable() throws { + let sql = "select country, avg(score) as score " + + "from (select yr, country, score from scores " + + "union select 2024, country, ladder from scores_current) as final " + + "group by country" + let result = try format(sql) + #expect(result == """ + SELECT country, + avg(score) AS score + FROM ( + SELECT yr, + country, + score + FROM scores + UNION + SELECT 2024, + country, + ladder + FROM scores_current + ) AS final + GROUP BY country + """) + } + + @Test("UNION ALL inside a derived table") + func unionAllInDerivedTable() throws { + let result = try format("select * from (select id from a union all select id from b) as t") + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM a + UNION ALL + SELECT id + FROM b + ) AS t + """) + } + + @Test("INTERSECT inside a derived table") + func intersectInDerivedTable() throws { + let result = try format("select * from (select id from a intersect select id from b) as t") + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM a + INTERSECT + SELECT id + FROM b + ) AS t + """) + } + + @Test("EXCEPT inside a derived table") + func exceptInDerivedTable() throws { + let result = try format("select * from (select id from a except select id from b) as t") + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM a + EXCEPT + SELECT id + FROM b + ) AS t + """) + } + + @Test("MINUS inside a derived table (Oracle)") + func minusInDerivedTable() throws { + let result = try formatter.format( + "select * from (select id from a minus select id from b) as t", + dialect: .oracle + ).formattedSQL + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM a + MINUS + SELECT id + FROM b + ) AS t + """) + } + + @Test("UNION inside a CTE body") + func unionInsideCTE() throws { + let result = try format("with combined as (select id from a union select id from b) select * from combined") + #expect(result == """ + WITH combined AS ( + SELECT id + FROM a + UNION + SELECT id + FROM b + ) + SELECT * + FROM combined + """) + } + + @Test("UNION inside a nested subquery keeps both levels") + func unionInNestedSubquery() throws { + let result = try format("select * from (select id from (select id from t union select id from u) as iq) as oq") + #expect(result == """ + SELECT * + FROM ( + SELECT id + FROM ( + SELECT id + FROM t + UNION + SELECT id + FROM u + ) AS iq + ) AS oq + """) + } + + @Test("Chained top-level UNIONs keep blank-line separation") + func chainedTopLevelUnions() throws { + let result = try format("select 1 union select 2 union select 3") + #expect(result == """ + SELECT 1 + + UNION + + SELECT 2 + + UNION + + SELECT 3 + """) + } + + @Test("Top-level INTERSECT keeps blank-line separation") + func topLevelIntersect() throws { + let result = try format("select id from a intersect select id from b") + #expect(result == """ + SELECT id + FROM a + + INTERSECT + + SELECT id + FROM b + """) + } + + @Test("INSERT ... SELECT") + func insertSelect() throws { + let result = try format("insert into t (a, b) select a, b from s") + #expect(result == """ + INSERT INTO t (a, b) + SELECT a, + b + FROM s + """) + } + + // MARK: - Idempotency of Nested Set Operations + + @Test("UNION in derived table is idempotent") + func unionInDerivedTableIdempotent() throws { + let sql = "select * from (select id from a union select id from b) as t" + let first = try format(sql) + let second = try format(first) + #expect(first == second) + } + + @Test("Nested subquery with UNION is idempotent") + func nestedUnionIdempotent() throws { + let sql = "select * from (select id from (select id from t union select id from u) as iq) as oq where id > 0" + let first = try format(sql) + let second = try format(first) + #expect(first == second) + } }