Skip to content

Commit 6318d7d

Browse files
ajitpratap0Ajit Pratap Singh
andauthored
feat(parser): QUALIFY clause for Snowflake/BigQuery (#483) (#490)
Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local>
1 parent b263a1d commit 6318d7d

File tree

5 files changed

+90
-2
lines changed

5 files changed

+90
-2
lines changed

pkg/sql/ast/ast.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ type SelectStatement struct {
414414
Where Expression
415415
GroupBy []Expression
416416
Having Expression
417+
Qualify Expression // Snowflake / BigQuery QUALIFY clause (filters after window functions)
417418
// StartWith is the optional seed condition for CONNECT BY (MariaDB 10.2+).
418419
// Example: START WITH parent_id IS NULL
419420
StartWith Expression // MariaDB hierarchical query seed
@@ -527,6 +528,9 @@ func (s SelectStatement) Children() []Node {
527528
if s.Having != nil {
528529
children = append(children, s.Having)
529530
}
531+
if s.Qualify != nil {
532+
children = append(children, s.Qualify)
533+
}
530534
for _, window := range s.Windows {
531535
window := window // G601: Create local copy to avoid memory aliasing
532536
children = append(children, &window)

pkg/sql/parser/pivot.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ func (p *Parser) pivotDialectAllowed() bool {
7575
}
7676

7777
// isPivotKeyword returns true if the current token is the contextual PIVOT
78+
// isQualifyKeyword returns true if the current token is the Snowflake /
79+
// BigQuery QUALIFY clause keyword. QUALIFY tokenizes as an identifier, so
80+
// detect by value and gate by dialect to avoid consuming a legitimate
81+
// table alias named "qualify" in other dialects.
82+
func (p *Parser) isQualifyKeyword() bool {
83+
if p.dialect != string(keywords.DialectSnowflake) &&
84+
p.dialect != string(keywords.DialectBigQuery) {
85+
return false
86+
}
87+
return strings.EqualFold(p.currentToken.Token.Value, "QUALIFY")
88+
}
89+
7890
// keyword in a dialect that supports it. PIVOT is non-reserved, so it may
7991
// arrive as either an identifier or a keyword token.
8092
func (p *Parser) isPivotKeyword() bool {

pkg/sql/parser/select.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) {
116116
return nil, err
117117
}
118118

119+
// Snowflake / BigQuery QUALIFY: filters rows after window functions.
120+
// Appears between HAVING and ORDER BY. Tokenizes as identifier or
121+
// keyword depending on dialect tables; detect by value.
122+
if (p.dialect == string(keywords.DialectSnowflake) ||
123+
p.dialect == string(keywords.DialectBigQuery)) &&
124+
strings.EqualFold(p.currentToken.Token.Value, "QUALIFY") {
125+
p.advance() // Consume QUALIFY
126+
qexpr, qerr := p.parseExpression()
127+
if qerr != nil {
128+
return nil, qerr
129+
}
130+
selectStmt.Qualify = qexpr
131+
}
132+
119133
// Oracle/MariaDB: START WITH ... CONNECT BY hierarchical queries
120134
if p.isMariaDB() || p.dialect == string(keywords.DialectOracle) {
121135
if strings.EqualFold(p.currentToken.Token.Value, "START") {

pkg/sql/parser/select_subquery.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) {
8989
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
9090
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
9191
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
92-
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() {
92+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() {
9393
if p.isType(models.TokenTypeAs) {
9494
p.advance() // Consume AS
9595
if !p.isIdentifier() {
@@ -203,7 +203,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error
203203
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
204204
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
205205
// keywords in SQL Server/Oracle and must reach the pivot-clause parser below.
206-
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() {
206+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() {
207207
if p.isType(models.TokenTypeAs) {
208208
p.advance()
209209
if !p.isIdentifier() {
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/ast"
12+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
13+
)
14+
15+
// TestSnowflakeQualify verifies the Snowflake QUALIFY clause parses between
16+
// HAVING and ORDER BY. Regression for #483.
17+
func TestSnowflakeQualify(t *testing.T) {
18+
queries := map[string]string{
19+
"simple": `SELECT id, name, ROW_NUMBER() OVER (ORDER BY id) AS rn
20+
FROM users
21+
QUALIFY rn = 1`,
22+
23+
"with_where": `SELECT id, name
24+
FROM users
25+
WHERE active = true
26+
QUALIFY ROW_NUMBER() OVER (PARTITION BY dept ORDER BY id) = 1`,
27+
28+
"with_group_having": `SELECT dept, COUNT(*) AS n
29+
FROM users
30+
GROUP BY dept
31+
HAVING COUNT(*) > 5
32+
QUALIFY RANK() OVER (ORDER BY n DESC) <= 10`,
33+
34+
"with_order_by": `SELECT id, RANK() OVER (ORDER BY score) AS r
35+
FROM leaderboard
36+
QUALIFY r <= 10
37+
ORDER BY id`,
38+
}
39+
for name, q := range queries {
40+
q := q
41+
t.Run(name, func(t *testing.T) {
42+
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake)
43+
if err != nil {
44+
t.Fatalf("parse failed: %v", err)
45+
}
46+
if len(tree.Statements) == 0 {
47+
t.Fatal("no statements parsed")
48+
}
49+
ss, ok := tree.Statements[0].(*ast.SelectStatement)
50+
if !ok {
51+
t.Fatalf("expected SelectStatement, got %T", tree.Statements[0])
52+
}
53+
if ss.Qualify == nil {
54+
t.Fatal("Qualify clause missing from AST")
55+
}
56+
})
57+
}
58+
}

0 commit comments

Comments
 (0)