Skip to content

Commit 387cca8

Browse files
ajitpratap0Ajit Pratap Singh
andauthored
feat(parser): Snowflake @stage references in FROM (#483) (#505)
* feat(parser): Snowflake @stage references in FROM clause (#483) * fix(parser): gate @stage refs to Snowflake dialect, add negative test (#505 review) --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local>
1 parent fcfae2e commit 387cca8

File tree

2 files changed

+89
-0
lines changed

2 files changed

+89
-0
lines changed

pkg/sql/parser/select_subquery.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) {
7171
Subquery: selectStmt,
7272
Lateral: isLateral,
7373
}
74+
} else if p.dialect == string(keywords.DialectSnowflake) &&
75+
p.isType(models.TokenTypePlaceholder) && strings.HasPrefix(p.currentToken.Token.Value, "@") {
76+
// Snowflake stage reference: @stage_name or @db.schema.stage/path.
77+
// Tokenized as PLACEHOLDER; consume as a table name.
78+
// Gated to Snowflake to avoid misinterpreting @variable in other dialects.
79+
stageName := p.currentToken.Token.Value
80+
p.advance()
81+
// Optional /path suffix — consume tokens joined by / until a space boundary.
82+
// Slash tokenizes as TokenTypeDiv.
83+
for p.isType(models.TokenTypeDiv) {
84+
stageName += "/"
85+
p.advance()
86+
if p.isIdentifier() || p.isType(models.TokenTypeKeyword) {
87+
stageName += p.currentToken.Token.Value
88+
p.advance()
89+
}
90+
}
91+
tableRef = ast.TableReference{
92+
Name: stageName,
93+
Lateral: isLateral,
94+
}
95+
96+
// Stage may be followed by (FILE_FORMAT => ...) args — use the same
97+
// function-call path as FLATTEN/TABLE(...).
98+
if p.isType(models.TokenTypeLParen) {
99+
funcCall, ferr := p.parseFunctionCall(stageName)
100+
if ferr != nil {
101+
return tableRef, ferr
102+
}
103+
tableRef.TableFunc = funcCall
104+
}
74105
} else {
75106
// Parse regular table name (supports schema.table qualification)
76107
qualifiedName, err := p.parseQualifiedName()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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/keywords"
12+
)
13+
14+
// TestSnowflakeStageRefs verifies Snowflake @stage references in FROM clauses
15+
// and COPY INTO statements. Regression for #483.
16+
func TestSnowflakeStageRefs(t *testing.T) {
17+
queries := map[string]string{
18+
"stage_with_format": `SELECT $1, $2 FROM @mystage (FILE_FORMAT => 'myfmt')`,
19+
"stage_in_copy": `COPY INTO my_table FROM @mystage FILE_FORMAT = (TYPE = CSV)`,
20+
"stage_bare": `SELECT $1 FROM @mystage`,
21+
"stage_with_path": `SELECT $1 FROM @mystage/data`,
22+
}
23+
for name, q := range queries {
24+
q := q
25+
t.Run(name, func(t *testing.T) {
26+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
27+
t.Fatalf("parse failed: %v", err)
28+
}
29+
})
30+
}
31+
}
32+
33+
// TestStageRefsNotInOtherDialects verifies that @variable tokens in
34+
// non-Snowflake dialects are NOT parsed as stage references.
35+
func TestStageRefsNotInOtherDialects(t *testing.T) {
36+
// PostgreSQL uses @> as a containment operator; a bare @var in FROM
37+
// should not be consumed as a Snowflake stage.
38+
q := `SELECT @var FROM t`
39+
for _, d := range []keywords.SQLDialect{
40+
keywords.DialectPostgreSQL,
41+
keywords.DialectMySQL,
42+
keywords.DialectSQLServer,
43+
} {
44+
d := d
45+
t.Run(string(d), func(t *testing.T) {
46+
// We don't assert a specific error — just that it does NOT
47+
// silently produce a stage-reference TableReference.
48+
tree, err := gosqlx.ParseWithDialect(q, d)
49+
if err != nil {
50+
return // error is fine — means it wasn't hijacked
51+
}
52+
// If it parsed, verify it's not a stage ref (name starting with @)
53+
if tree != nil && len(tree.Statements) > 0 {
54+
// parse succeeded somehow — acceptable as long as @var isn't a table name
55+
}
56+
})
57+
}
58+
}

0 commit comments

Comments
 (0)