Skip to content

Commit 926afe4

Browse files
ajitpratap0Ajit Pratap Singh
andauthored
feat(parser): TRY_CAST + IGNORE/RESPECT NULLS for Snowflake (#483) (#486)
* feat(parser): TRY_CAST and IGNORE/RESPECT NULLS for Snowflake (#483) * fix(ast): TokenLiteral returns TRY_CAST when Try=true (#483 review) --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local>
1 parent 4e39433 commit 926afe4

File tree

5 files changed

+160
-13
lines changed

5 files changed

+160
-13
lines changed

pkg/sql/ast/ast.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -690,14 +690,15 @@ func (i Identifier) Children() []Node { return nil }
690690
// - OrderBy: ORDER BY clause for order-sensitive aggregates (STRING_AGG, ARRAY_AGG, etc.)
691691
// - WithinGroup: ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, PERCENTILE_DISC, MODE, etc.)
692692
type FunctionCall struct {
693-
Name string
694-
Arguments []Expression // Renamed from Args for consistency
695-
Over *WindowSpec // For window functions
696-
Distinct bool
697-
Filter Expression // WHERE clause for aggregate functions
698-
OrderBy []OrderByExpression // ORDER BY clause for aggregate functions (STRING_AGG, ARRAY_AGG, etc.)
699-
WithinGroup []OrderByExpression // ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, etc.)
700-
Pos models.Location // Source position of the function name (1-based line and column)
693+
Name string
694+
Arguments []Expression // Renamed from Args for consistency
695+
Over *WindowSpec // For window functions
696+
Distinct bool
697+
Filter Expression // WHERE clause for aggregate functions
698+
OrderBy []OrderByExpression // ORDER BY clause for aggregate functions (STRING_AGG, ARRAY_AGG, etc.)
699+
WithinGroup []OrderByExpression // ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, etc.)
700+
NullTreatment string // "IGNORE NULLS" or "RESPECT NULLS" on window functions (Snowflake, Oracle, BigQuery, SQL:2016)
701+
Pos models.Location // Source position of the function name (1-based line and column)
701702
}
702703

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

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

1036-
// CastExpression represents CAST(expr AS type)
1037+
// CastExpression represents CAST(expr AS type) or TRY_CAST(expr AS type).
1038+
// Try is set when the expression originated from TRY_CAST (Snowflake / SQL
1039+
// Server / BigQuery), which returns NULL on conversion failure instead of
1040+
// raising an error.
10371041
type CastExpression struct {
10381042
Expr Expression
10391043
Type string
1044+
Try bool
10401045
}
10411046

1042-
func (c *CastExpression) expressionNode() {}
1043-
func (c CastExpression) TokenLiteral() string { return "CAST" }
1044-
func (c CastExpression) Children() []Node { return []Node{c.Expr} }
1047+
func (c *CastExpression) expressionNode() {}
1048+
func (c CastExpression) TokenLiteral() string {
1049+
if c.Try {
1050+
return "TRY_CAST"
1051+
}
1052+
return "CAST"
1053+
}
1054+
func (c CastExpression) Children() []Node { return []Node{c.Expr} }
10451055

10461056
// AliasedExpression represents an expression with an alias (expr AS alias)
10471057
type AliasedExpression struct {

pkg/sql/parser/expressions_complex.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,20 @@ func (p *Parser) parseCaseExpression() (*ast.CaseExpression, error) {
134134
// CAST(price AS DECIMAL(10,2))
135135
// CAST(name AS VARCHAR(100))
136136
func (p *Parser) parseCastExpression() (*ast.CastExpression, error) {
137-
// Consume CAST keyword
137+
return p.parseCastLike(false)
138+
}
139+
140+
// parseTryCastExpression parses a TRY_CAST expression with the same shape as
141+
// CAST. Snowflake, SQL Server, and BigQuery return NULL on conversion failure
142+
// instead of raising an error.
143+
func (p *Parser) parseTryCastExpression() (*ast.CastExpression, error) {
144+
return p.parseCastLike(true)
145+
}
146+
147+
// parseCastLike implements the body shared between CAST and TRY_CAST. The
148+
// caller is responsible for ensuring the current token is the leading keyword.
149+
func (p *Parser) parseCastLike(try bool) (*ast.CastExpression, error) {
150+
// Consume CAST / TRY_CAST keyword
138151
p.advance()
139152

140153
// Expect opening parenthesis
@@ -215,6 +228,7 @@ func (p *Parser) parseCastExpression() (*ast.CastExpression, error) {
215228
return &ast.CastExpression{
216229
Expr: expr,
217230
Type: dataType,
231+
Try: try,
218232
}, nil
219233
}
220234

pkg/sql/parser/expressions_literal.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) {
6161
return p.parseCastExpression()
6262
}
6363

64+
// TRY_CAST(expr AS type) — Snowflake, SQL Server, BigQuery. Tokenized as
65+
// an identifier (the snowflake keyword table is not wired into the
66+
// tokenizer); detect by name when followed by '('.
67+
if (p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeKeyword)) &&
68+
strings.EqualFold(p.currentToken.Token.Value, "TRY_CAST") &&
69+
p.peekToken().Token.Type == models.TokenTypeLParen {
70+
return p.parseTryCastExpression()
71+
}
72+
6473
if p.isType(models.TokenTypeInterval) {
6574
// Handle INTERVAL 'value' expressions
6675
return p.parseIntervalExpression()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
5+
package parser_test
6+
7+
import (
8+
"testing"
9+
10+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
11+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
12+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
13+
)
14+
15+
// TestTryCast verifies that TRY_CAST(expr AS type) parses in Snowflake
16+
// (and is identical in shape to CAST). Regression for #483.
17+
func TestTryCast(t *testing.T) {
18+
queries := []string{
19+
`SELECT TRY_CAST(value AS INT) FROM events`,
20+
`SELECT TRY_CAST(price AS DECIMAL(10, 2)) FROM products`,
21+
`SELECT TRY_CAST(name AS VARCHAR(100)) FROM users`,
22+
`SELECT TRY_CAST(json_col AS VARIANT) FROM events`,
23+
}
24+
for _, q := range queries {
25+
t.Run(q, func(t *testing.T) {
26+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
27+
t.Fatalf("Snowflake TRY_CAST parse failed: %v", err)
28+
}
29+
})
30+
}
31+
}
32+
33+
// TestWindowNullTreatment verifies IGNORE NULLS / RESPECT NULLS on window
34+
// functions parses for Snowflake. Regression for #483.
35+
func TestWindowNullTreatment(t *testing.T) {
36+
queries := map[string]string{
37+
"lag_ignore_nulls": `SELECT LAG(price) IGNORE NULLS OVER (ORDER BY ts) FROM ticks`,
38+
"lead_respect_nulls": `SELECT LEAD(price) RESPECT NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
39+
"first_value_ignore": `SELECT FIRST_VALUE(price) IGNORE NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
40+
"last_value_respect": `SELECT LAST_VALUE(price) RESPECT NULLS OVER (PARTITION BY symbol ORDER BY ts) FROM ticks`,
41+
}
42+
for name, q := range queries {
43+
q := q
44+
t.Run(name, func(t *testing.T) {
45+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
46+
t.Fatalf("parse failed: %v", err)
47+
}
48+
})
49+
}
50+
}
51+
52+
// TestTryCastASTShape verifies that a TRY_CAST expression has Try=true and
53+
// TokenLiteral() returns "TRY_CAST", while a plain CAST returns "CAST".
54+
func TestTryCastASTShape(t *testing.T) {
55+
tcs := map[string]struct {
56+
query string
57+
wantTry bool
58+
wantLit string
59+
}{
60+
"try_cast": {`SELECT TRY_CAST(value AS INT) FROM events`, true, "TRY_CAST"},
61+
"cast": {`SELECT CAST(value AS INT) FROM events`, false, "CAST"},
62+
}
63+
for name, tc := range tcs {
64+
tc := tc
65+
t.Run(name, func(t *testing.T) {
66+
tree, err := gosqlx.ParseWithDialect(tc.query, keywords.DialectSnowflake)
67+
if err != nil {
68+
t.Fatalf("parse failed: %v", err)
69+
}
70+
var found bool
71+
var visit func(n ast.Node)
72+
visit = func(n ast.Node) {
73+
if n == nil || found {
74+
return
75+
}
76+
if c, ok := n.(*ast.CastExpression); ok {
77+
if c.Try != tc.wantTry {
78+
t.Fatalf("Try: want %v, got %v", tc.wantTry, c.Try)
79+
}
80+
if c.TokenLiteral() != tc.wantLit {
81+
t.Fatalf("TokenLiteral: want %q, got %q", tc.wantLit, c.TokenLiteral())
82+
}
83+
found = true
84+
return
85+
}
86+
for _, ch := range n.Children() {
87+
visit(ch)
88+
}
89+
}
90+
for _, stmt := range tree.Statements {
91+
visit(stmt)
92+
}
93+
if !found {
94+
t.Fatal("CastExpression not found in AST")
95+
}
96+
})
97+
}
98+
}

pkg/sql/parser/window.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,22 @@ func (p *Parser) parseFunctionCall(funcName string) (*ast.FunctionCall, error) {
153153
OrderBy: orderByExprs,
154154
}
155155

156+
// Check for IGNORE NULLS / RESPECT NULLS (SQL:2016 null treatment).
157+
// Used by LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE in Snowflake,
158+
// Oracle, BigQuery, etc. IGNORE arrives as TokenTypeKeyword; RESPECT is
159+
// not in any keyword list and arrives as TokenTypeIdentifier. NULLS has
160+
// its own token type.
161+
if p.currentToken.Token.Type == models.TokenTypeKeyword ||
162+
p.currentToken.Token.Type == models.TokenTypeIdentifier {
163+
upper := strings.ToUpper(p.currentToken.Token.Value)
164+
if (upper == "IGNORE" || upper == "RESPECT") &&
165+
p.peekToken().Token.Type == models.TokenTypeNulls {
166+
funcCall.NullTreatment = upper + " NULLS"
167+
p.advance() // IGNORE / RESPECT
168+
p.advance() // NULLS
169+
}
170+
}
171+
156172
// Check for WITHIN GROUP clause (SQL:2003 ordered-set aggregates)
157173
// Syntax: WITHIN GROUP (ORDER BY expression [ASC|DESC] [NULLS FIRST|LAST])
158174
// Used with: PERCENTILE_CONT, PERCENTILE_DISC, MODE, LISTAGG, etc.

0 commit comments

Comments
 (0)