Skip to content

Commit 1fd384b

Browse files
ajitpratap0Ajit Pratap Singh
andauthored
feat(parser): ClickHouse parametric aggregates (#482) (#487)
* feat(parser): ClickHouse parametric aggregates fn(p)(args) (#482) * fix(ast): include Parameters in FunctionCall.Children() (#482 review) --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local>
1 parent 926afe4 commit 1fd384b

File tree

3 files changed

+109
-0
lines changed

3 files changed

+109
-0
lines changed

pkg/sql/ast/ast.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@ func (i Identifier) Children() []Node { return nil }
692692
type FunctionCall struct {
693693
Name string
694694
Arguments []Expression // Renamed from Args for consistency
695+
Parameters []Expression // ClickHouse parametric aggregates: quantile(0.5)(x) — params before args
695696
Over *WindowSpec // For window functions
696697
Distinct bool
697698
Filter Expression // WHERE clause for aggregate functions
@@ -705,6 +706,9 @@ func (f *FunctionCall) expressionNode() {}
705706
func (f FunctionCall) TokenLiteral() string { return f.Name }
706707
func (f FunctionCall) Children() []Node {
707708
children := nodifyExpressions(f.Arguments)
709+
if len(f.Parameters) > 0 {
710+
children = append(children, nodifyExpressions(f.Parameters)...)
711+
}
708712
if f.Over != nil {
709713
children = append(children, f.Over)
710714
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
// TestClickHouseParametricAggregates verifies that ClickHouse parametric
16+
// aggregates of the form `funcName(params)(args)` parse. Regression for #482.
17+
func TestClickHouseParametricAggregates(t *testing.T) {
18+
queries := map[string]string{
19+
"quantile_tdigest": `SELECT quantileTDigest(0.95)(value) FROM events`,
20+
"top_k": `SELECT topK(10)(name) FROM users`,
21+
"quantiles": `SELECT quantiles(0.5, 0.9, 0.99)(latency_ms) FROM requests`,
22+
"with_group_by": `SELECT category, quantileTDigest(0.99)(price) FROM products GROUP BY category`,
23+
}
24+
for name, q := range queries {
25+
q := q
26+
t.Run(name, func(t *testing.T) {
27+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse); err != nil {
28+
t.Fatalf("parse failed: %v", err)
29+
}
30+
})
31+
}
32+
}
33+
34+
// TestClickHouseParametricAggregates_ASTShape verifies that the Parameters
35+
// field is populated and reachable via the visitor pattern.
36+
func TestClickHouseParametricAggregates_ASTShape(t *testing.T) {
37+
q := `SELECT quantileTDigest(0.95)(value) FROM events`
38+
tree, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse)
39+
if err != nil {
40+
t.Fatalf("parse failed: %v", err)
41+
}
42+
// Walk the tree until we find a FunctionCall node and verify both
43+
// Parameters and Arguments are populated, and Children() exposes both.
44+
var found bool
45+
var visit func(n ast.Node)
46+
visit = func(n ast.Node) {
47+
if n == nil || found {
48+
return
49+
}
50+
if fc, ok := n.(*ast.FunctionCall); ok && fc.Name == "quantileTDigest" {
51+
if len(fc.Parameters) != 1 {
52+
t.Fatalf("Parameters: want 1, got %d", len(fc.Parameters))
53+
}
54+
if len(fc.Arguments) != 1 {
55+
t.Fatalf("Arguments: want 1, got %d", len(fc.Arguments))
56+
}
57+
// Children() must include both the argument and the parameter.
58+
if len(fc.Children()) < 2 {
59+
t.Fatalf("Children(): want >=2 (args + params), got %d", len(fc.Children()))
60+
}
61+
found = true
62+
return
63+
}
64+
for _, c := range n.Children() {
65+
visit(c)
66+
}
67+
}
68+
for _, stmt := range tree.Statements {
69+
visit(stmt)
70+
}
71+
if !found {
72+
t.Fatal("did not find quantileTDigest FunctionCall in AST")
73+
}
74+
}

pkg/sql/parser/window.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"github.com/ajitpratap0/GoSQLX/pkg/models"
2525
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
26+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
2627
)
2728

2829
// SUM(salary) OVER (PARTITION BY dept ORDER BY date ROWS UNBOUNDED PRECEDING) -> window function with frame
@@ -153,6 +154,36 @@ func (p *Parser) parseFunctionCall(funcName string) (*ast.FunctionCall, error) {
153154
OrderBy: orderByExprs,
154155
}
155156

157+
// ClickHouse parametric aggregates: funcName(params)(args).
158+
// e.g. quantileTDigest(0.95)(value), topK(10)(name).
159+
// What we just parsed becomes Parameters; the next paren group is the
160+
// real arguments. Gated to ClickHouse to avoid false positives.
161+
if p.dialect == string(keywords.DialectClickHouse) && p.isType(models.TokenTypeLParen) {
162+
funcCall.Parameters = funcCall.Arguments
163+
funcCall.Arguments = nil
164+
p.advance() // Consume second (
165+
if !p.isType(models.TokenTypeRParen) {
166+
for {
167+
arg, err := p.parseExpression()
168+
if err != nil {
169+
return nil, err
170+
}
171+
funcCall.Arguments = append(funcCall.Arguments, arg)
172+
if p.isType(models.TokenTypeComma) {
173+
p.advance()
174+
} else if p.isType(models.TokenTypeRParen) {
175+
break
176+
} else {
177+
return nil, p.expectedError(", or )")
178+
}
179+
}
180+
}
181+
if !p.isType(models.TokenTypeRParen) {
182+
return nil, p.expectedError(")")
183+
}
184+
p.advance() // Consume second )
185+
}
186+
156187
// Check for IGNORE NULLS / RESPECT NULLS (SQL:2016 null treatment).
157188
// Used by LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE in Snowflake,
158189
// Oracle, BigQuery, etc. IGNORE arrives as TokenTypeKeyword; RESPECT is

0 commit comments

Comments
 (0)