Skip to content
Open
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
18 changes: 18 additions & 0 deletions pkg/formatter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 50 additions & 2 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
184 changes: 184 additions & 0 deletions pkg/sql/parser/pivot.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions pkg/sql/parser/select_subquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading