Skip to content

Commit 96137ab

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(transform): dialect-aware SQL formatting (#479) (#507)
Add dialect-aware formatting to the transform and formatter packages so that AST-to-SQL rendering respects dialect-specific row-limiting syntax: SQL Server emits TOP, Oracle emits FETCH FIRST, and LIMIT-based dialects remain unchanged. Key changes: - Add Dialect field to FormatOptions and formatter.Options - Render parsed TopClause (previously silently dropped) - normalizeSelectForDialect converts LIMIT to TOP/FETCH on a shallow copy - SQL Server with OFFSET uses OFFSET/FETCH NEXT syntax - Add FormatSQLWithDialect(stmt, keywords.SQLDialect) to transform pkg - Add ParseSQLWithDialect(sql, keywords.SQLDialect) to transform pkg - Thread nodeFormatter through fetchSQL for keyword casing Closes #479 Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 627e712 commit 96137ab

6 files changed

Lines changed: 594 additions & 17 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
5+
package formatter
6+
7+
import (
8+
"strings"
9+
"testing"
10+
11+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
12+
)
13+
14+
func TestDialectRenderSelect_TopClause(t *testing.T) {
15+
// Parsed TOP clause should always render, even without a dialect.
16+
limit := 10
17+
stmt := &ast.SelectStatement{
18+
Top: &ast.TopClause{
19+
Count: &ast.LiteralValue{Value: 100, Type: "int"},
20+
},
21+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
22+
From: []ast.TableReference{{Name: "users"}},
23+
Limit: &limit, // Both TOP and LIMIT present
24+
}
25+
26+
got := FormatStatement(stmt, ast.CompactStyle())
27+
if !strings.Contains(got, "TOP 100") {
28+
t.Errorf("expected TOP 100 in output, got: %s", got)
29+
}
30+
// LIMIT should also render since no dialect normalization removes it
31+
if !strings.Contains(got, "LIMIT 10") {
32+
t.Errorf("expected LIMIT 10 in output, got: %s", got)
33+
}
34+
}
35+
36+
func TestDialectRenderSelect_TopPercent(t *testing.T) {
37+
stmt := &ast.SelectStatement{
38+
Top: &ast.TopClause{
39+
Count: &ast.LiteralValue{Value: 10, Type: "int"},
40+
IsPercent: true,
41+
WithTies: true,
42+
},
43+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
44+
From: []ast.TableReference{{Name: "orders"}},
45+
}
46+
47+
got := FormatStatement(stmt, ast.CompactStyle())
48+
if !strings.Contains(got, "TOP 10 PERCENT WITH TIES") {
49+
t.Errorf("expected TOP 10 PERCENT WITH TIES, got: %s", got)
50+
}
51+
}
52+
53+
func TestDialectRenderSelect_LimitToTop(t *testing.T) {
54+
// SQL Server dialect should convert LIMIT to TOP
55+
limit := 50
56+
stmt := &ast.SelectStatement{
57+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
58+
From: []ast.TableReference{{Name: "users"}},
59+
Limit: &limit,
60+
}
61+
62+
opts := ast.CompactStyle()
63+
opts.Dialect = "sqlserver"
64+
got := FormatStatement(stmt, opts)
65+
66+
if !strings.Contains(got, "TOP 50") {
67+
t.Errorf("sqlserver: expected TOP 50, got: %s", got)
68+
}
69+
if strings.Contains(got, "LIMIT") {
70+
t.Errorf("sqlserver: should not contain LIMIT, got: %s", got)
71+
}
72+
}
73+
74+
func TestDialectRenderSelect_LimitToFetch(t *testing.T) {
75+
// Oracle dialect should convert LIMIT to FETCH FIRST
76+
limit := 100
77+
stmt := &ast.SelectStatement{
78+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
79+
From: []ast.TableReference{{Name: "users"}},
80+
Limit: &limit,
81+
}
82+
83+
opts := ast.CompactStyle()
84+
opts.Dialect = "oracle"
85+
got := FormatStatement(stmt, opts)
86+
87+
if !strings.Contains(got, "FETCH FIRST 100 ROWS ONLY") {
88+
t.Errorf("oracle: expected FETCH FIRST 100 ROWS ONLY, got: %s", got)
89+
}
90+
if strings.Contains(got, "LIMIT") {
91+
t.Errorf("oracle: should not contain LIMIT, got: %s", got)
92+
}
93+
}
94+
95+
func TestDialectRenderSelect_LimitOffsetOracle(t *testing.T) {
96+
// Oracle: LIMIT + OFFSET -> OFFSET n ROWS FETCH FIRST m ROWS ONLY
97+
limit := 10
98+
offset := 20
99+
stmt := &ast.SelectStatement{
100+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
101+
From: []ast.TableReference{{Name: "users"}},
102+
Limit: &limit,
103+
Offset: &offset,
104+
}
105+
106+
opts := ast.CompactStyle()
107+
opts.Dialect = "oracle"
108+
got := FormatStatement(stmt, opts)
109+
110+
if !strings.Contains(got, "OFFSET 20 ROWS") {
111+
t.Errorf("oracle: expected OFFSET 20 ROWS, got: %s", got)
112+
}
113+
if !strings.Contains(got, "FETCH FIRST 10 ROWS ONLY") {
114+
t.Errorf("oracle: expected FETCH FIRST 10 ROWS ONLY, got: %s", got)
115+
}
116+
if strings.Contains(got, "LIMIT") {
117+
t.Errorf("oracle: should not contain LIMIT, got: %s", got)
118+
}
119+
}
120+
121+
func TestDialectRenderSelect_LimitOffsetSQLServer(t *testing.T) {
122+
// SQL Server with OFFSET uses OFFSET/FETCH NEXT syntax
123+
limit := 10
124+
offset := 20
125+
stmt := &ast.SelectStatement{
126+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
127+
From: []ast.TableReference{{Name: "users"}},
128+
OrderBy: []ast.OrderByExpression{
129+
{Expression: &ast.Identifier{Name: "id"}, Ascending: true},
130+
},
131+
Limit: &limit,
132+
Offset: &offset,
133+
}
134+
135+
opts := ast.CompactStyle()
136+
opts.Dialect = "sqlserver"
137+
got := FormatStatement(stmt, opts)
138+
139+
if !strings.Contains(got, "OFFSET 20 ROWS") {
140+
t.Errorf("sqlserver: expected OFFSET 20 ROWS, got: %s", got)
141+
}
142+
if !strings.Contains(got, "FETCH NEXT 10 ROWS ONLY") {
143+
t.Errorf("sqlserver: expected FETCH NEXT 10 ROWS ONLY, got: %s", got)
144+
}
145+
if strings.Contains(got, "TOP") {
146+
t.Errorf("sqlserver with offset: should not contain TOP, got: %s", got)
147+
}
148+
if strings.Contains(got, "LIMIT") {
149+
t.Errorf("sqlserver: should not contain LIMIT, got: %s", got)
150+
}
151+
}
152+
153+
func TestDialectRenderSelect_PostgreSQLUnchanged(t *testing.T) {
154+
// PostgreSQL should keep LIMIT/OFFSET as-is
155+
limit := 10
156+
offset := 5
157+
stmt := &ast.SelectStatement{
158+
Columns: []ast.Expression{&ast.Identifier{Name: "id"}, &ast.Identifier{Name: "name"}},
159+
From: []ast.TableReference{{Name: "users"}},
160+
Limit: &limit,
161+
Offset: &offset,
162+
}
163+
164+
opts := ast.CompactStyle()
165+
opts.Dialect = "postgresql"
166+
got := FormatStatement(stmt, opts)
167+
168+
if !strings.Contains(got, "LIMIT 10") {
169+
t.Errorf("postgresql: expected LIMIT 10, got: %s", got)
170+
}
171+
if !strings.Contains(got, "OFFSET 5") {
172+
t.Errorf("postgresql: expected OFFSET 5, got: %s", got)
173+
}
174+
}
175+
176+
func TestDialectRenderSelect_GenericPreservesExistingTop(t *testing.T) {
177+
// When no dialect is set, a parsed TopClause should still render.
178+
stmt := &ast.SelectStatement{
179+
Top: &ast.TopClause{
180+
Count: &ast.LiteralValue{Value: 5, Type: "int"},
181+
},
182+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
183+
From: []ast.TableReference{{Name: "t"}},
184+
}
185+
186+
got := FormatStatement(stmt, ast.CompactStyle())
187+
if !strings.Contains(got, "TOP 5") {
188+
t.Errorf("generic: expected TOP 5 in output, got: %s", got)
189+
}
190+
}
191+
192+
func TestDialectRenderSelect_GenericPreservesExistingFetch(t *testing.T) {
193+
fetchVal := int64(25)
194+
stmt := &ast.SelectStatement{
195+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
196+
From: []ast.TableReference{{Name: "t"}},
197+
Fetch: &ast.FetchClause{
198+
FetchValue: &fetchVal,
199+
FetchType: "FIRST",
200+
},
201+
}
202+
203+
got := FormatStatement(stmt, ast.CompactStyle())
204+
if !strings.Contains(got, "FETCH FIRST 25 ROWS ONLY") {
205+
t.Errorf("generic: expected FETCH FIRST, got: %s", got)
206+
}
207+
}
208+
209+
func TestDialectRenderSelect_KeywordCasing(t *testing.T) {
210+
limit := 10
211+
stmt := &ast.SelectStatement{
212+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
213+
From: []ast.TableReference{{Name: "users"}},
214+
Limit: &limit,
215+
}
216+
217+
opts := ast.FormatOptions{
218+
KeywordCase: ast.KeywordUpper,
219+
Dialect: "sqlserver",
220+
}
221+
got := FormatStatement(stmt, opts)
222+
if !strings.Contains(got, "SELECT TOP 10") {
223+
t.Errorf("expected uppercase SELECT TOP, got: %s", got)
224+
}
225+
226+
opts.KeywordCase = ast.KeywordLower
227+
got = FormatStatement(stmt, opts)
228+
if !strings.Contains(got, "select top 10") {
229+
t.Errorf("expected lowercase select top, got: %s", got)
230+
}
231+
}
232+
233+
func TestDialectRenderSelect_OriginalASTNotMutated(t *testing.T) {
234+
// Verify that dialect normalization does not mutate the original AST.
235+
limit := 50
236+
stmt := &ast.SelectStatement{
237+
Columns: []ast.Expression{&ast.Identifier{Name: "*"}},
238+
From: []ast.TableReference{{Name: "users"}},
239+
Limit: &limit,
240+
}
241+
242+
opts := ast.CompactStyle()
243+
opts.Dialect = "sqlserver"
244+
_ = FormatStatement(stmt, opts)
245+
246+
// Original should still have Limit set and Top nil
247+
if stmt.Limit == nil {
248+
t.Error("original AST Limit was mutated to nil")
249+
}
250+
if stmt.Top != nil {
251+
t.Error("original AST Top was mutated (should remain nil)")
252+
}
253+
}

pkg/formatter/formatter.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ import (
101101

102102
// Options configures SQL formatting behaviour.
103103
type Options struct {
104-
IndentSize int // spaces per indent level (default 2)
105-
Uppercase bool // uppercase SQL keywords
106-
Compact bool // single-line output
104+
IndentSize int // spaces per indent level (default 2)
105+
Uppercase bool // uppercase SQL keywords
106+
Compact bool // single-line output
107+
Dialect string // target SQL dialect (empty = generic)
107108
}
108109

109110
// Formatter formats SQL strings.
@@ -165,6 +166,7 @@ func (f *Formatter) Format(sql string) (string, error) {
165166
if f.opts.Uppercase {
166167
style.KeywordCase = ast.KeywordUpper
167168
}
169+
style.Dialect = f.opts.Dialect
168170

169171
return FormatAST(parsedAST, style), nil
170172
}

0 commit comments

Comments
 (0)