diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index c050427b..2f7efdd6 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -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() {} @@ -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 { diff --git a/pkg/sql/parser/expressions_complex.go b/pkg/sql/parser/expressions_complex.go index 15bffb37..19792114 100644 --- a/pkg/sql/parser/expressions_complex.go +++ b/pkg/sql/parser/expressions_complex.go @@ -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 @@ -215,6 +228,7 @@ func (p *Parser) parseCastExpression() (*ast.CastExpression, error) { return &ast.CastExpression{ Expr: expr, Type: dataType, + Try: try, }, nil } diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 0fe9f324..350f6c94 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -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() diff --git a/pkg/sql/parser/snowflake_trycast_nulls_test.go b/pkg/sql/parser/snowflake_trycast_nulls_test.go new file mode 100644 index 00000000..69cc5087 --- /dev/null +++ b/pkg/sql/parser/snowflake_trycast_nulls_test.go @@ -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") + } + }) + } +} diff --git a/pkg/sql/parser/window.go b/pkg/sql/parser/window.go index 63f99a2b..414d4bae 100644 --- a/pkg/sql/parser/window.go +++ b/pkg/sql/parser/window.go @@ -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.