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
34 changes: 22 additions & 12 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,14 +690,15 @@ func (i Identifier) Children() []Node { return nil }
// - OrderBy: ORDER BY clause for order-sensitive aggregates (STRING_AGG, ARRAY_AGG, etc.)
// - WithinGroup: ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, PERCENTILE_DISC, MODE, etc.)
type FunctionCall struct {
Name string
Arguments []Expression // Renamed from Args for consistency
Over *WindowSpec // For window functions
Distinct bool
Filter Expression // WHERE clause for aggregate functions
OrderBy []OrderByExpression // ORDER BY clause for aggregate functions (STRING_AGG, ARRAY_AGG, etc.)
WithinGroup []OrderByExpression // ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, etc.)
Pos models.Location // Source position of the function name (1-based line and column)
Name string
Arguments []Expression // Renamed from Args for consistency
Over *WindowSpec // For window functions
Distinct bool
Filter Expression // WHERE clause for aggregate functions
OrderBy []OrderByExpression // ORDER BY clause for aggregate functions (STRING_AGG, ARRAY_AGG, etc.)
WithinGroup []OrderByExpression // ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, etc.)
NullTreatment string // "IGNORE NULLS" or "RESPECT NULLS" on window functions (Snowflake, Oracle, BigQuery, SQL:2016)
Pos models.Location // Source position of the function name (1-based line and column)
}

func (f *FunctionCall) expressionNode() {}
Expand Down Expand Up @@ -1033,15 +1034,24 @@ func (u *UnaryExpression) TokenLiteral() string {

func (u UnaryExpression) Children() []Node { return []Node{u.Expr} }

// CastExpression represents CAST(expr AS type)
// CastExpression represents CAST(expr AS type) or TRY_CAST(expr AS type).
// Try is set when the expression originated from TRY_CAST (Snowflake / SQL
// Server / BigQuery), which returns NULL on conversion failure instead of
// raising an error.
type CastExpression struct {
Expr Expression
Type string
Try bool
}

func (c *CastExpression) expressionNode() {}
func (c CastExpression) TokenLiteral() string { return "CAST" }
func (c CastExpression) Children() []Node { return []Node{c.Expr} }
func (c *CastExpression) expressionNode() {}
func (c CastExpression) TokenLiteral() string {
if c.Try {
return "TRY_CAST"
}
return "CAST"
}
func (c CastExpression) Children() []Node { return []Node{c.Expr} }

// AliasedExpression represents an expression with an alias (expr AS alias)
type AliasedExpression struct {
Expand Down
16 changes: 15 additions & 1 deletion pkg/sql/parser/expressions_complex.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,20 @@ func (p *Parser) parseCaseExpression() (*ast.CaseExpression, error) {
// CAST(price AS DECIMAL(10,2))
// CAST(name AS VARCHAR(100))
func (p *Parser) parseCastExpression() (*ast.CastExpression, error) {
// Consume CAST keyword
return p.parseCastLike(false)
}

// parseTryCastExpression parses a TRY_CAST expression with the same shape as
// CAST. Snowflake, SQL Server, and BigQuery return NULL on conversion failure
// instead of raising an error.
func (p *Parser) parseTryCastExpression() (*ast.CastExpression, error) {
return p.parseCastLike(true)
}

// parseCastLike implements the body shared between CAST and TRY_CAST. The
// caller is responsible for ensuring the current token is the leading keyword.
func (p *Parser) parseCastLike(try bool) (*ast.CastExpression, error) {
// Consume CAST / TRY_CAST keyword
p.advance()

// Expect opening parenthesis
Expand Down Expand Up @@ -215,6 +228,7 @@ func (p *Parser) parseCastExpression() (*ast.CastExpression, error) {
return &ast.CastExpression{
Expr: expr,
Type: dataType,
Try: try,
}, nil
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/sql/parser/expressions_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) {
return p.parseCastExpression()
}

// TRY_CAST(expr AS type) — Snowflake, SQL Server, BigQuery. Tokenized as
// an identifier (the snowflake keyword table is not wired into the
// tokenizer); detect by name when followed by '('.
if (p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeKeyword)) &&
strings.EqualFold(p.currentToken.Token.Value, "TRY_CAST") &&
p.peekToken().Token.Type == models.TokenTypeLParen {
return p.parseTryCastExpression()
}

if p.isType(models.TokenTypeInterval) {
// Handle INTERVAL 'value' expressions
return p.parseIntervalExpression()
Expand Down
98 changes: 98 additions & 0 deletions pkg/sql/parser/snowflake_trycast_nulls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");

package parser_test

import (
"testing"

"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
)

// TestTryCast verifies that TRY_CAST(expr AS type) parses in Snowflake
// (and is identical in shape to CAST). Regression for #483.
func TestTryCast(t *testing.T) {
queries := []string{
`SELECT TRY_CAST(value AS INT) FROM events`,
`SELECT TRY_CAST(price AS DECIMAL(10, 2)) FROM products`,
`SELECT TRY_CAST(name AS VARCHAR(100)) FROM users`,
`SELECT TRY_CAST(json_col AS VARIANT) FROM events`,
}
for _, q := range queries {
t.Run(q, func(t *testing.T) {
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
t.Fatalf("Snowflake TRY_CAST parse failed: %v", err)
}
})
}
}

// TestWindowNullTreatment verifies IGNORE NULLS / RESPECT NULLS on window
// functions parses for Snowflake. Regression for #483.
func TestWindowNullTreatment(t *testing.T) {
queries := map[string]string{
"lag_ignore_nulls": `SELECT LAG(price) IGNORE NULLS OVER (ORDER BY ts) FROM ticks`,
"lead_respect_nulls": `SELECT LEAD(price) RESPECT NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
"first_value_ignore": `SELECT FIRST_VALUE(price) IGNORE NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
"last_value_respect": `SELECT LAST_VALUE(price) RESPECT NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
}
for name, q := range queries {
q := q
t.Run(name, func(t *testing.T) {
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
t.Fatalf("parse failed: %v", err)
}
})
}
}

// TestTryCastASTShape verifies that a TRY_CAST expression has Try=true and
// TokenLiteral() returns "TRY_CAST", while a plain CAST returns "CAST".
func TestTryCastASTShape(t *testing.T) {
tcs := map[string]struct {
query string
wantTry bool
wantLit string
}{
"try_cast": {`SELECT TRY_CAST(value AS INT) FROM events`, true, "TRY_CAST"},
"cast": {`SELECT CAST(value AS INT) FROM events`, false, "CAST"},
}
for name, tc := range tcs {
tc := tc
t.Run(name, func(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(tc.query, keywords.DialectSnowflake)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
var found bool
var visit func(n ast.Node)
visit = func(n ast.Node) {
if n == nil || found {
return
}
if c, ok := n.(*ast.CastExpression); ok {
if c.Try != tc.wantTry {
t.Fatalf("Try: want %v, got %v", tc.wantTry, c.Try)
}
if c.TokenLiteral() != tc.wantLit {
t.Fatalf("TokenLiteral: want %q, got %q", tc.wantLit, c.TokenLiteral())
}
found = true
return
}
for _, ch := range n.Children() {
visit(ch)
}
}
for _, stmt := range tree.Statements {
visit(stmt)
}
if !found {
t.Fatal("CastExpression not found in AST")
}
})
}
}
16 changes: 16 additions & 0 deletions pkg/sql/parser/window.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ func (p *Parser) parseFunctionCall(funcName string) (*ast.FunctionCall, error) {
OrderBy: orderByExprs,
}

// Check for IGNORE NULLS / RESPECT NULLS (SQL:2016 null treatment).
// Used by LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE in Snowflake,
// Oracle, BigQuery, etc. IGNORE arrives as TokenTypeKeyword; RESPECT is
// not in any keyword list and arrives as TokenTypeIdentifier. NULLS has
// its own token type.
if p.currentToken.Token.Type == models.TokenTypeKeyword ||
p.currentToken.Token.Type == models.TokenTypeIdentifier {
upper := strings.ToUpper(p.currentToken.Token.Value)
if (upper == "IGNORE" || upper == "RESPECT") &&
p.peekToken().Token.Type == models.TokenTypeNulls {
funcCall.NullTreatment = upper + " NULLS"
p.advance() // IGNORE / RESPECT
p.advance() // NULLS
}
}

// Check for WITHIN GROUP clause (SQL:2003 ordered-set aggregates)
// Syntax: WITHIN GROUP (ORDER BY expression [ASC|DESC] [NULLS FIRST|LAST])
// Used with: PERCENTILE_CONT, PERCENTILE_DISC, MODE, LISTAGG, etc.
Expand Down
Loading