From 28d97cadd7560f5dc18f677cd135d7b52bc27f4d Mon Sep 17 00:00:00 2001 From: h3n4l Date: Thu, 11 Jun 2026 16:00:54 +0800 Subject: [PATCH] feat(snowflake): parse star ILIKE transform (was mis-parsed as LikeExpr, dropping FROM) --- snowflake/ast/parsenodes.go | 14 +- snowflake/ast/walk_coverage_test.go | 6 + snowflake/ast/walk_generated.go | 3 + snowflake/deparse/deparse_select.go | 7 + snowflake/deparse/deparse_test.go | 23 +++ snowflake/parser/select.go | 44 ++++-- snowflake/parser/select_test.go | 235 ++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 14 deletions(-) diff --git a/snowflake/ast/parsenodes.go b/snowflake/ast/parsenodes.go index cba41097..6967f058 100644 --- a/snowflake/ast/parsenodes.go +++ b/snowflake/ast/parsenodes.go @@ -802,15 +802,19 @@ var _ Node = (*SelectStmt)(nil) // For expressions: Expr is set, Star is false. // For star: Star is true, Expr may be a qualifier (table.*) or nil (bare *). // -// Exclude / Replace / Rename carry the Snowflake star column-transforms that -// may follow a `*` or `tbl.*`: EXCLUDE drops the named columns; REPLACE -// substitutes an expression for a column while keeping its name; RENAME -// aliases columns. Any combination may appear together, in documented order -// (EXCLUDE, then REPLACE, then RENAME). They are only valid on a star target. +// Ilike / Exclude / Replace / Rename carry the Snowflake star +// column-transforms that may follow a `*` or `tbl.*`: ILIKE keeps only the +// columns whose names match the pattern; EXCLUDE drops the named columns; +// REPLACE substitutes an expression for a column while keeping its name; +// RENAME aliases columns. They appear in documented order (ILIKE, then +// EXCLUDE, then REPLACE, then RENAME) and are only valid on a star target. +// The docs additionally forbid combining ILIKE with EXCLUDE; the parser +// over-accepts that combination (semantic validation is a later layer's job). type SelectTarget struct { Expr Node // expression; nil for bare * Alias Ident // AS alias; zero Ident if absent Star bool // true for * or qualifier.* + Ilike *Literal // ILIKE '' string literal; nil if absent Exclude []Ident // EXCLUDE columns; nil if absent Replace []StarReplace // REPLACE expr AS col pairs; nil if absent Rename []StarRename // RENAME col AS alias pairs; nil if absent diff --git a/snowflake/ast/walk_coverage_test.go b/snowflake/ast/walk_coverage_test.go index bfb49b23..3d442e90 100644 --- a/snowflake/ast/walk_coverage_test.go +++ b/snowflake/ast/walk_coverage_test.go @@ -172,6 +172,12 @@ func TestWalkCoverage_SelectClauses(t *testing.T) { sql: "SELECT * REPLACE (UPPER(ra) AS ssn, rb || rc AS dept) FROM t", cols: []string{"RA", "RB", "RC"}, }, + { + name: "SelectTarget.Ilike star-transform pattern", + sql: "SELECT * ILIKE '%id%' REPLACE (UPPER(ra) AS ssn) FROM t", + cols: []string{"RA"}, + lits: []string{"%id%"}, + }, { name: "SelectStmt.With CTE body", sql: "WITH c AS (SELECT ca FROM t) SELECT * FROM c", diff --git a/snowflake/ast/walk_generated.go b/snowflake/ast/walk_generated.go index b30b536f..14adff7d 100644 --- a/snowflake/ast/walk_generated.go +++ b/snowflake/ast/walk_generated.go @@ -1597,6 +1597,9 @@ func walkSelectTarget(v Visitor, n *SelectTarget) { return } Walk(v, n.Expr) + if n.Ilike != nil { + Walk(v, n.Ilike) + } for i := range n.Replace { walkStarReplace(v, &n.Replace[i]) } diff --git a/snowflake/deparse/deparse_select.go b/snowflake/deparse/deparse_select.go index a0a811b0..deb4de00 100644 --- a/snowflake/deparse/deparse_select.go +++ b/snowflake/deparse/deparse_select.go @@ -238,6 +238,13 @@ func (w *writer) writeSelectTarget(t *ast.SelectTarget) error { } else { w.buf.WriteByte('*') } + // ILIKE 'pattern' + if t.Ilike != nil { + w.buf.WriteString(" ILIKE") + if err := w.writeLiteral(t.Ilike); err != nil { + return err + } + } // EXCLUDE (col, ...) if len(t.Exclude) > 0 { w.buf.WriteString(" EXCLUDE (") diff --git a/snowflake/deparse/deparse_test.go b/snowflake/deparse/deparse_test.go index eaf7af7a..3689d9dd 100644 --- a/snowflake/deparse/deparse_test.go +++ b/snowflake/deparse/deparse_test.go @@ -134,6 +134,29 @@ func TestDeparse_Select_RenameStarList(t *testing.T) { assertRoundTrip(t, `SELECT * RENAME (a AS b, c AS d) FROM t`) } +func TestDeparse_Select_IlikeStar(t *testing.T) { + assertRoundTrip(t, `SELECT * ILIKE '%id%' FROM employee_table`) +} + +func TestDeparse_Select_QualifiedStarIlike(t *testing.T) { + assertRoundTrip(t, `SELECT t.* ILIKE '%id%' FROM t`) +} + +func TestDeparse_Select_IlikeRenameStar(t *testing.T) { + // Corpus official/select/example_12 shape. + assertRoundTrip(t, `SELECT * ILIKE '%id%' RENAME (department_id AS department) FROM employee_table`) +} + +func TestDeparse_Select_IlikeReplaceStar(t *testing.T) { + // Corpus official/select/example_15 shape. + assertRoundTrip(t, `SELECT * ILIKE '%id%' REPLACE ('DEPT-' || department_id AS department_id) FROM employee_table`) +} + +func TestDeparse_Select_IlikeReplaceRenameStar(t *testing.T) { + // ILIKE first, then REPLACE, then RENAME — the documented order. + assertRoundTrip(t, `SELECT * ILIKE 'col%' REPLACE (UPPER(a) AS a) RENAME (a AS b) FROM t`) +} + func TestDeparse_Select_ReplaceStar(t *testing.T) { assertRoundTrip(t, `SELECT * REPLACE (UPPER(SSN) AS SSN) FROM T`) } diff --git a/snowflake/parser/select.go b/snowflake/parser/select.go index af1c10a7..a39886b4 100644 --- a/snowflake/parser/select.go +++ b/snowflake/parser/select.go @@ -490,7 +490,7 @@ func (p *Parser) selectListTerminator() bool { } // parseSelectTarget parses one item in the SELECT list: -// - * [EXCLUDE (col, ...)] [REPLACE (expr AS col, ...)] [RENAME (col AS alias, ...)] +// - * [ILIKE 'pattern'] [EXCLUDE (col, ...)] [REPLACE (expr AS col, ...)] [RENAME (col AS alias, ...)] // - expr [AS alias] // // The expression parser already handles * (StarExpr) and qualifier.* @@ -508,13 +508,37 @@ func (p *Parser) parseSelectTarget() (*ast.SelectTarget, error) { Loc: ast.Loc{Start: startLoc.Start}, } + // Star ILIKE transform: ILIKE is an infix operator, so for + // `* ILIKE ''` (and `tbl.* ILIKE ...`) the expression parser has + // already bound the star into LikeExpr(StarExpr, pattern) before this + // function can see it. A star is not a scalar operand, so in a SELECT + // list that shape is unambiguously Snowflake's star ILIKE + // column-transform, not a boolean expression — unwrap it here, at the + // select-target boundary, so other expression contexts are untouched. + // The unwrap keys on the exact documented shape (plain ILIKE with a + // string-literal pattern); NOT/ANY/ESCAPE variants and non-literal + // patterns are not transforms and stay expressions. + if like, ok := expr.(*ast.LikeExpr); ok && + like.Op == ast.LikeOpILike && !like.Not && !like.Any && like.Escape == nil { + if _, isStar := like.Expr.(*ast.StarExpr); isStar { + if pat, isLit := like.Pattern.(*ast.Literal); isLit && pat.Kind == ast.LitString { + expr = like.Expr + target.Ilike = pat + } + } + } + // Check if the expression is a star (* or qualifier.*) if _, ok := expr.(*ast.StarExpr); ok { target.Star = true target.Expr = expr - // Star column-transforms, in Snowflake's documented order: EXCLUDE, - // then REPLACE, then RENAME (any subset may be present). + // Star column-transforms, in Snowflake's documented order: ILIKE + // (unwrapped above), then EXCLUDE, then REPLACE, then RENAME (any + // subset may be present; the docs forbid combining ILIKE with + // EXCLUDE, which the parser over-accepts — the combination is + // positionally in order and parses soundly). + // ILIKE '' // EXCLUDE | EXCLUDE (, ...) // REPLACE ( AS , ...) // RENAME AS | RENAME ( AS , ...) @@ -537,15 +561,17 @@ func (p *Parser) parseSelectTarget() (*ast.SelectTarget, error) { } } // A transform keyword still pending here is out of documented order - // (e.g. `* RENAME (...) REPLACE (...)`). parseSingle ignores tokens - // after a completed statement, so without this check the rest of the - // statement — including FROM — would be dropped silently. Fail loudly - // instead. + // (e.g. `* RENAME (...) REPLACE (...)`, or ILIKE after any other + // transform — ILIKE must come first, and in first position it was + // already consumed by the expression parse and unwrapped above). + // parseSingle ignores tokens after a completed statement, so without + // this check the rest of the statement — including FROM — would be + // dropped silently. Fail loudly instead. switch p.cur.Type { - case kwEXCLUDE, kwREPLACE, kwRENAME: + case kwILIKE, kwEXCLUDE, kwREPLACE, kwRENAME: return nil, &ParseError{ Loc: p.cur.Loc, - Msg: "star column-transforms must appear in EXCLUDE, REPLACE, RENAME order", + Msg: "star column-transforms must appear in ILIKE/EXCLUDE, REPLACE, RENAME order", } } } else { diff --git a/snowflake/parser/select_test.go b/snowflake/parser/select_test.go index f6294097..92e437c2 100644 --- a/snowflake/parser/select_test.go +++ b/snowflake/parser/select_test.go @@ -2396,6 +2396,241 @@ func TestSelect_ReplaceAsColumnAndFunction(t *testing.T) { } } +// Regression for the star-ILIKE silent drop (corpus official/select +// example_06): ILIKE is an infix operator, so the expression parser used to +// swallow `* ILIKE ''` into a LikeExpr — the target was not a star, +// any following transform keyword was eaten as an alias, and the FROM clause +// was silently dropped from the AST (SelectStmt.From empty), which is +// masking-unsound for query-span consumers. It must now parse as a star +// target with Ilike populated and From non-empty. +func TestSelect_StarIlike(t *testing.T) { + const sql = "SELECT * ILIKE '%id%' FROM employee_table" + sel, errs := testParseSelectStmt(sql) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if !target.Star { + t.Fatalf("target should be star, got expr %#v", target.Expr) + } + if target.Ilike == nil { + t.Fatal("target.Ilike = nil, want ILIKE pattern literal") + } + if target.Ilike.Kind != ast.LitString || target.Ilike.Value != "%id%" { + t.Errorf("Ilike = kind %v value %q, want string %q", target.Ilike.Kind, target.Ilike.Value, "%id%") + } + if len(target.Exclude) != 0 || len(target.Replace) != 0 || len(target.Rename) != 0 { + t.Errorf("unexpected other transforms: exclude=%v replace=%v rename=%v", + target.Exclude, target.Replace, target.Rename) + } + // The FROM clause must survive — this is the silent-drop regression check. + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1 (FROM clause was dropped)", len(sel.From)) + } + ref, ok := sel.From[0].(*ast.TableRef) + if !ok || ref.Name == nil || ref.Name.Normalize() != "EMPLOYEE_TABLE" { + t.Errorf("From[0] = %#v, want table employee_table", sel.From[0]) + } + // And the statement must span the whole input, not a prefix. + if got := sql[sel.Loc.Start:sel.Loc.End]; got != sql { + t.Errorf("stmt Loc slice = %q, want full input", got) + } +} + +// ILIKE then RENAME (corpus official/select/example_12). RENAME used to be +// eaten as the alias of the mis-parsed LikeExpr target. +func TestSelect_StarIlikeRename(t *testing.T) { + sel, errs := testParseSelectStmt( + "SELECT * ILIKE '%id%' RENAME department_id AS department FROM employee_table") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if !target.Star || target.Ilike == nil || target.Ilike.Value != "%id%" { + t.Fatalf("star/Ilike mismatch: star=%v ilike=%#v", target.Star, target.Ilike) + } + if len(target.Rename) != 1 || target.Rename[0].Col.Name != "department_id" || + target.Rename[0].Alias.Name != "department" { + t.Fatalf("rename = %v, want department_id AS department", target.Rename) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// ILIKE then REPLACE (corpus official/select/example_15). +func TestSelect_StarIlikeReplace(t *testing.T) { + sel, errs := testParseSelectStmt( + "SELECT * ILIKE '%id%' REPLACE('DEPT-' || department_id AS department_id) FROM employee_table") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if !target.Star || target.Ilike == nil || target.Ilike.Value != "%id%" { + t.Fatalf("star/Ilike mismatch: star=%v ilike=%#v", target.Star, target.Ilike) + } + if len(target.Replace) != 1 || target.Replace[0].Col.Name != "department_id" { + t.Fatalf("replace = %v, want one department_id pair", target.Replace) + } + if _, ok := target.Replace[0].Expr.(*ast.BinaryExpr); !ok { + t.Errorf("replace[0].Expr = %T, want *ast.BinaryExpr (concat)", target.Replace[0].Expr) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// ILIKE then REPLACE then RENAME — the full documented-order chain. +func TestSelect_StarIlikeReplaceRename(t *testing.T) { + sel, errs := testParseSelectStmt( + "SELECT * ILIKE 'col%' REPLACE (UPPER(a) AS a) RENAME (a AS b) FROM t") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if target.Ilike == nil || target.Ilike.Value != "col%" { + t.Fatalf("Ilike = %#v, want 'col%%'", target.Ilike) + } + if len(target.Replace) != 1 || len(target.Rename) != 1 { + t.Fatalf("replace=%v rename=%v, want one pair each", target.Replace, target.Rename) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// ILIKE applies to qualified stars too: [{|}.]* ILIKE ... +func TestSelect_QualifiedStarIlike(t *testing.T) { + sel, errs := testParseSelectStmt("SELECT t.* ILIKE '%id%' FROM t") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if !target.Star { + t.Fatalf("target should be star, got %#v", target.Expr) + } + star, ok := target.Expr.(*ast.StarExpr) + if !ok || star.Qualifier == nil || star.Qualifier.Name.Name != "t" { + t.Fatalf("qualifier mismatch: %#v", target.Expr) + } + if target.Ilike == nil || target.Ilike.Value != "%id%" { + t.Fatalf("Ilike = %#v, want '%%id%%'", target.Ilike) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +func TestSelect_StarIlikeLoc(t *testing.T) { + const sql = "SELECT * ILIKE '%id%' FROM t" + sel, errs := testParseSelectStmt(sql) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + got := sql[target.Loc.Start:target.Loc.End] + if got != "* ILIKE '%id%'" { + t.Errorf("target Loc slice = %q, want %q", got, "* ILIKE '%id%'") + } +} + +// Snowflake's docs forbid combining ILIKE with EXCLUDE, but the combination +// is positionally in documented order and parses soundly (FROM intact), so +// the parser over-accepts it — semantic validation is a later layer's job. +func TestSelect_StarIlikeExcludeOverAccept(t *testing.T) { + sel, errs := testParseSelectStmt("SELECT * ILIKE '%id%' EXCLUDE department_id FROM t") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if target.Ilike == nil || len(target.Exclude) != 1 { + t.Fatalf("ilike=%#v exclude=%v, want both populated", target.Ilike, target.Exclude) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// Disambiguation: `a ILIKE 'x'` on a column stays a boolean ILIKE expression +// target — only a BARE (or qualified) star left operand is reinterpreted as +// the star ILIKE transform. +func TestSelect_ColumnIlikeExpressionUntouched(t *testing.T) { + sel, errs := testParseSelectStmt("SELECT a ILIKE '%x%' AS flag, b FROM t") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if target.Star || target.Ilike != nil { + t.Fatalf("target wrongly star-ified: star=%v ilike=%#v", target.Star, target.Ilike) + } + like, ok := target.Expr.(*ast.LikeExpr) + if !ok || like.Op != ast.LikeOpILike { + t.Fatalf("target = %T, want *ast.LikeExpr with ILIKE op", target.Expr) + } + if target.Alias.Name != "flag" { + t.Errorf("alias = %q, want flag", target.Alias.Name) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// Scope boundary: COUNT(* ILIKE 'pattern') is valid Snowflake (the wildcard +// transforms also apply inside COUNT per its docs), but the COUNT(*) argument +// path is special-cased (`*` then `)`) and has never parsed transforms — a +// pre-existing, separate gap. The select-target reinterpretation must not +// change that: it stays a LOUD parse error, never a silent truncation. +func TestSelect_CountStarIlikeArgStillLoudError(t *testing.T) { + if _, err := Parse("SELECT COUNT(* ILIKE 'col1%') FROM t"); err == nil { + t.Fatal("COUNT(* ILIKE ...) unexpectedly parses now — update this pin " + + "(and make sure the select-target ILIKE unwrap did not leak into function args)") + } +} + +// Non-transform star shapes are NOT reinterpreted: `* NOT ILIKE 'x'` is not a +// documented star transform, so it stays an (invalid-SQL) expression target. +// Nothing after the pattern follows here, so FROM still parses. +func TestSelect_StarNotIlikeStaysExpression(t *testing.T) { + sel, errs := testParseSelectStmt("SELECT * NOT ILIKE 'x%' FROM t") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + target := sel.Targets[0] + if target.Star || target.Ilike != nil { + t.Fatalf("target wrongly star-ified: star=%v ilike=%#v", target.Star, target.Ilike) + } + like, ok := target.Expr.(*ast.LikeExpr) + if !ok || !like.Not { + t.Fatalf("target = %#v, want NOT ILIKE LikeExpr", target.Expr) + } + if len(sel.From) != 1 { + t.Fatalf("From = %d entries, want 1", len(sel.From)) + } +} + +// Negative: ILIKE without a pattern. +func TestSelect_StarIlikeNoPattern(t *testing.T) { + _, err := Parse("SELECT * ILIKE FROM t") + if err == nil { + t.Fatal("expected error for `* ILIKE` without pattern") + } +} + +// Negative: ILIKE after another transform is out of documented order (ILIKE +// must come first) and must error loudly rather than have the rest of the +// statement — including FROM — silently dropped. +func TestSelect_StarIlikeOutOfOrder(t *testing.T) { + for _, sql := range []string{ + "SELECT * EXCLUDE a ILIKE '%id%' FROM t", + "SELECT * REPLACE (UPPER(c) AS c) ILIKE '%id%' FROM t", + "SELECT * RENAME (a AS b) ILIKE '%id%' FROM t", + } { + if _, err := Parse(sql); err == nil { + t.Errorf("expected error for out-of-order ILIKE: %s", sql) + } + } +} + // Negative: `* EXCLUDE` with no column. func TestSelect_StarExcludeNoColumn(t *testing.T) { _, err := Parse("SELECT * EXCLUDE FROM t")