Skip to content
Open
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
19 changes: 17 additions & 2 deletions parser/parser_column.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ func (p *Parser) getNextPrecedence() int {
case p.matchKeyword(KeywordIn):
return precedenceIn
case p.matchKeyword(KeywordGlobal):
// GLOBAL is only valid before IN, so it binds like IN itself.
// GLOBAL is both the IN operator's prefix and a join locality. Before a
// join it belongs to the enclosing FROM clause — the locality in
// `ON a = b GLOBAL LEFT JOIN c` — so the expression has to end here
// rather than bind it. Everywhere else it binds like IN itself, which
// keeps `GLOBAL` from being mistaken for an alias in `SELECT a GLOBAL`.
if p.peekJoinAfterLocality() {
return PrecedenceUnknown
}

return precedenceIn
case p.matchTokenKind(TokenKindQuestionMark):
return PrecedenceQuery
Expand Down Expand Up @@ -159,16 +167,23 @@ func (p *Parser) parseInfix(expr Expr, precedence int) (Expr, error) {
return p.parseBetweenClause(expr, false)
case p.matchKeyword(KeywordGlobal):
_ = p.lexer.consumeToken()
negated := p.tryConsumeKeywords(KeywordNot)
if p.expectKeyword(KeywordIn) != nil {
return nil, fmt.Errorf("expected IN after GLOBAL, got %s", p.currentTokenKind())
}

op := TokenKind("GLOBAL IN")
if negated {
op = "GLOBAL NOT IN"
}

rightExpr, err := p.parseSubExpr(p.Pos(), precedence)
if err != nil {
return nil, err
}
return &BinaryOperation{
LeftExpr: expr,
Operation: "GLOBAL IN",
Operation: op,
RightExpr: rightExpr,
}, nil
case p.matchTokenKind(TokenKindDot):
Expand Down
53 changes: 51 additions & 2 deletions parser/parser_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package parser
import (
"errors"
"fmt"
"strings"

"slices"
)
Expand Down Expand Up @@ -279,12 +280,60 @@ func (p *Parser) parseJoinTableExpr(_ Pos) (Expr, error) {
}
}

// parseJoinLocality consumes a GLOBAL or LOCAL join locality together with the
// join type that follows it, returning them as join modifiers. It returns a nil
// slice when the keyword is not followed by a join, leaving the caller to treat
// the clause as something other than a join.
func (p *Parser) parseJoinLocality(pos Pos) ([]string, error) {
locality := p.current().String
_ = p.lexer.consumeToken()

joinOp := p.parseJoinOp(pos)
if len(joinOp) == 0 && !p.matchKeyword(KeywordJoin) {
return nil, nil
}

// ARRAY JOIN reads a column list rather than a distributed table, so it has
// no locality. parseJoinOp keeps each modifier's source spelling, so the
// keyword has to be matched case-insensitively.
if slices.ContainsFunc(joinOp, func(modifier string) bool {
return strings.EqualFold(modifier, KeywordArray)
}) {
return nil, fmt.Errorf("%s cannot be combined with ARRAY JOIN", locality)
}

return append([]string{locality}, joinOp...), nil
}

// peekJoinAfterLocality reports whether the current GLOBAL/LOCAL keyword is
// followed by a join operator. It leaves the lexer where it found it, so
// expression parsing can use it to tell a join locality apart from the GLOBAL IN
// operator.
func (p *Parser) peekJoinAfterLocality() bool {
savedState := p.lexer.saveState()
defer p.lexer.restoreState(savedState)

_ = p.lexer.consumeToken()
p.parseJoinOp(p.Pos())

return p.matchKeyword(KeywordJoin)
}

func (p *Parser) parseJoinRightExpr(pos Pos) (expr Expr, err error) {
var rightExpr Expr
var modifiers []string
switch {
case p.tryConsumeKeywords(KeywordGlobal):
case p.tryConsumeKeywords(KeywordLocal):
case p.matchOneOfKeywords(KeywordGlobal, KeywordLocal):
// GLOBAL/LOCAL only says how the right-hand table is distributed, so the
// join type still follows it: `GLOBAL LEFT JOIN` is a LEFT join.
modifiers, err = p.parseJoinLocality(p.Pos())
if err != nil {
return nil, err
}

if modifiers == nil {
return nil, nil
}
case p.tryConsumeTokenKind(TokenKindComma) != nil:
return p.parseJoinExpr(p.Pos())
default:
Expand Down
21 changes: 21 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ func TestParser_InvalidSyntax(t *testing.T) {
// Invalid ARRAY JOIN types (only ARRAY JOIN, LEFT ARRAY JOIN, and INNER ARRAY JOIN are valid)
"SELECT * FROM t RIGHT ARRAY JOIN arr AS a", // RIGHT ARRAY JOIN not supported
"SELECT * FROM t FULL ARRAY JOIN arr AS a", // FULL ARRAY JOIN not supported
// ARRAY JOIN reads a column list, so it has no GLOBAL/LOCAL locality.
// Join modifiers keep their source spelling, so the lowercase spellings
// have to be rejected too.
"SELECT * FROM t GLOBAL ARRAY JOIN arr AS a",
"SELECT * FROM t LOCAL LEFT ARRAY JOIN arr AS a",
"SELECT * FROM t GLOBAL array JOIN arr AS a",
"SELECT * FROM t LOCAL left array JOIN arr AS a",
// A join takes at most one locality, and it precedes the join type
"SELECT * FROM t1 GLOBAL LOCAL JOIN t2 ON t1.a = t2.a",
"SELECT * FROM t1 LEFT GLOBAL JOIN t2 ON t1.a = t2.a",
// GLOBAL is a keyword everywhere, never an implicit alias
"SELECT a GLOBAL FROM t",
"SELECT a GLOBAL, b FROM t",
// GLOBAL is an operator only before IN/NOT IN, and LOCAL is never one
"SELECT * FROM t WHERE a GLOBAL LIKE 'x'",
"SELECT * FROM t WHERE a GLOBAL NOT LIKE 'x'",
"SELECT * FROM t WHERE a GLOBAL BETWEEN 1 AND 2",
"SELECT * FROM t WHERE a NOT GLOBAL IN (SELECT 1)",
"SELECT * FROM t WHERE a GLOBAL GLOBAL IN (SELECT 1)",
"SELECT * FROM t WHERE a LOCAL IN (SELECT 1)",
"SELECT * FROM t WHERE a LOCAL NOT IN (SELECT 1)",
"00e1d", // invalid number that leaves curToken nil
"CREATE--", // trailing comment pushes p.Pos() past end of input (wrapError out-of-range)
// Inputs that previously caused a nil-pointer dereference while
Expand Down
11 changes: 11 additions & 0 deletions parser/position_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ func TestDictionaryAttributeEnd(t *testing.T) {
require.Equal(t, "IS_OBJECT_ID", sql[36:48])
require.Equal(t, Pos(48), attrs[0].End())
}

func TestJoinLocalityIsInsideTheJoinSpan(t *testing.T) {
sql := "SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a"
stmt := parseOneStmt(t, sql).(*SelectQuery)
join := stmt.From.Expr.(*JoinExpr).Right.(*JoinExpr)
// the join starts at GLOBAL rather than at the join type, so a rewriter that
// reads the node back from the source keeps the locality
require.Equal(t, "GLOBAL", sql[17:23])
require.Equal(t, Pos(17), join.Pos())
require.Equal(t, Pos(len(sql)), join.End())
}
72 changes: 72 additions & 0 deletions parser/precedence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,75 @@ func TestIntersect(t *testing.T) {
require.NotNil(t, selectQuery.Intersect)
require.Equal(t, "SELECT 1 INTERSECT SELECT 2", Format(stmts[0]))
}

func TestGlobalJoinLocalityPrefixesTheJoinType(t *testing.T) {
// GLOBAL/LOCAL only says how the right-hand table is distributed, so the
// join type still follows it and both have to survive into the modifiers.
for _, tc := range []struct {
sql string
modifiers []string
}{
{"SELECT * FROM t1 GLOBAL JOIN t2 ON t1.a = t2.a", []string{"GLOBAL", "JOIN"}},
{"SELECT * FROM t1 GLOBAL INNER JOIN t2 ON t1.a = t2.a", []string{"GLOBAL", "INNER", "JOIN"}},
{"SELECT * FROM t1 GLOBAL LEFT OUTER JOIN t2 ON t1.a = t2.a", []string{"GLOBAL", "LEFT", "OUTER", "JOIN"}},
{"SELECT * FROM t1 GLOBAL ANY LEFT JOIN t2 ON t1.a = t2.a", []string{"GLOBAL", "ANY", "LEFT", "JOIN"}},
{"SELECT * FROM t1 GLOBAL CROSS JOIN t2", []string{"GLOBAL", "CROSS", "JOIN"}},
{"SELECT * FROM t1 AS x LOCAL FULL JOIN t2 USING a", []string{"LOCAL", "FULL", "JOIN"}},
} {
from := parseOneStmt(t, tc.sql).(*SelectQuery).From.Expr
join, ok := from.(*JoinExpr)
require.True(t, ok, "%s: expected a *JoinExpr in FROM, got %T", tc.sql, from)

right, ok := join.Right.(*JoinExpr)
require.True(t, ok, "%s: expected the join to carry a right side, got %T", tc.sql, join.Right)
require.Equal(t, tc.modifiers, right.Modifiers, tc.sql)
require.Equal(t, tc.sql, Format(parseOneStmt(t, tc.sql)), tc.sql)
}
}

func TestGlobalAfterJoinConstraintStartsANewJoin(t *testing.T) {
// A GLOBAL that follows an ON/USING clause belongs to the next join, so it
// must not be read as the GLOBAL IN operator.
sql := "SELECT * FROM t1 GLOBAL JOIN t2 ON t1.a = t2.a GLOBAL LEFT JOIN t3 ON t1.a = t3.a"
stmt := parseOneStmt(t, sql)
join := stmt.(*SelectQuery).From.Expr.(*JoinExpr)

first, ok := join.Right.(*JoinExpr)
require.True(t, ok)
require.Equal(t, []string{"GLOBAL", "JOIN"}, first.Modifiers)

second, ok := first.Right.(*JoinExpr)
require.True(t, ok, "the second GLOBAL should start another join, got %T", first.Right)
require.Equal(t, []string{"GLOBAL", "LEFT", "JOIN"}, second.Modifiers)
require.Equal(t, sql, Format(stmt))
}

func TestGlobalInAndGlobalNotIn(t *testing.T) {
for _, tc := range []struct {
sql string
op TokenKind
}{
{"SELECT a GLOBAL IN (SELECT 1)", "GLOBAL IN"},
{"SELECT a GLOBAL NOT IN (SELECT 1)", "GLOBAL NOT IN"},
} {
expr := parseSelectItemExpr(t, tc.sql)
in, ok := expr.(*BinaryOperation)
require.True(t, ok, "%s: expected BinaryOperation at the top, got %T", tc.sql, expr)
require.Equal(t, tc.op, in.Operation, tc.sql)
}
}

func TestGlobalInGroupsLikeIn(t *testing.T) {
// GLOBAL IN binds with IN's precedence: `a = b GLOBAL IN (1)` groups as
// `a = (b GLOBAL IN (1))`.
for _, sql := range []string{"SELECT a = b GLOBAL IN (1)", "SELECT a = b GLOBAL NOT IN (1)"} {
expr := parseSelectItemExpr(t, sql)
eq, ok := expr.(*BinaryOperation)
require.True(t, ok, "%s: expected `=` at the top, got %T", sql, expr)
require.Equal(t, TokenKind("="), eq.Operation, sql)

in, ok := eq.RightExpr.(*BinaryOperation)
require.True(t, ok, "%s: right side of `=` should be the GLOBAL IN operation, got %T", sql, eq.RightExpr)
require.Contains(t, string(in.Operation), "GLOBAL", sql)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
-- Origin SQL:
SELECT * FROM t1 GLOBAL JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL INNER JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL LEFT OUTER JOIN t2 USING (a);

SELECT * FROM t1 GLOBAL ANY LEFT JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL CROSS JOIN t2;

SELECT * FROM t1 AS x LOCAL FULL JOIN t2 ON x.a = t2.a;

SELECT * FROM t1 AS x LOCAL RIGHT JOIN t2 USING a;

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a GLOBAL LEFT JOIN t3 ON t1.a = t3.a;

SELECT * FROM t WHERE a GLOBAL IN (SELECT b FROM t2);

SELECT * FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2);

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a WHERE t1.a GLOBAL NOT IN (SELECT b FROM t3);


-- Beautify SQL:
SELECT
*
FROM
t1
GLOBAL JOIN
t2 ON t1.a = t2.a;
SELECT
*
FROM
t1
GLOBAL INNER JOIN
t2 ON t1.a = t2.a;
SELECT
*
FROM
t1
GLOBAL LEFT JOIN
t2 ON t1.a = t2.a;
SELECT
*
FROM
t1
GLOBAL LEFT OUTER JOIN
t2 USING a;
SELECT
*
FROM
t1
GLOBAL ANY LEFT JOIN
t2 ON t1.a = t2.a;
SELECT
*
FROM
t1
GLOBAL CROSS JOIN
t2;
SELECT
*
FROM
t1 AS x
LOCAL FULL JOIN
t2 ON x.a = t2.a;
SELECT
*
FROM
t1 AS x
LOCAL RIGHT JOIN
t2 USING a;
SELECT
*
FROM
t1
GLOBAL LEFT JOIN
t2 ON t1.a = t2.a
GLOBAL LEFT JOIN
t3 ON t1.a = t3.a;
SELECT
*
FROM
t
WHERE
a GLOBAL IN (SELECT
b
FROM
t2);
SELECT
*
FROM
t
WHERE
a GLOBAL NOT IN (SELECT
b
FROM
t2);
SELECT
*
FROM
t1
GLOBAL LEFT JOIN
t2 ON t1.a = t2.a
WHERE
t1.a GLOBAL NOT IN (SELECT
b
FROM
t3);
39 changes: 39 additions & 0 deletions parser/testdata/query/format/select_with_global_join_locality.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Origin SQL:
SELECT * FROM t1 GLOBAL JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL INNER JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL LEFT OUTER JOIN t2 USING (a);

SELECT * FROM t1 GLOBAL ANY LEFT JOIN t2 ON t1.a = t2.a;

SELECT * FROM t1 GLOBAL CROSS JOIN t2;

SELECT * FROM t1 AS x LOCAL FULL JOIN t2 ON x.a = t2.a;

SELECT * FROM t1 AS x LOCAL RIGHT JOIN t2 USING a;

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a GLOBAL LEFT JOIN t3 ON t1.a = t3.a;

SELECT * FROM t WHERE a GLOBAL IN (SELECT b FROM t2);

SELECT * FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2);

SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a WHERE t1.a GLOBAL NOT IN (SELECT b FROM t3);


-- Format SQL:
SELECT * FROM t1 GLOBAL JOIN t2 ON t1.a = t2.a;
SELECT * FROM t1 GLOBAL INNER JOIN t2 ON t1.a = t2.a;
SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a;
SELECT * FROM t1 GLOBAL LEFT OUTER JOIN t2 USING a;
SELECT * FROM t1 GLOBAL ANY LEFT JOIN t2 ON t1.a = t2.a;
SELECT * FROM t1 GLOBAL CROSS JOIN t2;
SELECT * FROM t1 AS x LOCAL FULL JOIN t2 ON x.a = t2.a;
SELECT * FROM t1 AS x LOCAL RIGHT JOIN t2 USING a;
SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a GLOBAL LEFT JOIN t3 ON t1.a = t3.a;
SELECT * FROM t WHERE a GLOBAL IN (SELECT b FROM t2);
SELECT * FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2);
SELECT * FROM t1 GLOBAL LEFT JOIN t2 ON t1.a = t2.a WHERE t1.a GLOBAL NOT IN (SELECT b FROM t3);
Loading
Loading