diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 2dd19e62..7a78a42d 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -707,6 +707,15 @@ func (p *Parser) parseStatement() (ast.Statement, error) { p.advance() return p.parsePragmaStatement() } + // Snowflake stage operations may arrive as keyword tokens depending on + // the active keyword table (LIST, COPY, etc. can be registered). + if p.dialect == string(keywords.DialectSnowflake) { + upper := strings.ToUpper(p.currentToken.Token.Value) + switch upper { + case "COPY", "PUT", "GET", "LIST", "REMOVE", "LS": + return p.parseSnowflakeStageStatement(upper) + } + } case models.TokenTypeIdentifier: // PRAGMA may be tokenized as IDENTIFIER when no dialect-specific keyword // set is active (e.g. when using the default PostgreSQL tokenizer dialect). @@ -721,6 +730,16 @@ func (p *Parser) parseStatement() (ast.Statement, error) { strings.EqualFold(p.currentToken.Token.Value, "USE") { return p.parseSnowflakeUseStatement() } + // Snowflake stage operations: COPY INTO, PUT, GET, LIST, REMOVE. + // All tokenize as identifiers; parse-only stubs that consume the + // rest of the statement body. + if p.dialect == string(keywords.DialectSnowflake) { + upper := strings.ToUpper(p.currentToken.Token.Value) + switch upper { + case "COPY", "PUT", "GET", "LIST", "REMOVE", "LS": + return p.parseSnowflakeStageStatement(upper) + } + } } return nil, p.expectedError("statement") } @@ -748,6 +767,47 @@ func (p *Parser) parseSnowflakeUseStatement() (ast.Statement, error) { return stmt, nil } +// parseSnowflakeStageStatement parses Snowflake stage operations as stubs: +// +// COPY INTO FROM [options] +// PUT file:// @ +// GET @ file:// +// LIST @ (or LS) +// REMOVE @/ +// +// The statement is consumed token-by-token (tracking balanced parens) until +// ';' or EOF and returned as a DescribeStatement placeholder tagged with the +// operation kind. No AST modeling yet; follow-up work. +func (p *Parser) parseSnowflakeStageStatement(kind string) (ast.Statement, error) { + p.advance() // Consume leading kind token + + // COPY INTO: consume the INTO keyword if present. + if kind == "COPY" && p.isType(models.TokenTypeInto) { + p.advance() + } + + // Consume the rest of the statement body. + depth := 0 + for { + t := p.currentToken.Token.Type + if t == models.TokenTypeEOF { + break + } + if t == models.TokenTypeSemicolon && depth == 0 { + break + } + if t == models.TokenTypeLParen { + depth++ + } else if t == models.TokenTypeRParen { + depth-- + } + p.advance() + } + stub := ast.GetDescribeStatement() + stub.TableName = kind + return stub, nil +} + // NewParser creates a new parser with optional configuration. func NewParser(opts ...ParserOption) *Parser { p := &Parser{} diff --git a/pkg/sql/parser/snowflake_stage_ops_test.go b/pkg/sql/parser/snowflake_stage_ops_test.go new file mode 100644 index 00000000..814a1b7a --- /dev/null +++ b/pkg/sql/parser/snowflake_stage_ops_test.go @@ -0,0 +1,42 @@ +// 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/keywords" +) + +// TestSnowflakeStageOps verifies Snowflake stage operations parse as stubs. +// Regression for #483. +func TestSnowflakeStageOps(t *testing.T) { + queries := map[string]string{ + "copy_into_table_with_format": `COPY INTO my_table FROM @my_stage FILE_FORMAT = (TYPE = CSV)`, + + "copy_into_named_format": `COPY INTO my_table FROM @my_stage/file.csv FILE_FORMAT = (FORMAT_NAME = my_csv) ON_ERROR = CONTINUE`, + + "copy_into_stage_from_table": `COPY INTO @my_stage FROM my_table FILE_FORMAT = (TYPE = PARQUET)`, + + "put_to_stage": `PUT file:///tmp/data.csv @my_stage`, + + "get_from_stage": `GET @my_stage file:///tmp/output/`, + + "list_stage": `LIST @my_stage`, + + "remove_from_stage": `REMOVE @my_stage/old_files`, + + "ls_alias": `LS @my_stage`, + } + 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) + } + }) + } +}