Skip to content

Commit d72b4d8

Browse files
ajitpratap0Ajit Pratap Singh
andauthored
feat(parser): MINUS as EXCEPT synonym in Snowflake/Oracle (#483) (#494)
Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local>
1 parent 132a13e commit d72b4d8

File tree

3 files changed

+89
-4
lines changed

3 files changed

+89
-4
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
// TestMinusAsExceptSynonym verifies MINUS is accepted as a set operator
16+
// (synonym for EXCEPT) in Snowflake and Oracle dialects, and that it is
17+
// normalized to "EXCEPT" on the AST. Regression for #483.
18+
func TestMinusAsExceptSynonym(t *testing.T) {
19+
dialects := []keywords.SQLDialect{
20+
keywords.DialectSnowflake,
21+
keywords.DialectOracle,
22+
}
23+
q := `SELECT id FROM a MINUS SELECT id FROM b`
24+
for _, d := range dialects {
25+
d := d
26+
t.Run(string(d), func(t *testing.T) {
27+
tree, err := gosqlx.ParseWithDialect(q, d)
28+
if err != nil {
29+
t.Fatalf("parse failed: %v", err)
30+
}
31+
if len(tree.Statements) != 1 {
32+
t.Fatalf("want 1 statement, got %d", len(tree.Statements))
33+
}
34+
so, ok := tree.Statements[0].(*ast.SetOperation)
35+
if !ok {
36+
t.Fatalf("want *ast.SetOperation, got %T", tree.Statements[0])
37+
}
38+
if so.Operator != "EXCEPT" {
39+
t.Fatalf("Operator: want %q, got %q", "EXCEPT", so.Operator)
40+
}
41+
})
42+
}
43+
}
44+
45+
// TestMinusNotSetOpInOtherDialects verifies that MINUS is still treated as
46+
// a table alias (not a set operator) in dialects that do not support it.
47+
// This protects against accidental hijacking in e.g. MySQL/PostgreSQL.
48+
func TestMinusNotSetOpInOtherDialects(t *testing.T) {
49+
q := `SELECT id FROM a MINUS SELECT id FROM b`
50+
// In dialects without MINUS-as-EXCEPT, the MINUS identifier is consumed
51+
// as a table alias ("a AS MINUS") and "SELECT ..." starts a new statement.
52+
// We expect either success with 2 statements, or at minimum no panic.
53+
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectPostgreSQL)
54+
if err != nil {
55+
return // error is acceptable; we only verify no hijacking
56+
}
57+
if len(tree.Statements) == 1 {
58+
if _, isSetOp := tree.Statements[0].(*ast.SetOperation); isSetOp {
59+
t.Fatal("PostgreSQL should not parse MINUS as a set operator")
60+
}
61+
}
62+
}

pkg/sql/parser/select_set_ops.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ package parser
1919

2020
import (
2121
"fmt"
22+
"strings"
2223

2324
goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors"
2425
"github.com/ajitpratap0/GoSQLX/pkg/models"
2526
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
27+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
2628
)
2729

2830
// parseSelectWithSetOperations parses SELECT statements that may have set operations.
@@ -41,10 +43,16 @@ func (p *Parser) parseSelectWithSetOperations() (ast.Statement, error) {
4143
return nil, err
4244
}
4345

44-
// Check for set operations (UNION, EXCEPT, INTERSECT)
45-
for p.isAnyType(models.TokenTypeUnion, models.TokenTypeExcept, models.TokenTypeIntersect) {
46+
// Check for set operations (UNION, EXCEPT, INTERSECT). MINUS is a
47+
// Snowflake / Oracle synonym for EXCEPT; it tokenizes as a plain
48+
// identifier, so match it by value.
49+
for p.isAnyType(models.TokenTypeUnion, models.TokenTypeExcept, models.TokenTypeIntersect) ||
50+
p.isMinusSetOp() {
4651
// Parse the set operation type
4752
operationLiteral := p.currentToken.Token.Value
53+
if p.isMinusSetOp() {
54+
operationLiteral = "EXCEPT" // normalize to canonical name
55+
}
4856
p.advance()
4957

5058
// Check for ALL keyword
@@ -83,3 +91,18 @@ func (p *Parser) parseSelectWithSetOperations() (ast.Statement, error) {
8391

8492
return leftStmt, nil
8593
}
94+
95+
// isMinusSetOp returns true if the current token is the Snowflake / Oracle
96+
// MINUS keyword used as a set operator (synonym for EXCEPT). MINUS tokenizes
97+
// as an identifier; gate by dialect so that MINUS as a legitimate column
98+
// alias in other dialects is not hijacked.
99+
func (p *Parser) isMinusSetOp() bool {
100+
if p.dialect != string(keywords.DialectSnowflake) &&
101+
p.dialect != string(keywords.DialectOracle) {
102+
return false
103+
}
104+
if !p.isIdentifier() {
105+
return false
106+
}
107+
return strings.EqualFold(p.currentToken.Token.Value, "MINUS")
108+
}

pkg/sql/parser/select_subquery.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) {
101101
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
102102
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
103103
// 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() {
104+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
105105
if p.isType(models.TokenTypeAs) {
106106
p.advance() // Consume AS
107107
if !p.isIdentifier() {
@@ -215,7 +215,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error
215215
// Similarly, START followed by WITH is a hierarchical query seed, not an alias.
216216
// Don't consume PIVOT/UNPIVOT as a table alias — they are contextual
217217
// 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() {
218+
if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() {
219219
if p.isType(models.TokenTypeAs) {
220220
p.advance()
221221
if !p.isIdentifier() {

0 commit comments

Comments
 (0)