Skip to content

Commit a7dadb1

Browse files
authored
Merge branch 'main' into feat/snowflake-variant-path-483
2 parents 01ddf15 + 4421fe3 commit a7dadb1

File tree

4 files changed

+229
-2
lines changed

4 files changed

+229
-2
lines changed

pkg/sql/ast/ast.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ type TableReference struct {
233233
// IDENTIFIER('t'), or PostgreSQL unnest(array_col). When set, Name
234234
// holds the function name and TableFunc carries the call itself.
235235
TableFunc *FunctionCall
236+
// TimeTravel is the Snowflake time-travel clause applied to this table
237+
// reference: AT / BEFORE (TIMESTAMP|OFFSET|STATEMENT => expr) or
238+
// CHANGES (INFORMATION => DEFAULT|APPEND_ONLY).
239+
TimeTravel *TimeTravelClause
236240
// ForSystemTime is the MariaDB temporal table clause (10.3.4+).
237241
// Example: SELECT * FROM t FOR SYSTEM_TIME AS OF '2024-01-01'
238242
ForSystemTime *ForSystemTimeClause // MariaDB temporal query
@@ -262,6 +266,9 @@ func (t TableReference) Children() []Node {
262266
if t.TableFunc != nil {
263267
nodes = append(nodes, t.TableFunc)
264268
}
269+
if t.TimeTravel != nil {
270+
nodes = append(nodes, t.TimeTravel)
271+
}
265272
if t.Pivot != nil {
266273
nodes = append(nodes, t.Pivot)
267274
}
@@ -2059,6 +2066,41 @@ func (c ForSystemTimeClause) Children() []Node {
20592066
return nodes
20602067
}
20612068

2069+
// TimeTravelClause represents the Snowflake time-travel / change-tracking
2070+
// modifier on a table reference:
2071+
//
2072+
// SELECT ... FROM t AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)
2073+
// SELECT ... FROM t BEFORE (STATEMENT => '...uuid...')
2074+
// SELECT ... FROM t CHANGES (INFORMATION => DEFAULT) AT (...)
2075+
//
2076+
// Kind is one of "AT", "BEFORE", "CHANGES". Named holds the
2077+
// `name => expr` arguments keyed by upper-cased name (e.g. TIMESTAMP,
2078+
// OFFSET, STATEMENT, INFORMATION). Multiple clauses may chain (CHANGES
2079+
// plus AT); extra clauses are appended to Chained.
2080+
type TimeTravelClause struct {
2081+
Kind string // "AT" | "BEFORE" | "CHANGES"
2082+
Named map[string]Expression
2083+
Chained []*TimeTravelClause
2084+
Pos models.Location
2085+
}
2086+
2087+
func (c *TimeTravelClause) expressionNode() {}
2088+
func (c TimeTravelClause) TokenLiteral() string { return c.Kind }
2089+
func (c TimeTravelClause) Children() []Node {
2090+
var nodes []Node
2091+
for _, v := range c.Named {
2092+
if v != nil {
2093+
nodes = append(nodes, v)
2094+
}
2095+
}
2096+
for _, ch := range c.Chained {
2097+
if ch != nil {
2098+
nodes = append(nodes, ch)
2099+
}
2100+
}
2101+
return nodes
2102+
}
2103+
20622104
// PivotClause represents the SQL Server / Oracle PIVOT operator for row-to-column
20632105
// transformation in a FROM clause.
20642106
//

pkg/sql/parser/pivot.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,99 @@ func (p *Parser) supportsTableFunction() bool {
278278
}
279279
return false
280280
}
281+
282+
// parseSnowflakeTimeTravel parses the Snowflake time-travel / change-tracking
283+
// modifier attached to a table reference. The current token must be one of
284+
// AT / BEFORE / CHANGES. Returns the head clause with any additional clauses
285+
// appended to Chained (e.g. CHANGES (...) AT (...)).
286+
func (p *Parser) parseSnowflakeTimeTravel() (*ast.TimeTravelClause, error) {
287+
head, err := p.parseOneTimeTravelClause()
288+
if err != nil {
289+
return nil, err
290+
}
291+
// Allow additional clauses: CHANGES (...) AT (...) is legal.
292+
for p.isSnowflakeTimeTravelStart() {
293+
next, err := p.parseOneTimeTravelClause()
294+
if err != nil {
295+
return nil, err
296+
}
297+
head.Chained = append(head.Chained, next)
298+
}
299+
return head, nil
300+
}
301+
302+
func (p *Parser) parseOneTimeTravelClause() (*ast.TimeTravelClause, error) {
303+
pos := p.currentLocation()
304+
kind := strings.ToUpper(p.currentToken.Token.Value)
305+
p.advance() // Consume AT / BEFORE / CHANGES
306+
if !p.isType(models.TokenTypeLParen) {
307+
return nil, p.expectedError("( after " + kind)
308+
}
309+
p.advance() // Consume (
310+
311+
clause := &ast.TimeTravelClause{
312+
Kind: kind,
313+
Named: map[string]ast.Expression{},
314+
Pos: pos,
315+
}
316+
317+
// Parse comma-separated named arguments: name => expr [, name => expr]...
318+
// Snowflake uses TIMESTAMP, OFFSET, STATEMENT, INFORMATION as argument
319+
// names; these tokenize as dedicated keyword types, not identifiers.
320+
// Accept any non-punctuation token with a non-empty value as the name.
321+
for {
322+
argName := strings.ToUpper(p.currentToken.Token.Value)
323+
if argName == "" || p.isType(models.TokenTypeRParen) ||
324+
p.isType(models.TokenTypeComma) || p.isType(models.TokenTypeLParen) {
325+
return nil, p.expectedError("argument name in " + kind)
326+
}
327+
p.advance()
328+
if p.currentToken.Token.Type != models.TokenTypeRArrow {
329+
return nil, p.expectedError("=> after " + argName)
330+
}
331+
p.advance() // =>
332+
// Values are typically literal expressions, but may also be bare
333+
// keywords like DEFAULT or APPEND_ONLY for CHANGES (INFORMATION => …).
334+
var value ast.Expression
335+
if v, err := p.parseExpression(); err == nil {
336+
value = v
337+
} else if p.currentToken.Token.Value != "" &&
338+
!p.isType(models.TokenTypeRParen) && !p.isType(models.TokenTypeComma) {
339+
value = &ast.Identifier{Name: p.currentToken.Token.Value}
340+
p.advance()
341+
} else {
342+
return nil, err
343+
}
344+
clause.Named[argName] = value
345+
if p.isType(models.TokenTypeComma) {
346+
p.advance()
347+
continue
348+
}
349+
break
350+
}
351+
352+
if !p.isType(models.TokenTypeRParen) {
353+
return nil, p.expectedError(")")
354+
}
355+
p.advance() // Consume )
356+
return clause, nil
357+
}
358+
359+
// isSnowflakeTimeTravelStart returns true when the current token begins an
360+
// AT / BEFORE / CHANGES time-travel clause in the Snowflake dialect.
361+
func (p *Parser) isSnowflakeTimeTravelStart() bool {
362+
if p.dialect != string(keywords.DialectSnowflake) {
363+
return false
364+
}
365+
// BEFORE / CHANGES: plain identifier or keyword
366+
val := strings.ToUpper(p.currentToken.Token.Value)
367+
if val == "BEFORE" || val == "CHANGES" {
368+
// Must be followed by '(' to disambiguate from other uses.
369+
return p.peekToken().Token.Type == models.TokenTypeLParen
370+
}
371+
// AT: either TokenTypeAt (@) or an identifier-token "AT" followed by '('.
372+
if val == "AT" && p.peekToken().Token.Type == models.TokenTypeLParen {
373+
return true
374+
}
375+
return false
376+
}

pkg/sql/parser/select_subquery.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,26 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) {
9494
}
9595
tableRef.TableFunc = funcCall
9696
}
97+
98+
// Snowflake time-travel / change-tracking clauses:
99+
// AT (TIMESTAMP => ...)
100+
// BEFORE (STATEMENT => ...)
101+
// CHANGES (INFORMATION => DEFAULT) AT (...)
102+
if p.isSnowflakeTimeTravelStart() {
103+
tt, err := p.parseSnowflakeTimeTravel()
104+
if err != nil {
105+
return tableRef, err
106+
}
107+
tableRef.TimeTravel = tt
108+
}
97109
}
98110

99111
// Check for table alias (required for derived tables, optional for regular tables).
100112
// Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias.
101113
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
102114
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
103115
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
104-
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
116+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() {
105117
if p.isType(models.TokenTypeAs) {
106118
p.advance() // Consume AS
107119
if !p.isIdentifier() {
@@ -215,7 +227,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error
215227
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
216228
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
217229
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
218-
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
230+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() {
219231
if p.isType(models.TokenTypeAs) {
220232
p.advance()
221233
if !p.isIdentifier() {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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/ast"
12+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
13+
)
14+
15+
// TestSnowflakeTimeTravel verifies AT / BEFORE / CHANGES clauses on a table
16+
// reference in the Snowflake dialect. Regression for #483.
17+
func TestSnowflakeTimeTravel(t *testing.T) {
18+
queries := map[string]string{
19+
"at_timestamp_cast": `SELECT * FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`,
20+
"at_offset": `SELECT * FROM users AT (OFFSET => -300)`,
21+
"at_statement": `SELECT * FROM users AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')`,
22+
"before_statement": `SELECT * FROM users BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')`,
23+
"changes_default": `SELECT * FROM users CHANGES (INFORMATION => DEFAULT)`,
24+
"changes_and_at": `SELECT * FROM users CHANGES (INFORMATION => DEFAULT) AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`,
25+
"at_with_alias": `SELECT t.id FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP) t WHERE t.id = 1`,
26+
}
27+
for name, q := range queries {
28+
q := q
29+
t.Run(name, func(t *testing.T) {
30+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
31+
t.Fatalf("parse failed: %v", err)
32+
}
33+
})
34+
}
35+
}
36+
37+
// TestSnowflakeTimeTravelASTShape verifies the TimeTravel clause is populated
38+
// on the TableReference and reachable via Children().
39+
func TestSnowflakeTimeTravelASTShape(t *testing.T) {
40+
q := `SELECT * FROM users AT (TIMESTAMP => '2024-01-01'::TIMESTAMP)`
41+
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake)
42+
if err != nil {
43+
t.Fatalf("parse failed: %v", err)
44+
}
45+
ss, ok := tree.Statements[0].(*ast.SelectStatement)
46+
if !ok {
47+
t.Fatalf("want *ast.SelectStatement, got %T", tree.Statements[0])
48+
}
49+
if len(ss.Joins) > 0 || ss.TableName == "" && len(ss.Joins) == 0 {
50+
// The parser may place the table ref in different shapes; walk the
51+
// tree to find the TimeTravelClause instead.
52+
}
53+
var found bool
54+
var visit func(n ast.Node)
55+
visit = func(n ast.Node) {
56+
if n == nil || found {
57+
return
58+
}
59+
if tt, ok := n.(*ast.TimeTravelClause); ok {
60+
if tt.Kind != "AT" {
61+
t.Fatalf("Kind: want AT, got %q", tt.Kind)
62+
}
63+
if _, ok := tt.Named["TIMESTAMP"]; !ok {
64+
t.Fatalf("Named[TIMESTAMP] missing; have: %v", tt.Named)
65+
}
66+
found = true
67+
return
68+
}
69+
for _, c := range n.Children() {
70+
visit(c)
71+
}
72+
}
73+
visit(ss)
74+
if !found {
75+
t.Fatal("TimeTravelClause not found in AST")
76+
}
77+
}

0 commit comments

Comments
 (0)