From e028a0318c0d6cda78cb6c58929d0df0c9cbce7d Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 31 Mar 2026 22:25:34 +0530 Subject: [PATCH 1/2] 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) --- pkg/formatter/render.go | 18 +++ pkg/sql/ast/ast.go | 52 ++++++++- pkg/sql/parser/pivot.go | 184 ++++++++++++++++++++++++++++++ pkg/sql/parser/select_subquery.go | 78 +++++++++++++ pkg/sql/parser/tsql_test.go | 141 ++++++++++++++++++++++- pkg/sql/tokenizer/tokenizer.go | 3 + 6 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 pkg/sql/parser/pivot.go diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 77baa0f5..c1ea1501 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -1186,6 +1186,24 @@ func tableRefSQL(t *ast.TableReference) string { } else { sb.WriteString(t.Name) } + if t.Pivot != nil { + sb.WriteString(" PIVOT (") + sb.WriteString(exprSQL(t.Pivot.AggregateFunction)) + sb.WriteString(" FOR ") + sb.WriteString(t.Pivot.PivotColumn) + sb.WriteString(" IN (") + sb.WriteString(strings.Join(t.Pivot.InValues, ", ")) + sb.WriteString("))") + } + if t.Unpivot != nil { + sb.WriteString(" UNPIVOT (") + sb.WriteString(t.Unpivot.ValueColumn) + sb.WriteString(" FOR ") + sb.WriteString(t.Unpivot.NameColumn) + sb.WriteString(" IN (") + sb.WriteString(strings.Join(t.Unpivot.InColumns, ", ")) + sb.WriteString("))") + } if t.Alias != "" { sb.WriteString(" ") sb.WriteString(t.Alias) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index a1b43060..c050427b 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -231,6 +231,12 @@ type TableReference struct { // ForSystemTime is the MariaDB temporal table clause (10.3.4+). // Example: SELECT * FROM t FOR SYSTEM_TIME AS OF '2024-01-01' ForSystemTime *ForSystemTimeClause // MariaDB temporal query + // Pivot is the SQL Server / Oracle PIVOT clause for row-to-column transformation. + // Example: SELECT * FROM t PIVOT (SUM(sales) FOR region IN ([North], [South])) AS pvt + Pivot *PivotClause + // Unpivot is the SQL Server / Oracle UNPIVOT clause for column-to-row transformation. + // Example: SELECT * FROM t UNPIVOT (sales FOR region IN (north_sales, south_sales)) AS unpvt + Unpivot *UnpivotClause } func (t *TableReference) statementNode() {} @@ -244,10 +250,17 @@ func (t TableReference) TokenLiteral() string { return "subquery" } func (t TableReference) Children() []Node { + var nodes []Node if t.Subquery != nil { - return []Node{t.Subquery} + nodes = append(nodes, t.Subquery) } - return nil + if t.Pivot != nil { + nodes = append(nodes, t.Pivot) + } + if t.Unpivot != nil { + nodes = append(nodes, t.Unpivot) + } + return nodes } // OrderByExpression represents an ORDER BY clause element with direction and NULL ordering @@ -1969,6 +1982,41 @@ func (c ForSystemTimeClause) Children() []Node { return nodes } +// PivotClause represents the SQL Server / Oracle PIVOT operator for row-to-column +// transformation in a FROM clause. +// +// PIVOT (SUM(sales) FOR region IN ([North], [South], [East], [West])) AS pvt +type PivotClause struct { + AggregateFunction Expression // The aggregate function, e.g. SUM(sales) + PivotColumn string // The column used for pivoting, e.g. region + InValues []string // The values to pivot on, e.g. [North], [South] + Pos models.Location // Source position of the PIVOT keyword +} + +func (p *PivotClause) expressionNode() {} +func (p PivotClause) TokenLiteral() string { return "PIVOT" } +func (p PivotClause) Children() []Node { + if p.AggregateFunction != nil { + return []Node{p.AggregateFunction} + } + return nil +} + +// UnpivotClause represents the SQL Server / Oracle UNPIVOT operator for column-to-row +// transformation in a FROM clause. +// +// UNPIVOT (sales FOR region IN (north_sales, south_sales, east_sales)) AS unpvt +type UnpivotClause struct { + ValueColumn string // The target value column, e.g. sales + NameColumn string // The target name column, e.g. region + InColumns []string // The source columns to unpivot, e.g. north_sales, south_sales + Pos models.Location // Source position of the UNPIVOT keyword +} + +func (u *UnpivotClause) expressionNode() {} +func (u UnpivotClause) TokenLiteral() string { return "UNPIVOT" } +func (u UnpivotClause) Children() []Node { return nil } + // PeriodDefinition represents a PERIOD FOR clause in CREATE TABLE. // // PERIOD FOR app_time (start_col, end_col) diff --git a/pkg/sql/parser/pivot.go b/pkg/sql/parser/pivot.go new file mode 100644 index 00000000..646d6da8 --- /dev/null +++ b/pkg/sql/parser/pivot.go @@ -0,0 +1,184 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - pivot.go +// SQL Server / Oracle PIVOT and UNPIVOT clause parsing. + +package parser + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// isPivotKeyword returns true if the current token is the PIVOT keyword. +func (p *Parser) isPivotKeyword() bool { + return p.isType(models.TokenTypeKeyword) && + strings.EqualFold(p.currentToken.Token.Value, "PIVOT") +} + +// isUnpivotKeyword returns true if the current token is the UNPIVOT keyword. +func (p *Parser) isUnpivotKeyword() bool { + return p.isType(models.TokenTypeKeyword) && + strings.EqualFold(p.currentToken.Token.Value, "UNPIVOT") +} + +// parsePivotClause parses PIVOT (aggregate FOR column IN (values)). +// The current token must be the PIVOT keyword. +func (p *Parser) parsePivotClause() (*ast.PivotClause, error) { + pos := p.currentLocation() + p.advance() // consume PIVOT + + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("( after PIVOT") + } + p.advance() // consume ( + + // Parse aggregate function expression (e.g. SUM(sales)) + aggFunc, err := p.parseExpression() + if err != nil { + return nil, err + } + + // Expect FOR keyword + if !p.isType(models.TokenTypeFor) { + return nil, p.expectedError("FOR in PIVOT clause") + } + p.advance() // consume FOR + + // Parse pivot column name + if !p.isIdentifier() { + return nil, p.expectedError("column name after FOR in PIVOT") + } + pivotCol := p.currentToken.Token.Value + p.advance() + + // Expect IN keyword + if !p.isType(models.TokenTypeIn) { + return nil, p.expectedError("IN in PIVOT clause") + } + p.advance() // consume IN + + // Expect opening parenthesis for value list + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("( after IN in PIVOT") + } + p.advance() // consume ( + + // Parse IN values — identifiers (possibly bracket-quoted in SQL Server) + var inValues []string + for !p.isType(models.TokenTypeRParen) && !p.isType(models.TokenTypeEOF) { + if !p.isIdentifier() && !p.isType(models.TokenTypeNumber) && !p.isStringLiteral() { + return nil, p.expectedError("value in PIVOT IN list") + } + inValues = append(inValues, p.currentToken.Token.Value) + p.advance() + if p.isType(models.TokenTypeComma) { + p.advance() + } + } + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") to close PIVOT IN list") + } + p.advance() // close IN list ) + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") to close PIVOT clause") + } + p.advance() // close PIVOT ) + + return &ast.PivotClause{ + AggregateFunction: aggFunc, + PivotColumn: pivotCol, + InValues: inValues, + Pos: pos, + }, nil +} + +// parseUnpivotClause parses UNPIVOT (value_col FOR name_col IN (columns)). +// The current token must be the UNPIVOT keyword. +func (p *Parser) parseUnpivotClause() (*ast.UnpivotClause, error) { + pos := p.currentLocation() + p.advance() // consume UNPIVOT + + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("( after UNPIVOT") + } + p.advance() // consume ( + + // Parse value column name + if !p.isIdentifier() { + return nil, p.expectedError("value column name in UNPIVOT") + } + valueCol := p.currentToken.Token.Value + p.advance() + + // Expect FOR keyword + if !p.isType(models.TokenTypeFor) { + return nil, p.expectedError("FOR in UNPIVOT clause") + } + p.advance() // consume FOR + + // Parse name column + if !p.isIdentifier() { + return nil, p.expectedError("name column after FOR in UNPIVOT") + } + nameCol := p.currentToken.Token.Value + p.advance() + + // Expect IN keyword + if !p.isType(models.TokenTypeIn) { + return nil, p.expectedError("IN in UNPIVOT clause") + } + p.advance() // consume IN + + // Expect opening parenthesis for column list + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("( after IN in UNPIVOT") + } + p.advance() // consume ( + + // Parse IN columns + var cols []string + for !p.isType(models.TokenTypeRParen) && !p.isType(models.TokenTypeEOF) { + if !p.isIdentifier() { + return nil, p.expectedError("column name in UNPIVOT IN list") + } + cols = append(cols, p.currentToken.Token.Value) + p.advance() + if p.isType(models.TokenTypeComma) { + p.advance() + } + } + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") to close UNPIVOT IN list") + } + p.advance() // close IN list ) + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") to close UNPIVOT clause") + } + p.advance() // close UNPIVOT ) + + return &ast.UnpivotClause{ + ValueColumn: valueCol, + NameColumn: nameCol, + InColumns: cols, + Pos: pos, + }, nil +} diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index f1eff0ae..af20082d 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -125,6 +125,46 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } } + // SQL Server / Oracle PIVOT clause + if p.isPivotKeyword() { + pivot, err := p.parsePivotClause() + if err != nil { + return tableRef, err + } + tableRef.Pivot = pivot + // PIVOT result often has its own alias: PIVOT (...) AS pvt + if p.isType(models.TokenTypeAs) { + p.advance() // consume AS + if p.isIdentifier() { + tableRef.Alias = p.currentToken.Token.Value + p.advance() + } + } else if p.isIdentifier() { + tableRef.Alias = p.currentToken.Token.Value + p.advance() + } + } + + // SQL Server / Oracle UNPIVOT clause + if p.isUnpivotKeyword() { + unpivot, err := p.parseUnpivotClause() + if err != nil { + return tableRef, err + } + tableRef.Unpivot = unpivot + // UNPIVOT result alias: UNPIVOT (...) AS unpvt + if p.isType(models.TokenTypeAs) { + p.advance() // consume AS + if p.isIdentifier() { + tableRef.Alias = p.currentToken.Token.Value + p.advance() + } + } else if p.isIdentifier() { + tableRef.Alias = p.currentToken.Token.Value + p.advance() + } + } + return tableRef, nil } @@ -217,6 +257,44 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error } } + // SQL Server / Oracle PIVOT clause + if p.isPivotKeyword() { + pivot, err := p.parsePivotClause() + if err != nil { + return ref, err + } + ref.Pivot = pivot + if p.isType(models.TokenTypeAs) { + p.advance() + if p.isIdentifier() { + ref.Alias = p.currentToken.Token.Value + p.advance() + } + } else if p.isIdentifier() { + ref.Alias = p.currentToken.Token.Value + p.advance() + } + } + + // SQL Server / Oracle UNPIVOT clause + if p.isUnpivotKeyword() { + unpivot, err := p.parseUnpivotClause() + if err != nil { + return ref, err + } + ref.Unpivot = unpivot + if p.isType(models.TokenTypeAs) { + p.advance() + if p.isIdentifier() { + ref.Alias = p.currentToken.Token.Value + p.advance() + } + } else if p.isIdentifier() { + ref.Alias = p.currentToken.Token.Value + p.advance() + } + } + return ref, nil } diff --git a/pkg/sql/parser/tsql_test.go b/pkg/sql/parser/tsql_test.go index 7a105e60..f815aed9 100644 --- a/pkg/sql/parser/tsql_test.go +++ b/pkg/sql/parser/tsql_test.go @@ -355,6 +355,8 @@ func TestTSQL_TestdataFiles(t *testing.T) { "08_window_row_number.sql": true, "09_window_rank.sql": true, "10_window_lag_lead.sql": true, + "11_pivot.sql": true, + "12_unpivot.sql": true, "13_cross_apply.sql": true, "14_outer_apply.sql": true, "15_try_convert.sql": true, @@ -391,9 +393,146 @@ func TestTSQL_TestdataFiles(t *testing.T) { t.Errorf("expected %s to parse, got: %v", name, parseErr) } } else { - // These are known to not yet be supported (PIVOT, UNPIVOT, OPTION) + // These are known to not yet be supported (OPTION) t.Logf("%s: %v (not yet supported)", name, parseErr) } }) } } + +func TestTSQL_PivotBasic(t *testing.T) { + sql := `SELECT * FROM ( + SELECT product, region, sales + FROM sales_data +) AS SourceTable +PIVOT ( + SUM(sales) FOR region IN ([North], [South], [East], [West]) +) AS PivotTable` + + result, err := ParseWithDialect(sql, keywords.DialectSQLServer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(result.Statements)) + } + stmt, ok := result.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement, got %T", result.Statements[0]) + } + if len(stmt.From) == 0 { + t.Fatal("expected at least one FROM reference") + } + ref := stmt.From[0] + if ref.Pivot == nil { + t.Fatal("expected Pivot clause on table reference") + } + if ref.Pivot.PivotColumn != "region" { + t.Errorf("expected pivot column 'region', got %q", ref.Pivot.PivotColumn) + } + if len(ref.Pivot.InValues) != 4 { + t.Errorf("expected 4 IN values, got %d", len(ref.Pivot.InValues)) + } + expected := []string{"North", "South", "East", "West"} + for i, v := range expected { + if i < len(ref.Pivot.InValues) && ref.Pivot.InValues[i] != v { + t.Errorf("IN value [%d]: expected %q, got %q", i, v, ref.Pivot.InValues[i]) + } + } + if ref.Pivot.AggregateFunction == nil { + t.Error("expected aggregate function in PIVOT") + } + if ref.Alias != "PivotTable" { + t.Errorf("expected alias 'PivotTable', got %q", ref.Alias) + } +} + +func TestTSQL_UnpivotBasic(t *testing.T) { + sql := `SELECT product, region, sales FROM ( + SELECT product, north_sales, south_sales, east_sales, west_sales + FROM regional_sales +) AS SourceTable +UNPIVOT ( + sales FOR region IN (north_sales, south_sales, east_sales, west_sales) +) AS UnpivotTable` + + result, err := ParseWithDialect(sql, keywords.DialectSQLServer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(result.Statements)) + } + stmt, ok := result.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement, got %T", result.Statements[0]) + } + if len(stmt.From) == 0 { + t.Fatal("expected at least one FROM reference") + } + ref := stmt.From[0] + if ref.Unpivot == nil { + t.Fatal("expected Unpivot clause on table reference") + } + if ref.Unpivot.ValueColumn != "sales" { + t.Errorf("expected value column 'sales', got %q", ref.Unpivot.ValueColumn) + } + if ref.Unpivot.NameColumn != "region" { + t.Errorf("expected name column 'region', got %q", ref.Unpivot.NameColumn) + } + if len(ref.Unpivot.InColumns) != 4 { + t.Errorf("expected 4 IN columns, got %d", len(ref.Unpivot.InColumns)) + } + expected := []string{"north_sales", "south_sales", "east_sales", "west_sales"} + for i, v := range expected { + if i < len(ref.Unpivot.InColumns) && ref.Unpivot.InColumns[i] != v { + t.Errorf("IN column [%d]: expected %q, got %q", i, v, ref.Unpivot.InColumns[i]) + } + } + if ref.Alias != "UnpivotTable" { + t.Errorf("expected alias 'UnpivotTable', got %q", ref.Alias) + } +} + +func TestTSQL_PivotWithoutAlias(t *testing.T) { + sql := `SELECT * FROM sales_data PIVOT (SUM(amount) FOR quarter IN (Q1, Q2, Q3, Q4))` + + result, err := ParseWithDialect(sql, keywords.DialectSQLServer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt, ok := result.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement, got %T", result.Statements[0]) + } + ref := stmt.From[0] + if ref.Pivot == nil { + t.Fatal("expected Pivot clause") + } + if ref.Name != "sales_data" { + t.Errorf("expected table name 'sales_data', got %q", ref.Name) + } + if ref.Pivot.PivotColumn != "quarter" { + t.Errorf("expected pivot column 'quarter', got %q", ref.Pivot.PivotColumn) + } + if len(ref.Pivot.InValues) != 4 { + t.Errorf("expected 4 IN values, got %d", len(ref.Pivot.InValues)) + } +} + +func TestTSQL_PivotWithASAlias(t *testing.T) { + sql := `SELECT * FROM t PIVOT (COUNT(id) FOR status IN (active, inactive)) AS pvt` + + result, err := ParseWithDialect(sql, keywords.DialectSQLServer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt := result.Statements[0].(*ast.SelectStatement) + ref := stmt.From[0] + if ref.Pivot == nil { + t.Fatal("expected Pivot clause") + } + if ref.Alias != "pvt" { + t.Errorf("expected alias 'pvt', got %q", ref.Alias) + } +} diff --git a/pkg/sql/tokenizer/tokenizer.go b/pkg/sql/tokenizer/tokenizer.go index 39885055..8fbfda60 100644 --- a/pkg/sql/tokenizer/tokenizer.go +++ b/pkg/sql/tokenizer/tokenizer.go @@ -228,6 +228,9 @@ var keywordTokenTypes = map[string]models.TokenType{ // boundary detection. SETTINGS/FORMAT are common words and must NOT be here. "PREWHERE": models.TokenTypeKeyword, "FINAL": models.TokenTypeKeyword, + // SQL Server / Oracle PIVOT/UNPIVOT clause keywords + "PIVOT": models.TokenTypeKeyword, + "UNPIVOT": models.TokenTypeKeyword, } // Tokenizer provides high-performance SQL tokenization with zero-copy operations. From b8c058f0c779928462b975ce7ca95e34360c156e Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Wed, 1 Apr 2026 03:16:28 +0530 Subject: [PATCH 2/2] security: add CVE-2026-32285 to .trivyignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .trivyignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.trivyignore b/.trivyignore index f8e6bc1d..87b50db3 100644 --- a/.trivyignore +++ b/.trivyignore @@ -2,6 +2,13 @@ # Format: [expiry-date] [comment] # See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/#trivyignore +# CVE-2026-32285 — github.com/buger/jsonparser v1.1.1 +# Severity: HIGH/MEDIUM | No fixed version available (latest is v1.1.1, released 2021-01-08) +# Transitive dependency: mark3labs/mcp-go → invopop/jsonschema → wk8/go-ordered-map → buger/jsonparser +# Not called directly by any GoSQLX code. Risk is scoped to MCP JSON schema generation. +# Re-evaluate when buger/jsonparser releases a patched version or when mcp-go updates its dependency. +CVE-2026-32285 + # GHSA-6g7g-w4f8-9c9x — buger/jsonparser v1.1.1 # Severity: MEDIUM | No fixed version available (latest is v1.1.1, released 2021-01-08) # Transitive dependency: mark3labs/mcp-go → invopop/jsonschema → wk8/go-ordered-map → buger/jsonparser