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
4 changes: 4 additions & 0 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ type SelectStatement struct {
Where Expression
GroupBy []Expression
Having Expression
Qualify Expression // Snowflake / BigQuery QUALIFY clause (filters after window functions)
// StartWith is the optional seed condition for CONNECT BY (MariaDB 10.2+).
// Example: START WITH parent_id IS NULL
StartWith Expression // MariaDB hierarchical query seed
Expand Down Expand Up @@ -527,6 +528,9 @@ func (s SelectStatement) Children() []Node {
if s.Having != nil {
children = append(children, s.Having)
}
if s.Qualify != nil {
children = append(children, s.Qualify)
}
for _, window := range s.Windows {
window := window // G601: Create local copy to avoid memory aliasing
children = append(children, &window)
Expand Down
12 changes: 12 additions & 0 deletions pkg/sql/parser/pivot.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ func (p *Parser) pivotDialectAllowed() bool {
}

// isPivotKeyword returns true if the current token is the contextual PIVOT
// isQualifyKeyword returns true if the current token is the Snowflake /
// BigQuery QUALIFY clause keyword. QUALIFY tokenizes as an identifier, so
// detect by value and gate by dialect to avoid consuming a legitimate
// table alias named "qualify" in other dialects.
func (p *Parser) isQualifyKeyword() bool {
if p.dialect != string(keywords.DialectSnowflake) &&
p.dialect != string(keywords.DialectBigQuery) {
return false
}
return strings.EqualFold(p.currentToken.Token.Value, "QUALIFY")
}

// keyword in a dialect that supports it. PIVOT is non-reserved, so it may
// arrive as either an identifier or a keyword token.
func (p *Parser) isPivotKeyword() bool {
Expand Down
14 changes: 14 additions & 0 deletions pkg/sql/parser/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) {
return nil, err
}

// Snowflake / BigQuery QUALIFY: filters rows after window functions.
// Appears between HAVING and ORDER BY. Tokenizes as identifier or
// keyword depending on dialect tables; detect by value.
if (p.dialect == string(keywords.DialectSnowflake) ||
p.dialect == string(keywords.DialectBigQuery)) &&
strings.EqualFold(p.currentToken.Token.Value, "QUALIFY") {
p.advance() // Consume QUALIFY
qexpr, qerr := p.parseExpression()
if qerr != nil {
return nil, qerr
}
selectStmt.Qualify = qexpr
}

// Oracle/MariaDB: START WITH ... CONNECT BY hierarchical queries
if p.isMariaDB() || p.dialect == string(keywords.DialectOracle) {
if strings.EqualFold(p.currentToken.Token.Value, "START") {
Expand Down
4 changes: 2 additions & 2 deletions pkg/sql/parser/select_subquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (p *Parser) parseFromTableReference() (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() {
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() {
if p.isType(models.TokenTypeAs) {
p.advance() // Consume AS
if !p.isIdentifier() {
Expand Down Expand Up @@ -203,7 +203,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() {
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() {
if p.isType(models.TokenTypeAs) {
p.advance()
if !p.isIdentifier() {
Expand Down
58 changes: 58 additions & 0 deletions pkg/sql/parser/snowflake_qualify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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"
)

// TestSnowflakeQualify verifies the Snowflake QUALIFY clause parses between
// HAVING and ORDER BY. Regression for #483.
func TestSnowflakeQualify(t *testing.T) {
queries := map[string]string{
"simple": `SELECT id, name, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM users
QUALIFY rn = 1`,

"with_where": `SELECT id, name
FROM users
WHERE active = true
QUALIFY ROW_NUMBER() OVER (PARTITION BY dept ORDER BY id) = 1`,

"with_group_having": `SELECT dept, COUNT(*) AS n
FROM users
GROUP BY dept
HAVING COUNT(*) > 5
QUALIFY RANK() OVER (ORDER BY n DESC) <= 10`,

"with_order_by": `SELECT id, RANK() OVER (ORDER BY score) AS r
FROM leaderboard
QUALIFY r <= 10
ORDER BY id`,
}
for name, q := range queries {
q := q
t.Run(name, func(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(tree.Statements) == 0 {
t.Fatal("no statements parsed")
}
ss, ok := tree.Statements[0].(*ast.SelectStatement)
if !ok {
t.Fatalf("expected SelectStatement, got %T", tree.Statements[0])
}
if ss.Qualify == nil {
t.Fatal("Qualify clause missing from AST")
}
})
}
}
Loading