From 814df812f7c5c9f0bba31fbec71e84d05efccda5 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Sun, 18 Oct 2026 16:45:04 +0530 Subject: [PATCH 1/3] fix(ast): render Snowflake VariantPath in SQL() and the formatter (#782) VariantPath had no SQL() method, so expression rendering fell back to TokenLiteral() and emitted a bare ':' (WHERE src:a = 1 became WHERE : = 1). Add SQL()/writeSQL covering root, :field, .field and [index] segments, with field names quoted via the shared bare-name rules so a hostile key cannot break out of the path. The formatter dispatches through SQL() and is fixed by the same change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --- pkg/formatter/render_variant_path_782_test.go | 152 ++++++++++++++++++ pkg/sql/ast/sql_variant_path.go | 65 ++++++++ 2 files changed, 217 insertions(+) create mode 100644 pkg/formatter/render_variant_path_782_test.go create mode 100644 pkg/sql/ast/sql_variant_path.go diff --git a/pkg/formatter/render_variant_path_782_test.go b/pkg/formatter/render_variant_path_782_test.go new file mode 100644 index 00000000..d43ff63d --- /dev/null +++ b/pkg/formatter/render_variant_path_782_test.go @@ -0,0 +1,152 @@ +// 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 formatter_test + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func variantSQL(t *testing.T, input string) string { + t.Helper() + tree, err := gosqlx.ParseWithDialect(input, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("parse %q: %v", input, err) + } + defer ast.ReleaseAST(tree) + return tree.SQL() +} + +// TestVariantPath_SQLRoundTrip_782 checks that every variant path form the +// parser produces renders as a path (never a bare ':') and that +// parse -> SQL() -> parse -> SQL() is stable. +func TestVariantPath_SQLRoundTrip_782(t *testing.T) { + cases := []struct { + name string + input string + want string // expected first rendering; empty means only check stability + }{ + {"simple_where", `SELECT * FROM t WHERE src:a = 1`, `SELECT * FROM t WHERE src:a = 1`}, + {"nested", `SELECT src:a.b FROM t`, `SELECT src:a.b FROM t`}, + {"index", `SELECT src:a[0] FROM t`, `SELECT src:a[0] FROM t`}, + {"nested_index_field", `SELECT src:a.b[0].c FROM t`, `SELECT src:a.b[0].c FROM t`}, + {"index_expr", `SELECT src:a[i + 1] FROM t`, ``}, + {"qualified_root", `SELECT t.src:a.b FROM t`, `SELECT t.src:a.b FROM t`}, + {"function_root", `SELECT PARSE_JSON(raw):a.b FROM t`, `SELECT PARSE_JSON(raw):a.b FROM t`}, + {"bracket_key", `SELECT src['key'] FROM t`, `SELECT src['key'] FROM t`}, + {"cast_select", `SELECT src:a.b::int FROM t`, ``}, + {"cast_rhs", `SELECT * FROM t WHERE a = src:b::int`, ``}, + {"cast_both_sides", `SELECT * FROM t WHERE src:a::int = src:b::int`, ``}, + {"rhs_plain", `SELECT * FROM t WHERE a = src:b`, `SELECT * FROM t WHERE a = src:b`}, + {"both_sides", `SELECT * FROM t WHERE src:a = src:b`, `SELECT * FROM t WHERE src:a = src:b`}, + {"order_by", `SELECT id FROM t ORDER BY src:a.b DESC`, `SELECT id FROM t ORDER BY src:a.b DESC`}, + {"group_by", `SELECT src:a, COUNT(*) FROM t GROUP BY src:a`, `SELECT src:a, COUNT(*) FROM t GROUP BY src:a`}, + {"quoted_field", `SELECT src:"my key" FROM t`, `SELECT src:"my key" FROM t`}, + {"keyword_field", `SELECT src:value.type FROM t`, ``}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + first := variantSQL(t, tc.input) + if strings.Contains(first, ": ") || strings.Contains(first, "(:") || strings.HasSuffix(first, ":") { + t.Fatalf("bare ':' in output: %s", first) + } + if tc.want != "" && first != tc.want { + t.Errorf("SQL() mismatch\n got: %s\nwant: %s", first, tc.want) + } + second := variantSQL(t, first) + if second != first { + t.Errorf("round-trip not stable\n first: %s\nsecond: %s", first, second) + } + }) + } +} + +// TestVariantPath_FormatAST_Reparses_782 checks that formatter output for +// variant paths parses again and is stable under both styles. +func TestVariantPath_FormatAST_Reparses_782(t *testing.T) { + inputs := []string{ + `SELECT src:a, src:a.b[0].c::int FROM t WHERE src:a = 1 AND b = src:c::int GROUP BY src:a ORDER BY src:a.b`, + `SELECT src['key'], src:"a b" FROM t WHERE src:x[1] > src:y`, + } + for _, input := range inputs { + tree, err := gosqlx.ParseWithDialect(input, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("parse %q: %v", input, err) + } + for _, opts := range []ast.FormatOptions{ast.CompactStyle(), ast.ReadableStyle()} { + got := formatter.FormatAST(tree, opts) + tree2, err := gosqlx.ParseWithDialect(got, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("re-parse of %q: %v", got, err) + } + got2 := formatter.FormatAST(tree2, opts) + ast.ReleaseAST(tree2) + if got2 != got { + t.Errorf("format not stable\n first: %s\nsecond: %s", got, got2) + } + } + ast.ReleaseAST(tree) + } +} + +// TestVariantPath_HostileKey_782 checks that a hostile field name cannot +// break out of the path: it is double-quoted with embedded quotes doubled and +// the output re-parses as one statement with one path. +func TestVariantPath_HostileKey_782(t *testing.T) { + hostile := []string{ + `a" ; DROP TABLE y; --`, + `a b`, + `a.b`, + `a:b`, + `1abc`, + ``, + `x"y`, + `a[0]`, + } + for _, key := range hostile { + vp := &ast.VariantPath{ + Root: &ast.Identifier{Name: "src"}, + Segments: []ast.VariantPathSegment{{Name: "ok"}, {Name: key}}, + } + sel := &ast.SelectStatement{ + Columns: []ast.Expression{vp}, + From: []ast.TableReference{{Name: "t"}}, + } + tree := &ast.AST{Statements: []ast.Statement{sel}} + want := `SELECT src:ok."` + strings.ReplaceAll(key, `"`, `""`) + `" FROM t` + for _, opts := range []ast.FormatOptions{ast.CompactStyle(), ast.ReadableStyle()} { + got := formatter.FormatAST(tree, opts) + if opts == ast.CompactStyle() && got != want { + t.Errorf("key %q\n got: %s\nwant: %s", key, got, want) + } + tree2, err := gosqlx.ParseWithDialect(got, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("key %q: re-parse of %q: %v", key, got, err) + } + if n := len(tree2.Statements); n != 1 { + t.Fatalf("key %q: output re-parses to %d statements: %s", key, n, got) + } + ast.ReleaseAST(tree2) + } + if got := vp.SQL(); got != `src:ok."`+strings.ReplaceAll(key, `"`, `""`)+`"` { + t.Errorf("key %q: SQL() = %s", key, got) + } + } +} diff --git a/pkg/sql/ast/sql_variant_path.go b/pkg/sql/ast/sql_variant_path.go new file mode 100644 index 00000000..ecac823a --- /dev/null +++ b/pkg/sql/ast/sql_variant_path.go @@ -0,0 +1,65 @@ +// 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 ast + +import "bytes" + +// SQL returns the SQL representation of a Snowflake VARIANT path such as +// "src:a.b[0].c". The first field follows ':' and later fields follow '.'; +// bracket subscripts render as "[expr]". A field name that would not re-parse +// as a bare path element is double-quoted with embedded quotes doubled, so a +// hostile key cannot break out of the path. +func (v *VariantPath) SQL() string { + if v == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + v.writeSQL(buf) + return buf.String() +} + +func (v *VariantPath) writeSQL(buf *bytes.Buffer) { + if v == nil { + return + } + writeExpr(buf, v.Root) + for i, seg := range v.Segments { + if seg.Index != nil { + buf.WriteByte('[') + writeExpr(buf, seg.Index) + buf.WriteByte(']') + continue + } + if i == 0 { + buf.WriteByte(':') + } else { + buf.WriteByte('.') + } + writeVariantFieldName(buf, seg.Name) + } +} + +// writeVariantFieldName writes a VARIANT path field name, double-quoting it +// unless it is a non-empty run of letters, digits and '_' that does not start +// with a digit. Reserved words stay bare: the parser accepts keywords as +// path fields. +func writeVariantFieldName(buf *bytes.Buffer, name string) { + if name != "" && isBareNameChars(name, false) { + buf.WriteString(name) + return + } + writeQuotedIdentifier(buf, name) +} From 80208c05d0d5f1caaa0e933ba7f86bcbfa8c517d Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Sun, 18 Oct 2026 16:54:54 +0530 Subject: [PATCH 2/3] fix(ast): quote reserved-word VARIANT path fields and harden hand-built paths (#782) Quote path field names that need quoting for a name position (NULL, FROM, ORDER, KEY, ANY, SOME, ...) so SQL() and FormatAST output re-parse. Write the ':' before the first field even after a leading index segment, and parenthesize operator-like roots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --- .../render_variant_path_reserved_782_test.go | 107 ++++++++++++++++++ pkg/sql/ast/sql_variant_path.go | 39 +++++-- 2 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 pkg/formatter/render_variant_path_reserved_782_test.go diff --git a/pkg/formatter/render_variant_path_reserved_782_test.go b/pkg/formatter/render_variant_path_reserved_782_test.go new file mode 100644 index 00000000..3b601a51 --- /dev/null +++ b/pkg/formatter/render_variant_path_reserved_782_test.go @@ -0,0 +1,107 @@ +// 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 formatter_test + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestVariantPath_ReservedWordFields_782 checks that JSON keys which are +// reserved words render quoted and re-parse, in both the ':' and '.' +// positions, via SQL() and FormatAST. +func TestVariantPath_ReservedWordFields_782(t *testing.T) { + words := []string{ + "null", "true", "false", "and", "or", "not", "is", "in", "select", "from", + "where", "as", "case", "when", "then", "else", "end", "between", "like", + "ilike", "union", "except", "intersect", "order", "group", "by", "having", + "limit", "offset", "fetch", "join", "on", "using", "inner", "outer", "left", + "right", "full", "cross", "natural", "lateral", "interval", "exists", + "distinct", "all", "any", "some", "with", "recursive", "over", "partition", + "asc", "desc", "nulls", "first", "last", "default", "values", "set", "into", + "key", "row", "array", "cast", "collate", "autoincrement", "materialized", + } + for _, w := range words { + for _, word := range []string{w, strings.ToUpper(w)} { + for _, tc := range []struct{ name, in string }{ + {"colon", `SELECT src:"` + word + `" FROM t`}, + {"dot", `SELECT src:a."` + word + `" FROM t`}, + } { + t.Run(tc.name+"/"+word, func(t *testing.T) { + first := variantSQL(t, tc.in) + if first != tc.in { + t.Fatalf("SQL() mismatch\n got: %s\nwant: %s", first, tc.in) + } + tree, err := gosqlx.ParseWithDialect(first, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("re-parse %q: %v", first, err) + } + defer ast.ReleaseAST(tree) + for _, opts := range []ast.FormatOptions{ast.CompactStyle(), ast.ReadableStyle()} { + out := formatter.FormatAST(tree, opts) + tree2, err := gosqlx.ParseWithDialect(out, keywords.DialectSnowflake) + if err != nil { + t.Fatalf("re-parse of formatted %q: %v", out, err) + } + ast.ReleaseAST(tree2) + } + }) + } + } + } +} + +// TestVariantPath_HandBuilt_782 covers hand-built paths the parser never +// produces: a leading index segment and an operator-like root. +func TestVariantPath_HandBuilt_782(t *testing.T) { + cases := []struct { + name string + vp *ast.VariantPath + want string + }{ + {"index_first", &ast.VariantPath{ + Root: &ast.Identifier{Name: "src"}, + Segments: []ast.VariantPathSegment{ + {Index: &ast.LiteralValue{Value: "0", Type: "INTEGER"}}, + {Name: "a"}, {Name: "b"}, + }, + }, `src[0]:a.b`}, + {"binary_root", &ast.VariantPath{ + Root: &ast.BinaryExpression{ + Left: &ast.Identifier{Name: "a"}, + Operator: "+", + Right: &ast.Identifier{Name: "b"}, + }, + Segments: []ast.VariantPathSegment{{Name: "c"}}, + }, `(a + b):c`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.vp.SQL(); got != tc.want { + t.Fatalf("SQL() = %q, want %q", got, tc.want) + } + tree, err := gosqlx.ParseWithDialect("SELECT "+tc.want+" FROM t", keywords.DialectSnowflake) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + ast.ReleaseAST(tree) + }) + } +} diff --git a/pkg/sql/ast/sql_variant_path.go b/pkg/sql/ast/sql_variant_path.go index ecac823a..92e7a012 100644 --- a/pkg/sql/ast/sql_variant_path.go +++ b/pkg/sql/ast/sql_variant_path.go @@ -14,7 +14,10 @@ package ast -import "bytes" +import ( + "bytes" + "strings" +) // SQL returns the SQL representation of a Snowflake VARIANT path such as // "src:a.b[0].c". The first field follows ':' and later fields follow '.'; @@ -35,29 +38,45 @@ func (v *VariantPath) writeSQL(buf *bytes.Buffer) { if v == nil { return } - writeExpr(buf, v.Root) - for i, seg := range v.Segments { + // Operator-like roots are parenthesized so the path binds to the whole + // expression rather than only its last operand. + switch v.Root.(type) { + case *BinaryExpression, *UnaryExpression, *CastExpression, + *CaseExpression, *BetweenExpression, *InExpression: + buf.WriteByte('(') + writeExpr(buf, v.Root) + buf.WriteByte(')') + default: + writeExpr(buf, v.Root) + } + colon := false // the ':' introducing the first field has been written + for _, seg := range v.Segments { if seg.Index != nil { buf.WriteByte('[') writeExpr(buf, seg.Index) buf.WriteByte(']') continue } - if i == 0 { - buf.WriteByte(':') - } else { + if colon { buf.WriteByte('.') + } else { + buf.WriteByte(':') + colon = true } writeVariantFieldName(buf, seg.Name) } } // writeVariantFieldName writes a VARIANT path field name, double-quoting it -// unless it is a non-empty run of letters, digits and '_' that does not start -// with a digit. Reserved words stay bare: the parser accepts keywords as -// path fields. +// unless it is bare-safe: a non-empty run of letters, digits and '_' that does +// not start with a digit and is not a reserved word. The parser accepts only +// words the tokenizer emits as keyword tokens as bare path fields; words with +// their own token type (NULL, FROM, ORDER, KEY, ...) and ANY/SOME are +// rejected, so they are quoted. Snowflake treats path elements as +// case-sensitive whether or not they are quoted, so quoting is always safe. func writeVariantFieldName(buf *bytes.Buffer, name string) { - if name != "" && isBareNameChars(name, false) { + if name != "" && !nameNeedsQuoting(name, false) && + !strings.EqualFold(name, "ANY") && !strings.EqualFold(name, "SOME") { buf.WriteString(name) return } From df719d291be66bd8cc87a6304bd517fd482e1f1c Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Sun, 18 Oct 2026 17:01:35 +0530 Subject: [PATCH 3/3] fix(ast): quote tokenizer-typed VARIANT path fields and stop wrapping unary roots (#782) - Always quote URL, OWNER, POLICY, VALID, UNTIL, RESET, MEMBER, CONNECTOR, AUTO_INCREMENT, DCPROPERTIES, NOCREATEDB, NOCREATEROLE, NOLOGIN and NOSUPERUSER path fields; the parser rejects them bare. - Do not parenthesize a UnaryExpression root: -src:a must not become (-src):a, which binds differently in Snowflake. - Add a brute-force keyword round-trip test asserting exact segment names, name-equality checks in the hostile-key test, and unary-root cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --- pkg/formatter/render_variant_path_782_test.go | 121 ++++++++++++++++++ pkg/sql/ast/sql_variant_path.go | 21 ++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/pkg/formatter/render_variant_path_782_test.go b/pkg/formatter/render_variant_path_782_test.go index d43ff63d..d8a67268 100644 --- a/pkg/formatter/render_variant_path_782_test.go +++ b/pkg/formatter/render_variant_path_782_test.go @@ -143,6 +143,9 @@ func TestVariantPath_HostileKey_782(t *testing.T) { if n := len(tree2.Statements); n != 1 { t.Fatalf("key %q: output re-parses to %d statements: %s", key, n, got) } + if names := variantNames(t, tree2); len(names) != 2 || names[1] != key { + t.Errorf("key %q: re-parsed names = %q", key, names) + } ast.ReleaseAST(tree2) } if got := vp.SQL(); got != `src:ok."`+strings.ReplaceAll(key, `"`, `""`)+`"` { @@ -150,3 +153,121 @@ func TestVariantPath_HostileKey_782(t *testing.T) { } } } + +// variantNames returns the field names of the first VariantPath found in the +// first SELECT column of tree. +func variantNames(t *testing.T, tree *ast.AST) []string { + t.Helper() + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok || len(sel.Columns) == 0 { + t.Fatalf("expected SelectStatement with columns, got %T", tree.Statements[0]) + } + vp, ok := sel.Columns[0].(*ast.VariantPath) + if !ok { + t.Fatalf("expected VariantPath column, got %T", sel.Columns[0]) + } + var names []string + for _, seg := range vp.Segments { + names = append(names, seg.Name) + } + return names +} + +// TestVariantPath_AllKeywordsRoundTrip_782 builds a path for every known +// keyword (upper and lower case) at both segment positions and requires the +// rendered SQL to re-parse with the exact same names, so a keyword whose token +// type the path parser rejects cannot silently break round-trips. +func TestVariantPath_AllKeywordsRoundTrip_782(t *testing.T) { + seen := map[string]bool{} + var words []string + add := func(list []keywords.Keyword) { + for _, k := range list { + if !seen[k.Word] { + seen[k.Word] = true + words = append(words, k.Word) + } + } + } + add(keywords.RESERVED_FOR_TABLE_ALIAS) + add(keywords.ADDITIONAL_KEYWORDS) + for _, d := range keywords.AllDialects() { + add(keywords.DialectKeywords(d)) + } + // Words with their own tokenizer token type that the keyword tables above + // do not list. + for _, w := range []string{"URL", "OWNER", "POLICY", "VALID", "UNTIL", "RESET", + "MEMBER", "CONNECTOR", "AUTO_INCREMENT", "DCPROPERTIES", "NOCREATEDB", + "NOCREATEROLE", "NOLOGIN", "NOSUPERUSER"} { + if !seen[w] { + seen[w] = true + words = append(words, w) + } + } + if len(words) < 100 { + t.Fatalf("keyword enumeration looks wrong: %d words", len(words)) + } + for _, w := range words { + for _, word := range []string{w, strings.ToLower(w)} { + vp := &ast.VariantPath{ + Root: &ast.Identifier{Name: "src"}, + Segments: []ast.VariantPathSegment{{Name: word}, {Name: word}}, + } + sel := &ast.SelectStatement{ + Columns: []ast.Expression{vp}, + From: []ast.TableReference{{Name: "t"}}, + } + tree := &ast.AST{Statements: []ast.Statement{sel}} + outs := []string{tree.SQL(), + formatter.FormatAST(tree, ast.CompactStyle()), + formatter.FormatAST(tree, ast.ReadableStyle())} + for _, out := range outs { + tree2, err := gosqlx.ParseWithDialect(out, keywords.DialectSnowflake) + if err != nil { + t.Errorf("word %q: re-parse of %q: %v", word, out, err) + continue + } + if names := variantNames(t, tree2); len(names) != 2 || names[0] != word || names[1] != word { + t.Errorf("word %q: re-parsed names = %q from %q", word, names, out) + } + ast.ReleaseAST(tree2) + } + } + } +} + +// TestVariantPath_UnaryRoot_782 checks that a leading unary operator is not +// rewritten to (-src):a, which binds differently in Snowflake, and that the +// text and AST shape are stable. +func TestVariantPath_UnaryRoot_782(t *testing.T) { + for _, in := range []string{ + `SELECT -src:a FROM t`, + `SELECT * FROM t WHERE -src:a > 0`, + } { + first := variantSQL(t, in) + if first != in { + t.Errorf("SQL() = %q, want %q", first, in) + } + if strings.Contains(first, "(-src)") { + t.Errorf("unary root was parenthesized: %q", first) + } + if second := variantSQL(t, first); second != first { + t.Errorf("unstable: %q -> %q", first, second) + } + } + tree, err := gosqlx.ParseWithDialect(`SELECT -src:a FROM t`, keywords.DialectSnowflake) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement, got %T", tree.Statements[0]) + } + vp, ok := sel.Columns[0].(*ast.VariantPath) + if !ok { + t.Fatalf("expected VariantPath, got %T", sel.Columns[0]) + } + if _, ok := vp.Root.(*ast.UnaryExpression); !ok { + t.Fatalf("expected UnaryExpression root, got %T", vp.Root) + } +} diff --git a/pkg/sql/ast/sql_variant_path.go b/pkg/sql/ast/sql_variant_path.go index 92e7a012..bd2ee654 100644 --- a/pkg/sql/ast/sql_variant_path.go +++ b/pkg/sql/ast/sql_variant_path.go @@ -39,9 +39,12 @@ func (v *VariantPath) writeSQL(buf *bytes.Buffer) { return } // Operator-like roots are parenthesized so the path binds to the whole - // expression rather than only its last operand. + // expression rather than only its last operand. UnaryExpression is + // deliberately not wrapped: the parser currently attaches the path to the + // already-unary operand (-src:a parses as Root=-src), and wrapping would + // turn Snowflake's -(src:a) into (-src):a, which means something else. switch v.Root.(type) { - case *BinaryExpression, *UnaryExpression, *CastExpression, + case *BinaryExpression, *CastExpression, *CaseExpression, *BetweenExpression, *InExpression: buf.WriteByte('(') writeExpr(buf, v.Root) @@ -76,9 +79,21 @@ func (v *VariantPath) writeSQL(buf *bytes.Buffer) { // case-sensitive whether or not they are quoted, so quoting is always safe. func writeVariantFieldName(buf *bytes.Buffer, name string) { if name != "" && !nameNeedsQuoting(name, false) && - !strings.EqualFold(name, "ANY") && !strings.EqualFold(name, "SOME") { + !strings.EqualFold(name, "ANY") && !strings.EqualFold(name, "SOME") && + !variantQuotedWords[strings.ToUpper(name)] { buf.WriteString(name) return } writeQuotedIdentifier(buf, name) } + +// variantQuotedWords lists words that the tokenizer gives their own token type +// (neither identifier nor generic keyword) but that are absent from +// reservedNameWords. The VARIANT path parser rejects them bare, so they are +// always quoted. A brute-force test over every keyword table guards this list. +var variantQuotedWords = map[string]bool{ + "URL": true, "OWNER": true, "POLICY": true, "VALID": true, "UNTIL": true, + "RESET": true, "MEMBER": true, "CONNECTOR": true, "AUTO_INCREMENT": true, + "DCPROPERTIES": true, "NOCREATEDB": true, "NOCREATEROLE": true, + "NOLOGIN": true, "NOSUPERUSER": true, +}