Skip to content

Commit 0c9ffeb

Browse files
committed
fix(editor): keep nested indentation for set operations in subqueries and CTEs (#1698)
1 parent caeea76 commit 0c9ffeb

3 files changed

Lines changed: 219 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- 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)
2525
- DuckDB VARIANT columns now show their value as text instead of an empty cell.
2626
- A new database group now appears in the connection list right away instead of only after restarting the app. (#1704)
27+
- 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)
2728

2829
## [0.51.1] - 2026-06-16
2930

TablePro/Core/Services/Formatting/SQLFormatterService.swift

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ internal struct SQLTokenFormatter {
105105

106106
// Mutable state
107107
private var output = ""
108-
private var indent = 0
109108
private var clauseStack: [ClauseContext] = []
110109
private var afterNewline = true
111110
private var isFirstClause = true
@@ -210,8 +209,8 @@ internal struct SQLTokenFormatter {
210209
output += String(repeating: "\n", count: newlineCount)
211210
afterNewline = true
212211
isFirstClause = true
213-
indent = 0
214212
clauseStack.removeAll()
213+
selectColumnIndentStack.removeAll()
215214
inSelectColumns = false
216215

217216
case ",":
@@ -256,7 +255,6 @@ internal struct SQLTokenFormatter {
256255
}
257256
output += "\n"
258257
selectColumnIndentStack.append(selectColumnIndent)
259-
indent += 1
260258
afterNewline = true
261259
isFirstClause = true
262260
suppressNextSpace = false
@@ -268,7 +266,6 @@ internal struct SQLTokenFormatter {
268266
output += " ("
269267
output += "\n"
270268
selectColumnIndentStack.append(selectColumnIndent)
271-
indent += 1
272269
afterNewline = true
273270
isFirstClause = true
274271
suppressNextSpace = false
@@ -279,7 +276,6 @@ internal struct SQLTokenFormatter {
279276
if clauseStack.contains(.createTable) && !clauseStack.contains(.createTableBody) {
280277
output += " ("
281278
output += "\n"
282-
indent += 1
283279
afterNewline = true
284280
suppressNextSpace = false
285281
clauseStack.append(.createTableBody)
@@ -318,9 +314,8 @@ internal struct SQLTokenFormatter {
318314
return
319315
}
320316
// Block paren (subquery or CREATE TABLE body): pop back to the block opener
321-
if let idx = clauseStack.lastIndex(where: { $0 == .subquery || $0 == .createTableBody }) {
317+
if let idx = clauseStack.lastIndex(where: Self.isBlockContext) {
322318
clauseStack.removeSubrange(idx...)
323-
indent = max(0, indent - 1)
324319
output += "\n" + indentStr() + ")"
325320
afterNewline = false
326321
isFirstClause = false
@@ -375,7 +370,7 @@ internal struct SQLTokenFormatter {
375370
appendToken(kw)
376371
case "HAVING", "LIMIT", "OFFSET":
377372
handleClauseKeyword(kw: kw, context: nil)
378-
case "UNION", "INTERSECT", "EXCEPT":
373+
case "UNION", "INTERSECT", "EXCEPT", "MINUS":
379374
handleSetOperation(upper: upper, kw: kw, next: next)
380375
case "ALL":
381376
// standalone ALL (not consumed by UNION ALL)
@@ -528,14 +523,23 @@ internal struct SQLTokenFormatter {
528523

529524
private mutating func handleSetOperation(upper: String, kw: String, next: SQLToken?) {
530525
inSelectColumns = false
531-
indent = 0
532-
clauseStack.removeAll()
526+
if let blockIdx = clauseStack.lastIndex(where: Self.isBlockContext) {
527+
clauseStack.removeSubrange((blockIdx + 1)...)
528+
} else {
529+
clauseStack.removeAll()
530+
}
531+
532+
var line = kw
533533
if upper == "UNION" && next?.upperValue == "ALL" {
534534
let allKw = options.uppercaseKeywords ? "ALL" : "all"
535-
output += "\n\n" + kw + " " + allKw + "\n\n"
535+
line += " " + allKw
536536
skipCount = 1 // skip ALL
537+
}
538+
539+
if clauseStack.contains(where: Self.isBlockContext) {
540+
output += "\n" + indentStr() + line + "\n"
537541
} else {
538-
output += "\n\n" + kw + "\n\n"
542+
output += "\n\n" + line + "\n\n"
539543
}
540544
afterNewline = true
541545
isFirstClause = true
@@ -600,6 +604,16 @@ internal struct SQLTokenFormatter {
600604

601605
private var currentContext: ClauseContext? { clauseStack.last }
602606

607+
private static func isBlockContext(_ ctx: ClauseContext) -> Bool {
608+
ctx == .subquery || ctx == .createTableBody
609+
}
610+
611+
private var indent: Int {
612+
clauseStack.reduce(into: 0) { depth, ctx in
613+
if Self.isBlockContext(ctx) { depth += 1 }
614+
}
615+
}
616+
603617
private mutating func replaceTop(with ctx: ClauseContext) {
604618
if !clauseStack.isEmpty { clauseStack.removeLast() }
605619
clauseStack.append(ctx)

TableProTests/Core/Services/SQLFormatterServiceTests.swift

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,196 @@ struct SQLFormatterServiceTests {
438438
FROM employees
439439
""")
440440
}
441+
442+
@Test("Window frame ROWS BETWEEN stays inline")
443+
func windowFrameInline() throws {
444+
let result = try format("select sum(x) over (order by id rows between unbounded preceding and current row) as running from t")
445+
let lines = result.split(separator: "\n", omittingEmptySubsequences: false)
446+
#expect(lines.count == 2)
447+
#expect(lines[0].contains("ROWS BETWEEN"))
448+
#expect(lines[1] == "FROM t")
449+
}
450+
451+
// MARK: - Set Operations Inside Subqueries and CTEs
452+
453+
@Test("UNION inside a derived table keeps nesting and closes on its own line")
454+
func unionInDerivedTable() throws {
455+
let sql = "select country, avg(score) as score "
456+
+ "from (select yr, country, score from scores "
457+
+ "union select 2024, country, ladder from scores_current) as final "
458+
+ "group by country"
459+
let result = try format(sql)
460+
#expect(result == """
461+
SELECT country,
462+
avg(score) AS score
463+
FROM (
464+
SELECT yr,
465+
country,
466+
score
467+
FROM scores
468+
UNION
469+
SELECT 2024,
470+
country,
471+
ladder
472+
FROM scores_current
473+
) AS final
474+
GROUP BY country
475+
""")
476+
}
477+
478+
@Test("UNION ALL inside a derived table")
479+
func unionAllInDerivedTable() throws {
480+
let result = try format("select * from (select id from a union all select id from b) as t")
481+
#expect(result == """
482+
SELECT *
483+
FROM (
484+
SELECT id
485+
FROM a
486+
UNION ALL
487+
SELECT id
488+
FROM b
489+
) AS t
490+
""")
491+
}
492+
493+
@Test("INTERSECT inside a derived table")
494+
func intersectInDerivedTable() throws {
495+
let result = try format("select * from (select id from a intersect select id from b) as t")
496+
#expect(result == """
497+
SELECT *
498+
FROM (
499+
SELECT id
500+
FROM a
501+
INTERSECT
502+
SELECT id
503+
FROM b
504+
) AS t
505+
""")
506+
}
507+
508+
@Test("EXCEPT inside a derived table")
509+
func exceptInDerivedTable() throws {
510+
let result = try format("select * from (select id from a except select id from b) as t")
511+
#expect(result == """
512+
SELECT *
513+
FROM (
514+
SELECT id
515+
FROM a
516+
EXCEPT
517+
SELECT id
518+
FROM b
519+
) AS t
520+
""")
521+
}
522+
523+
@Test("MINUS inside a derived table (Oracle)")
524+
func minusInDerivedTable() throws {
525+
let result = try formatter.format(
526+
"select * from (select id from a minus select id from b) as t",
527+
dialect: .oracle
528+
).formattedSQL
529+
#expect(result == """
530+
SELECT *
531+
FROM (
532+
SELECT id
533+
FROM a
534+
MINUS
535+
SELECT id
536+
FROM b
537+
) AS t
538+
""")
539+
}
540+
541+
@Test("UNION inside a CTE body")
542+
func unionInsideCTE() throws {
543+
let result = try format("with combined as (select id from a union select id from b) select * from combined")
544+
#expect(result == """
545+
WITH combined AS (
546+
SELECT id
547+
FROM a
548+
UNION
549+
SELECT id
550+
FROM b
551+
)
552+
SELECT *
553+
FROM combined
554+
""")
555+
}
556+
557+
@Test("UNION inside a nested subquery keeps both levels")
558+
func unionInNestedSubquery() throws {
559+
let result = try format("select * from (select id from (select id from t union select id from u) as iq) as oq")
560+
#expect(result == """
561+
SELECT *
562+
FROM (
563+
SELECT id
564+
FROM (
565+
SELECT id
566+
FROM t
567+
UNION
568+
SELECT id
569+
FROM u
570+
) AS iq
571+
) AS oq
572+
""")
573+
}
574+
575+
@Test("Chained top-level UNIONs keep blank-line separation")
576+
func chainedTopLevelUnions() throws {
577+
let result = try format("select 1 union select 2 union select 3")
578+
#expect(result == """
579+
SELECT 1
580+
581+
UNION
582+
583+
SELECT 2
584+
585+
UNION
586+
587+
SELECT 3
588+
""")
589+
}
590+
591+
@Test("Top-level INTERSECT keeps blank-line separation")
592+
func topLevelIntersect() throws {
593+
let result = try format("select id from a intersect select id from b")
594+
#expect(result == """
595+
SELECT id
596+
FROM a
597+
598+
INTERSECT
599+
600+
SELECT id
601+
FROM b
602+
""")
603+
}
604+
605+
@Test("INSERT ... SELECT")
606+
func insertSelect() throws {
607+
let result = try format("insert into t (a, b) select a, b from s")
608+
#expect(result == """
609+
INSERT INTO t (a, b)
610+
SELECT a,
611+
b
612+
FROM s
613+
""")
614+
}
615+
616+
// MARK: - Idempotency of Nested Set Operations
617+
618+
@Test("UNION in derived table is idempotent")
619+
func unionInDerivedTableIdempotent() throws {
620+
let sql = "select * from (select id from a union select id from b) as t"
621+
let first = try format(sql)
622+
let second = try format(first)
623+
#expect(first == second)
624+
}
625+
626+
@Test("Nested subquery with UNION is idempotent")
627+
func nestedUnionIdempotent() throws {
628+
let sql = "select * from (select id from (select id from t union select id from u) as iq) as oq where id > 0"
629+
let first = try format(sql)
630+
let second = try format(first)
631+
#expect(first == second)
632+
}
441633
}

0 commit comments

Comments
 (0)