diff --git a/pkg/sql/ast/ast_expressions.go b/pkg/sql/ast/ast_expressions.go index 1c68cc4e..b22c0bde 100644 --- a/pkg/sql/ast/ast_expressions.go +++ b/pkg/sql/ast/ast_expressions.go @@ -271,6 +271,24 @@ func (s SubqueryExpression) Children() []Node { return []Node{s.Subquery} } +// ParenthesizedExpression represents an expression wrapped in parentheses to +// preserve grouping/operator-precedence, e.g. `WHERE (a OR b) AND c`. +// SQL requires them to round-trip unchanged; discarding the node would silently +// change AND/OR precedence on re-render. +type ParenthesizedExpression struct { + Expr Expression + Pos models.Location // Source position of the opening parenthesis +} + +func (p *ParenthesizedExpression) expressionNode() {} +func (p ParenthesizedExpression) TokenLiteral() string { return "PAREN" } +func (p ParenthesizedExpression) Children() []Node { + if p.Expr == nil { + return nil + } + return []Node{p.Expr} +} + // AnyExpression represents expr op ANY (subquery) type AnyExpression struct { Expr Expression diff --git a/pkg/sql/ast/pool_expression_release.go b/pkg/sql/ast/pool_expression_release.go index 2eed7731..2be19ea6 100644 --- a/pkg/sql/ast/pool_expression_release.go +++ b/pkg/sql/ast/pool_expression_release.go @@ -339,6 +339,14 @@ func putExpressionImpl(expr Expression, depth int) { } subqueryExprPool.Put(e) + case *ParenthesizedExpression: + if e.Expr != nil { + workQueue = append(workQueue, e.Expr) + } + e.Expr = nil + // ParenthesizedExpression has no dedicated pool; children are freed above + // and the node itself is left to the garbage collector. + case *CastExpression: if e.Expr != nil { workQueue = append(workQueue, e.Expr) diff --git a/pkg/sql/ast/roundtrip_test.go b/pkg/sql/ast/roundtrip_test.go index 1ab20369..95a1a3a8 100644 --- a/pkg/sql/ast/roundtrip_test.go +++ b/pkg/sql/ast/roundtrip_test.go @@ -31,6 +31,8 @@ func TestRoundtrip(t *testing.T) { {"select where", "SELECT id FROM users WHERE active = TRUE"}, {"select and", "SELECT id FROM users WHERE active = TRUE AND age > 18"}, {"select or", "SELECT id FROM users WHERE a = 1 OR b = 2"}, + {"select or grouped", "SELECT id FROM users WHERE (a = 1 OR b = 2) AND c = 3"}, + {"select nested parens", "SELECT id FROM users WHERE ((a AND b) OR c) AND d"}, {"select distinct", "SELECT DISTINCT status FROM orders"}, {"select limit offset", "SELECT * FROM users LIMIT 10 OFFSET 20"}, {"select order by", "SELECT * FROM users ORDER BY name"}, diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 9569cfe3..67818c75 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -313,6 +313,15 @@ func (s *SubqueryExpression) SQL() string { return fmt.Sprintf("(%s)", stmtSQL(s.Subquery)) } +// SQL returns the parenthesized expression as "(expr)". The parentheses are +// preserved so that AND/OR grouping survives a round-trip (see #519). +func (p *ParenthesizedExpression) SQL() string { + if p == nil { + return "" + } + return fmt.Sprintf("(%s)", exprSQL(p.Expr)) +} + // SQL returns the SQL representation of this ANY expression as "expr op ANY (subquery)". func (a *AnyExpression) SQL() string { if a == nil { diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 04516108..6afa88af 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -331,7 +331,10 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { return p.parseArrayAccessExpression(expr) } - return expr, nil + // Preserve grouping for a single parenthesized expression. Without this + // node the parentheses are dropped and AND/OR precedence changes on + // re-render, e.g. `(a OR b) AND c` → `a OR b AND c` (see #519). + return &ast.ParenthesizedExpression{Expr: expr, Pos: parenPos}, nil } if p.isType(models.TokenTypeExists) { diff --git a/pkg/sql/parser/parenthesized_expression_test.go b/pkg/sql/parser/parenthesized_expression_test.go new file mode 100644 index 00000000..cf88c06a --- /dev/null +++ b/pkg/sql/parser/parenthesized_expression_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// Regression for #519: parenthesized expressions must round-trip unchanged. +// Previously the `ParenthesizedExpression` node did not exist, so parentheses +// were dropped during parsing and AND/OR precedence silently changed on render: +// (a OR b) AND c → a OR b AND c +func TestParenthesizedExpressionRoundtrip(t *testing.T) { + tests := []struct { + name string + sql string + }{ + {"or group and", + `SELECT * FROM t WHERE (category = 'si' OR category = 'eev') AND snapshot_date = today()`}, + {"and group", + `SELECT * FROM t WHERE a AND (b OR c)`}, + {"nested parens", + `SELECT * FROM t WHERE ((a AND b) OR c) AND d`}, + {"join condition parens", + `SELECT * FROM t WHERE name = 'Vasya' AND (user_id = account.id) GROUP BY position`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tt.sql, keywords.DialectClickHouse) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if got := tree.SQL(); got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +}