diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 2f7efdd6..5370bf64 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 @@ -705,6 +706,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 new file mode 100644 index 00000000..cf31280b --- /dev/null +++ b/pkg/sql/parser/clickhouse_parametric_test.go @@ -0,0 +1,74 @@ +// 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/ast" + "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) + } + }) + } +} + +// 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") + } +} diff --git a/pkg/sql/parser/window.go b/pkg/sql/parser/window.go index 414d4bae..c96e6d0c 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 IGNORE NULLS / RESPECT NULLS (SQL:2016 null treatment). // Used by LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE in Snowflake, // Oracle, BigQuery, etc. IGNORE arrives as TokenTypeKeyword; RESPECT is