From f161febaf5b6b8d4945190cd233b2eb3b58fdc6b Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 7 Apr 2026 13:21:09 +0530 Subject: [PATCH 1/2] feat(parser): ClickHouse parametric aggregates fn(p)(args) (#482) --- pkg/sql/ast/ast.go | 1 + pkg/sql/parser/clickhouse_parametric_test.go | 31 ++++++++++++++++++++ pkg/sql/parser/window.go | 31 ++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 pkg/sql/parser/clickhouse_parametric_test.go diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index c050427b..63ad58be 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -692,6 +692,7 @@ func (i Identifier) Children() []Node { return nil } type FunctionCall struct { Name string Arguments []Expression // Renamed from Args for consistency + Parameters []Expression // ClickHouse parametric aggregates: quantile(0.5)(x) — params before args Over *WindowSpec // For window functions Distinct bool Filter Expression // WHERE clause for aggregate functions diff --git a/pkg/sql/parser/clickhouse_parametric_test.go b/pkg/sql/parser/clickhouse_parametric_test.go new file mode 100644 index 00000000..5b719429 --- /dev/null +++ b/pkg/sql/parser/clickhouse_parametric_test.go @@ -0,0 +1,31 @@ +// 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/keywords" +) + +// TestClickHouseParametricAggregates verifies that ClickHouse parametric +// aggregates of the form `funcName(params)(args)` parse. Regression for #482. +func TestClickHouseParametricAggregates(t *testing.T) { + queries := map[string]string{ + "quantile_tdigest": `SELECT quantileTDigest(0.95)(value) FROM events`, + "top_k": `SELECT topK(10)(name) FROM users`, + "quantiles": `SELECT quantiles(0.5, 0.9, 0.99)(latency_ms) FROM requests`, + "with_group_by": `SELECT category, quantileTDigest(0.99)(price) FROM products GROUP BY category`, + } + for name, q := range queries { + q := q + t.Run(name, func(t *testing.T) { + if _, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse); err != nil { + t.Fatalf("parse failed: %v", err) + } + }) + } +} diff --git a/pkg/sql/parser/window.go b/pkg/sql/parser/window.go index 63f99a2b..5e8be967 100644 --- a/pkg/sql/parser/window.go +++ b/pkg/sql/parser/window.go @@ -23,6 +23,7 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) // 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) { OrderBy: orderByExprs, } + // ClickHouse parametric aggregates: funcName(params)(args). + // e.g. quantileTDigest(0.95)(value), topK(10)(name). + // What we just parsed becomes Parameters; the next paren group is the + // real arguments. Gated to ClickHouse to avoid false positives. + if p.dialect == string(keywords.DialectClickHouse) && p.isType(models.TokenTypeLParen) { + funcCall.Parameters = funcCall.Arguments + funcCall.Arguments = nil + p.advance() // Consume second ( + if !p.isType(models.TokenTypeRParen) { + for { + arg, err := p.parseExpression() + if err != nil { + return nil, err + } + funcCall.Arguments = append(funcCall.Arguments, arg) + if p.isType(models.TokenTypeComma) { + p.advance() + } else if p.isType(models.TokenTypeRParen) { + break + } else { + return nil, p.expectedError(", or )") + } + } + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume second ) + } + // Check for WITHIN GROUP clause (SQL:2003 ordered-set aggregates) // Syntax: WITHIN GROUP (ORDER BY expression [ASC|DESC] [NULLS FIRST|LAST]) // Used with: PERCENTILE_CONT, PERCENTILE_DISC, MODE, LISTAGG, etc. From 1c4ae55a734e23dd5f1521e728dfb99b6bc9b46c Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 7 Apr 2026 13:32:27 +0530 Subject: [PATCH 2/2] fix(ast): include Parameters in FunctionCall.Children() (#482 review) --- pkg/sql/ast/ast.go | 3 ++ pkg/sql/parser/clickhouse_parametric_test.go | 43 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 63ad58be..08b30129 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -705,6 +705,9 @@ func (f *FunctionCall) expressionNode() {} func (f FunctionCall) TokenLiteral() string { return f.Name } func (f FunctionCall) Children() []Node { children := nodifyExpressions(f.Arguments) + if len(f.Parameters) > 0 { + children = append(children, nodifyExpressions(f.Parameters)...) + } if f.Over != nil { children = append(children, f.Over) } diff --git a/pkg/sql/parser/clickhouse_parametric_test.go b/pkg/sql/parser/clickhouse_parametric_test.go index 5b719429..cf31280b 100644 --- a/pkg/sql/parser/clickhouse_parametric_test.go +++ b/pkg/sql/parser/clickhouse_parametric_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) @@ -29,3 +30,45 @@ func TestClickHouseParametricAggregates(t *testing.T) { }) } } + +// TestClickHouseParametricAggregates_ASTShape verifies that the Parameters +// field is populated and reachable via the visitor pattern. +func TestClickHouseParametricAggregates_ASTShape(t *testing.T) { + q := `SELECT quantileTDigest(0.95)(value) FROM events` + tree, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + // Walk the tree until we find a FunctionCall node and verify both + // Parameters and Arguments are populated, and Children() exposes both. + var found bool + var visit func(n ast.Node) + visit = func(n ast.Node) { + if n == nil || found { + return + } + if fc, ok := n.(*ast.FunctionCall); ok && fc.Name == "quantileTDigest" { + if len(fc.Parameters) != 1 { + t.Fatalf("Parameters: want 1, got %d", len(fc.Parameters)) + } + if len(fc.Arguments) != 1 { + t.Fatalf("Arguments: want 1, got %d", len(fc.Arguments)) + } + // Children() must include both the argument and the parameter. + if len(fc.Children()) < 2 { + t.Fatalf("Children(): want >=2 (args + params), got %d", len(fc.Children())) + } + found = true + return + } + for _, c := range n.Children() { + visit(c) + } + } + for _, stmt := range tree.Statements { + visit(stmt) + } + if !found { + t.Fatal("did not find quantileTDigest FunctionCall in AST") + } +}