Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ type TableReference struct {
// IDENTIFIER('t'), or PostgreSQL unnest(array_col). When set, Name
// holds the function name and TableFunc carries the call itself.
TableFunc *FunctionCall
// TimeTravel is the Snowflake time-travel clause applied to this table
// reference: AT / BEFORE (TIMESTAMP|OFFSET|STATEMENT => expr) or
// CHANGES (INFORMATION => DEFAULT|APPEND_ONLY).
TimeTravel *TimeTravelClause
// ForSystemTime is the MariaDB temporal table clause (10.3.4+).
// Example: SELECT * FROM t FOR SYSTEM_TIME AS OF '2024-01-01'
ForSystemTime *ForSystemTimeClause // MariaDB temporal query
Expand Down Expand Up @@ -262,6 +266,9 @@ func (t TableReference) Children() []Node {
if t.TableFunc != nil {
nodes = append(nodes, t.TableFunc)
}
if t.TimeTravel != nil {
nodes = append(nodes, t.TimeTravel)
}
if t.Pivot != nil {
nodes = append(nodes, t.Pivot)
}
Expand Down Expand Up @@ -2026,6 +2033,41 @@ func (c ForSystemTimeClause) Children() []Node {
return nodes
}

// TimeTravelClause represents the Snowflake time-travel / change-tracking
// modifier on a table reference:
//
// SELECT ... FROM t AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)
// SELECT ... FROM t BEFORE (STATEMENT => '...uuid...')
// SELECT ... FROM t CHANGES (INFORMATION => DEFAULT) AT (...)
//
// Kind is one of "AT", "BEFORE", "CHANGES". Named holds the
// `name => expr` arguments keyed by upper-cased name (e.g. TIMESTAMP,
// OFFSET, STATEMENT, INFORMATION). Multiple clauses may chain (CHANGES
// plus AT); extra clauses are appended to Chained.
type TimeTravelClause struct {
Kind string // "AT" | "BEFORE" | "CHANGES"
Named map[string]Expression
Chained []*TimeTravelClause
Pos models.Location
}

func (c *TimeTravelClause) expressionNode() {}
func (c TimeTravelClause) TokenLiteral() string { return c.Kind }
func (c TimeTravelClause) Children() []Node {
var nodes []Node
for _, v := range c.Named {
if v != nil {
nodes = append(nodes, v)
}
}
for _, ch := range c.Chained {
if ch != nil {
nodes = append(nodes, ch)
}
}
return nodes
}

// PivotClause represents the SQL Server / Oracle PIVOT operator for row-to-column
// transformation in a FROM clause.
//
Expand Down
96 changes: 96 additions & 0 deletions pkg/sql/parser/pivot.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,99 @@ func (p *Parser) supportsTableFunction() bool {
}
return false
}

// parseSnowflakeTimeTravel parses the Snowflake time-travel / change-tracking
// modifier attached to a table reference. The current token must be one of
// AT / BEFORE / CHANGES. Returns the head clause with any additional clauses
// appended to Chained (e.g. CHANGES (...) AT (...)).
func (p *Parser) parseSnowflakeTimeTravel() (*ast.TimeTravelClause, error) {
head, err := p.parseOneTimeTravelClause()
if err != nil {
return nil, err
}
// Allow additional clauses: CHANGES (...) AT (...) is legal.
for p.isSnowflakeTimeTravelStart() {
next, err := p.parseOneTimeTravelClause()
if err != nil {
return nil, err
}
head.Chained = append(head.Chained, next)
}
return head, nil
}

func (p *Parser) parseOneTimeTravelClause() (*ast.TimeTravelClause, error) {
pos := p.currentLocation()
kind := strings.ToUpper(p.currentToken.Token.Value)
p.advance() // Consume AT / BEFORE / CHANGES
if !p.isType(models.TokenTypeLParen) {
return nil, p.expectedError("( after " + kind)
}
p.advance() // Consume (

clause := &ast.TimeTravelClause{
Kind: kind,
Named: map[string]ast.Expression{},
Pos: pos,
}

// Parse comma-separated named arguments: name => expr [, name => expr]...
// Snowflake uses TIMESTAMP, OFFSET, STATEMENT, INFORMATION as argument
// names; these tokenize as dedicated keyword types, not identifiers.
// Accept any non-punctuation token with a non-empty value as the name.
for {
argName := strings.ToUpper(p.currentToken.Token.Value)
if argName == "" || p.isType(models.TokenTypeRParen) ||
p.isType(models.TokenTypeComma) || p.isType(models.TokenTypeLParen) {
return nil, p.expectedError("argument name in " + kind)
}
p.advance()
if p.currentToken.Token.Type != models.TokenTypeRArrow {
return nil, p.expectedError("=> after " + argName)
}
p.advance() // =>
// Values are typically literal expressions, but may also be bare
// keywords like DEFAULT or APPEND_ONLY for CHANGES (INFORMATION => …).
var value ast.Expression
if v, err := p.parseExpression(); err == nil {
value = v
} else if p.currentToken.Token.Value != "" &&
!p.isType(models.TokenTypeRParen) && !p.isType(models.TokenTypeComma) {
value = &ast.Identifier{Name: p.currentToken.Token.Value}
p.advance()
} else {
return nil, err
}
clause.Named[argName] = value
if p.isType(models.TokenTypeComma) {
p.advance()
continue
}
break
}

if !p.isType(models.TokenTypeRParen) {
return nil, p.expectedError(")")
}
p.advance() // Consume )
return clause, nil
}

// isSnowflakeTimeTravelStart returns true when the current token begins an
// AT / BEFORE / CHANGES time-travel clause in the Snowflake dialect.
func (p *Parser) isSnowflakeTimeTravelStart() bool {
if p.dialect != string(keywords.DialectSnowflake) {
return false
}
// BEFORE / CHANGES: plain identifier or keyword
val := strings.ToUpper(p.currentToken.Token.Value)
if val == "BEFORE" || val == "CHANGES" {
// Must be followed by '(' to disambiguate from other uses.
return p.peekToken().Token.Type == models.TokenTypeLParen
}
// AT: either TokenTypeAt (@) or an identifier-token "AT" followed by '('.
if val == "AT" && p.peekToken().Token.Type == models.TokenTypeLParen {
return true
}
return false
}
16 changes: 14 additions & 2 deletions pkg/sql/parser/select_subquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,26 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) {
}
tableRef.TableFunc = funcCall
}

// Snowflake time-travel / change-tracking clauses:
// AT (TIMESTAMP => ...)
// BEFORE (STATEMENT => ...)
// CHANGES (INFORMATION => DEFAULT) AT (...)
if p.isSnowflakeTimeTravelStart() {
tt, err := p.parseSnowflakeTimeTravel()
if err != nil {
return tableRef, err
}
tableRef.TimeTravel = tt
}
}

// Check for table alias (required for derived tables, optional for regular tables).
// Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias.
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() {
if p.isType(models.TokenTypeAs) {
p.advance() // Consume AS
if !p.isIdentifier() {
Expand Down Expand Up @@ -215,7 +227,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() {
if p.isType(models.TokenTypeAs) {
p.advance()
if !p.isIdentifier() {
Expand Down
77 changes: 77 additions & 0 deletions pkg/sql/parser/snowflake_time_travel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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"
)

// TestSnowflakeTimeTravel verifies AT / BEFORE / CHANGES clauses on a table
// reference in the Snowflake dialect. Regression for #483.
func TestSnowflakeTimeTravel(t *testing.T) {
queries := map[string]string{
"at_timestamp_cast": `SELECT * FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`,
"at_offset": `SELECT * FROM users AT (OFFSET => -300)`,
"at_statement": `SELECT * FROM users AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')`,
"before_statement": `SELECT * FROM users BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')`,
"changes_default": `SELECT * FROM users CHANGES (INFORMATION => DEFAULT)`,
"changes_and_at": `SELECT * FROM users CHANGES (INFORMATION => DEFAULT) AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`,
"at_with_alias": `SELECT t.id FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP) t WHERE t.id = 1`,
}
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)
}
})
}
}

// TestSnowflakeTimeTravelASTShape verifies the TimeTravel clause is populated
// on the TableReference and reachable via Children().
func TestSnowflakeTimeTravelASTShape(t *testing.T) {
q := `SELECT * FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
ss, ok := tree.Statements[0].(*ast.SelectStatement)
if !ok {
t.Fatalf("want *ast.SelectStatement, got %T", tree.Statements[0])
}
if len(ss.Joins) > 0 || ss.TableName == "" && len(ss.Joins) == 0 {
// The parser may place the table ref in different shapes; walk the
// tree to find the TimeTravelClause instead.
}
var found bool
var visit func(n ast.Node)
visit = func(n ast.Node) {
if n == nil || found {
return
}
if tt, ok := n.(*ast.TimeTravelClause); ok {
if tt.Kind != "AT" {
t.Fatalf("Kind: want AT, got %q", tt.Kind)
}
if _, ok := tt.Named["TIMESTAMP"]; !ok {
t.Fatalf("Named[TIMESTAMP] missing; have: %v", tt.Named)
}
found = true
return
}
for _, c := range n.Children() {
visit(c)
}
}
visit(ss)
if !found {
t.Fatal("TimeTravelClause not found in AST")
}
}
Loading