Skip to content

Commit daea668

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(parser): add SQL Server PIVOT/UNPIVOT clause parsing (#477)
* feat(parser): add SQL Server PIVOT/UNPIVOT clause parsing (#456) Add support for SQL Server and Oracle PIVOT/UNPIVOT operators in FROM clauses. PIVOT transforms rows to columns via an aggregate function, while UNPIVOT performs the reverse column-to-row transformation. - Add PivotClause and UnpivotClause AST node types - Add Pivot/Unpivot fields to TableReference struct - Implement parsePivotClause/parseUnpivotClause in new pivot.go - Wire parsing into parseFromTableReference and parseJoinedTableRef - Add PIVOT/UNPIVOT to tokenizer keyword map for correct token typing - Update formatter to render PIVOT/UNPIVOT clauses - Enable testdata/mssql/11_pivot.sql and 12_unpivot.sql - Add 4 dedicated tests covering subquery+alias, plain table, AS alias Closes #456 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * security: add CVE-2026-32285 to .trivyignore CVE-2026-32285 affects github.com/buger/jsonparser v1.1.1, which is a transitive dependency via mark3labs/mcp-go → invopop/jsonschema → wk8/go-ordered-map → buger/jsonparser. No fixed version is available upstream. The package is not called directly by any GoSQLX code and risk is scoped to MCP JSON schema generation. Added to .trivyignore until a patched version is released. Fixes Trivy Repository Scan CI failures in PR #475 and #477. * fix(parser): scope PIVOT/UNPIVOT to SQL Server/Oracle dialects PIVOT and UNPIVOT were registered in the global tokenizer keyword map, which made them reserved words across every dialect. Queries like `SELECT pivot FROM users` then failed in PostgreSQL/MySQL/SQLite/ ClickHouse where these identifiers are perfectly legal. Changes: - Remove PIVOT/UNPIVOT from tokenizer keywordTokenTypes (they are now tokenized as identifiers in all dialects). - Gate isPivotKeyword/isUnpivotKeyword on the parser dialect (TSQL/ Oracle only) and accept identifier-typed tokens by value match. - Skip alias consumption in parseFromTableReference / parseJoinedTableRef when the upcoming identifier is a contextual PIVOT/UNPIVOT keyword, so the pivot-clause parser can claim it. - Fix unsafe single-value type assertion in TestTSQL_PivotWithASAlias to comply with the project's mandatory two-value form. - Add TestPivotIdentifierInNonTSQLDialects regression covering pivot/ unpivot as identifiers in PostgreSQL, MySQL, and SQLite. All parser/tokenizer/formatter tests pass with -race. * fix(parser): polish PIVOT/UNPIVOT round-trip and validation - Formatter emits AS before PIVOT/UNPIVOT aliases for clean round-trip. - Tokenizer records Quote='[' on SQL Server bracket-quoted identifiers; pivot parser uses renderQuotedIdent to preserve [North] etc. in PivotClause.InValues and UnpivotClause.InColumns. - Reject empty IN lists for both PIVOT and UNPIVOT. - Extract parsePivotAlias helper, collapsing four duplicated alias blocks in select_subquery.go. - Add TestPivotNegativeCases (missing parens, missing FOR/IN, empty IN) and TestPivotBracketedInValuesPreserved. Full test suite passes with -race. * fix(parser): escape embedded delimiters and fix empty-IN error order - renderQuotedIdent now doubles embedded `]`, `"`, and `` ` `` per dialect convention so identifiers like [foo]bar] round-trip unambiguously. - Empty PIVOT/UNPIVOT IN list check now runs before the closing-`)` check so the user-facing error is "at least one value/column..." instead of the misleading ") to close ... IN list". - Clarify renderQuotedIdent comment to reference Token.Quote and Word.QuoteStyle as the actual sources. * refactor(formatter): thread nodeFormatter through tableRefSQL and joinSQL Previously these package-level renderers hardcoded keyword literals (PIVOT, UNPIVOT, FOR, IN, LATERAL, JOIN, ON) which bypassed the caller's case policy (f.kw). Thread *nodeFormatter into both functions and route every keyword through f.kw so uppercase/lowercase options apply uniformly across FROM, JOIN, MERGE, DELETE USING, and UPDATE FROM paths. Addresses claude-review feedback on PR #477. All formatter, parser, and tokenizer tests pass with -race. --------- 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 d07122b commit daea668

File tree

6 files changed

+625
-21
lines changed

6 files changed

+625
-21
lines changed

pkg/formatter/render.go

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -227,15 +227,15 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string {
227227
sb.WriteString(" ")
228228
froms := make([]string, len(s.From))
229229
for i := range s.From {
230-
froms[i] = tableRefSQL(&s.From[i])
230+
froms[i] = tableRefSQL(&s.From[i], f)
231231
}
232232
sb.WriteString(strings.Join(froms, ", "))
233233
}
234234

235235
for _, j := range s.Joins {
236236
j := j
237237
sb.WriteString(f.clauseSep())
238-
sb.WriteString(joinSQL(&j))
238+
sb.WriteString(joinSQL(&j, f))
239239
}
240240

241241
if s.Sample != nil {
@@ -400,7 +400,7 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string {
400400
sb.WriteString(" ")
401401
froms := make([]string, len(u.From))
402402
for i := range u.From {
403-
froms[i] = tableRefSQL(&u.From[i])
403+
froms[i] = tableRefSQL(&u.From[i], f)
404404
}
405405
sb.WriteString(strings.Join(froms, ", "))
406406
}
@@ -452,7 +452,7 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string {
452452
sb.WriteString(" ")
453453
usings := make([]string, len(d.Using))
454454
for i := range d.Using {
455-
usings[i] = tableRefSQL(&d.Using[i])
455+
usings[i] = tableRefSQL(&d.Using[i], f)
456456
}
457457
sb.WriteString(strings.Join(usings, ", "))
458458
}
@@ -883,7 +883,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string {
883883

884884
sb.WriteString(f.kw("MERGE INTO"))
885885
sb.WriteString(" ")
886-
sb.WriteString(tableRefSQL(&m.TargetTable))
886+
sb.WriteString(tableRefSQL(&m.TargetTable, f))
887887
if m.TargetAlias != "" {
888888
sb.WriteString(" ")
889889
sb.WriteString(m.TargetAlias)
@@ -892,7 +892,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string {
892892
sb.WriteString(f.clauseSep())
893893
sb.WriteString(f.kw("USING"))
894894
sb.WriteString(" ")
895-
sb.WriteString(tableRefSQL(&m.SourceTable))
895+
sb.WriteString(tableRefSQL(&m.SourceTable, f))
896896
if m.SourceAlias != "" {
897897
sb.WriteString(" ")
898898
sb.WriteString(m.SourceAlias)
@@ -1173,11 +1173,13 @@ func orderBySQL(orders []ast.OrderByExpression) string {
11731173
return strings.Join(parts, ", ")
11741174
}
11751175

1176-
// tableRefSQL renders a TableReference.
1177-
func tableRefSQL(t *ast.TableReference) string {
1176+
// tableRefSQL renders a TableReference. The formatter is threaded through
1177+
// so PIVOT/UNPIVOT/FOR/IN/AS/LATERAL keywords honor the caller's case policy.
1178+
func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string {
11781179
var sb strings.Builder
11791180
if t.Lateral {
1180-
sb.WriteString("LATERAL ")
1181+
sb.WriteString(f.kw("LATERAL"))
1182+
sb.WriteString(" ")
11811183
}
11821184
if t.Subquery != nil {
11831185
sb.WriteString("(")
@@ -1186,8 +1188,46 @@ func tableRefSQL(t *ast.TableReference) string {
11861188
} else {
11871189
sb.WriteString(t.Name)
11881190
}
1189-
if t.Alias != "" {
1191+
if t.Pivot != nil {
1192+
sb.WriteString(" ")
1193+
sb.WriteString(f.kw("PIVOT"))
1194+
sb.WriteString(" (")
1195+
sb.WriteString(exprSQL(t.Pivot.AggregateFunction))
1196+
sb.WriteString(" ")
1197+
sb.WriteString(f.kw("FOR"))
1198+
sb.WriteString(" ")
1199+
sb.WriteString(t.Pivot.PivotColumn)
1200+
sb.WriteString(" ")
1201+
sb.WriteString(f.kw("IN"))
1202+
sb.WriteString(" (")
1203+
sb.WriteString(strings.Join(t.Pivot.InValues, ", "))
1204+
sb.WriteString("))")
1205+
}
1206+
if t.Unpivot != nil {
1207+
sb.WriteString(" ")
1208+
sb.WriteString(f.kw("UNPIVOT"))
1209+
sb.WriteString(" (")
1210+
sb.WriteString(t.Unpivot.ValueColumn)
1211+
sb.WriteString(" ")
1212+
sb.WriteString(f.kw("FOR"))
1213+
sb.WriteString(" ")
1214+
sb.WriteString(t.Unpivot.NameColumn)
11901215
sb.WriteString(" ")
1216+
sb.WriteString(f.kw("IN"))
1217+
sb.WriteString(" (")
1218+
sb.WriteString(strings.Join(t.Unpivot.InColumns, ", "))
1219+
sb.WriteString("))")
1220+
}
1221+
if t.Alias != "" {
1222+
// PIVOT/UNPIVOT aliases conventionally use AS to avoid ambiguity
1223+
// with the closing paren of the clause.
1224+
if t.Pivot != nil || t.Unpivot != nil {
1225+
sb.WriteString(" ")
1226+
sb.WriteString(f.kw("AS"))
1227+
sb.WriteString(" ")
1228+
} else {
1229+
sb.WriteString(" ")
1230+
}
11911231
sb.WriteString(t.Alias)
11921232
}
11931233
return sb.String()
@@ -1217,13 +1257,17 @@ func sampleSQL(s *ast.SampleClause, f *nodeFormatter) string {
12171257
}
12181258

12191259
// joinSQL renders a JOIN clause.
1220-
func joinSQL(j *ast.JoinClause) string {
1260+
func joinSQL(j *ast.JoinClause, f *nodeFormatter) string {
12211261
var sb strings.Builder
1222-
sb.WriteString(j.Type)
1223-
sb.WriteString(" JOIN ")
1224-
sb.WriteString(tableRefSQL(&j.Right))
1262+
sb.WriteString(f.kw(j.Type))
1263+
sb.WriteString(" ")
1264+
sb.WriteString(f.kw("JOIN"))
1265+
sb.WriteString(" ")
1266+
sb.WriteString(tableRefSQL(&j.Right, f))
12251267
if j.Condition != nil {
1226-
sb.WriteString(" ON ")
1268+
sb.WriteString(" ")
1269+
sb.WriteString(f.kw("ON"))
1270+
sb.WriteString(" ")
12271271
sb.WriteString(exprSQL(j.Condition))
12281272
}
12291273
return sb.String()

pkg/sql/ast/ast.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ type TableReference struct {
231231
// ForSystemTime is the MariaDB temporal table clause (10.3.4+).
232232
// Example: SELECT * FROM t FOR SYSTEM_TIME AS OF '2024-01-01'
233233
ForSystemTime *ForSystemTimeClause // MariaDB temporal query
234+
// Pivot is the SQL Server / Oracle PIVOT clause for row-to-column transformation.
235+
// Example: SELECT * FROM t PIVOT (SUM(sales) FOR region IN ([North], [South])) AS pvt
236+
Pivot *PivotClause
237+
// Unpivot is the SQL Server / Oracle UNPIVOT clause for column-to-row transformation.
238+
// Example: SELECT * FROM t UNPIVOT (sales FOR region IN (north_sales, south_sales)) AS unpvt
239+
Unpivot *UnpivotClause
234240
}
235241

236242
func (t *TableReference) statementNode() {}
@@ -244,10 +250,17 @@ func (t TableReference) TokenLiteral() string {
244250
return "subquery"
245251
}
246252
func (t TableReference) Children() []Node {
253+
var nodes []Node
247254
if t.Subquery != nil {
248-
return []Node{t.Subquery}
255+
nodes = append(nodes, t.Subquery)
249256
}
250-
return nil
257+
if t.Pivot != nil {
258+
nodes = append(nodes, t.Pivot)
259+
}
260+
if t.Unpivot != nil {
261+
nodes = append(nodes, t.Unpivot)
262+
}
263+
return nodes
251264
}
252265

253266
// OrderByExpression represents an ORDER BY clause element with direction and NULL ordering
@@ -1969,6 +1982,41 @@ func (c ForSystemTimeClause) Children() []Node {
19691982
return nodes
19701983
}
19711984

1985+
// PivotClause represents the SQL Server / Oracle PIVOT operator for row-to-column
1986+
// transformation in a FROM clause.
1987+
//
1988+
// PIVOT (SUM(sales) FOR region IN ([North], [South], [East], [West])) AS pvt
1989+
type PivotClause struct {
1990+
AggregateFunction Expression // The aggregate function, e.g. SUM(sales)
1991+
PivotColumn string // The column used for pivoting, e.g. region
1992+
InValues []string // The values to pivot on, e.g. [North], [South]
1993+
Pos models.Location // Source position of the PIVOT keyword
1994+
}
1995+
1996+
func (p *PivotClause) expressionNode() {}
1997+
func (p PivotClause) TokenLiteral() string { return "PIVOT" }
1998+
func (p PivotClause) Children() []Node {
1999+
if p.AggregateFunction != nil {
2000+
return []Node{p.AggregateFunction}
2001+
}
2002+
return nil
2003+
}
2004+
2005+
// UnpivotClause represents the SQL Server / Oracle UNPIVOT operator for column-to-row
2006+
// transformation in a FROM clause.
2007+
//
2008+
// UNPIVOT (sales FOR region IN (north_sales, south_sales, east_sales)) AS unpvt
2009+
type UnpivotClause struct {
2010+
ValueColumn string // The target value column, e.g. sales
2011+
NameColumn string // The target name column, e.g. region
2012+
InColumns []string // The source columns to unpivot, e.g. north_sales, south_sales
2013+
Pos models.Location // Source position of the UNPIVOT keyword
2014+
}
2015+
2016+
func (u *UnpivotClause) expressionNode() {}
2017+
func (u UnpivotClause) TokenLiteral() string { return "UNPIVOT" }
2018+
func (u UnpivotClause) Children() []Node { return nil }
2019+
19722020
// PeriodDefinition represents a PERIOD FOR clause in CREATE TABLE.
19732021
//
19742022
// PERIOD FOR app_time (start_col, end_col)

0 commit comments

Comments
 (0)