Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 26 additions & 12 deletions TablePro/Core/Services/Formatting/SQLFormatterService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ",":
Expand Down Expand Up @@ -256,7 +255,6 @@ internal struct SQLTokenFormatter {
}
output += "\n"
selectColumnIndentStack.append(selectColumnIndent)
indent += 1
afterNewline = true
isFirstClause = true
suppressNextSpace = false
Expand All @@ -268,7 +266,6 @@ internal struct SQLTokenFormatter {
output += " ("
output += "\n"
selectColumnIndentStack.append(selectColumnIndent)
indent += 1
afterNewline = true
isFirstClause = true
suppressNextSpace = false
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
192 changes: 192 additions & 0 deletions TableProTests/Core/Services/SQLFormatterServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading