From 27a245013153f09a0d6664c3da0d839503a5ff1d Mon Sep 17 00:00:00 2001 From: MunMun Miao <18216142+MunMunMiao@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:59:28 +0800 Subject: [PATCH] Format ClickHouse aggregate functions with combinators as function calls ClickHouse builds names like `sumIf`, `countMerge` or `uniqExactIfState` from an aggregate function and combinators, so they are not in the list of functions (it comes from `system.functions`). They were treated as identifiers and printed with a space before the parenthesis: `countMerge (x)`. Treat an identifier that is a known aggregate function with combinators appended, and is followed by a parenthesis, as a function name. --- .../clickhouse/clickhouse.formatter.ts | 35 +++++++++++++++++++ test/clickhouse.test.ts | 18 ++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/languages/clickhouse/clickhouse.formatter.ts b/src/languages/clickhouse/clickhouse.formatter.ts index 71aa28b32d..2e529146b5 100644 --- a/src/languages/clickhouse/clickhouse.formatter.ts +++ b/src/languages/clickhouse/clickhouse.formatter.ts @@ -295,11 +295,37 @@ export const clickhouse: DialectOptions = { }, }; +// https://clickhouse.com/docs/sql-reference/aggregate-functions/combinators +// Longer combinators come first, so that `MergeState` is not matched as just `State`. +const aggregateCombinator = + /(?:SimpleState|MergeState|ForEach|Distinct|OrDefault|OrNull|Resample|ArgMax|ArgMin|State|Merge|Array|Map|If)$/u; +const functionNames = new Set(functions); + +/** + * Whether the name is an aggregate function with one or more combinators appended, + * like `sumIf`, `countMerge` or `uniqExactIfState`. These are not in the list of functions, + * because ClickHouse builds them from the name of the aggregate function and the combinator. + */ +function isAggregateWithCombinators(name: string): boolean { + for (let base = name; ; ) { + const match = aggregateCombinator.exec(base); + if (!match || match.index === 0) { + return false; + } + base = base.slice(0, match.index); + if (functionNames.has(base)) { + return true; + } + } +} + /** * 1. Formats GRANT statements to use RESERVED_KEYWORD instead of RESERVED_SELECT * for SELECT GRANTs * 2. Formats SET(100) as RESERVED_FUNCTION_NAME instead of RESERVED_KEYWORD * so it appears as a function rather than a statement. + * 3. Formats sumIf(x, y), countMerge(x) etc. as RESERVED_FUNCTION_NAME instead of IDENTIFIER + * so that they appear as function calls, like sum(x). */ function postProcess(tokens: Token[]): Token[] { return tokens.map((token, i) => { @@ -333,6 +359,15 @@ function postProcess(tokens: Token[]): Token[] { return { ...token, type: TokenType.RESERVED_FUNCTION_NAME }; } + // We should format `countMerge(x)` as-is rather than `countMerge (x)` + if ( + token.type === TokenType.IDENTIFIER && + nextToken.type === TokenType.OPEN_PAREN && + isAggregateWithCombinators(token.text) + ) { + return { ...token, type: TokenType.RESERVED_FUNCTION_NAME }; + } + return token; }); } diff --git a/test/clickhouse.test.ts b/test/clickhouse.test.ts index 225b0480f0..4b72490230 100644 --- a/test/clickhouse.test.ts +++ b/test/clickhouse.test.ts @@ -399,6 +399,24 @@ describe('ClickhouseFormatter', () => { }); // https://clickhouse.com/docs/sql-reference/window-functions + describe('Aggregate function combinators', () => { + it('formats aggregate functions with combinators as function calls', () => { + const result = format( + `SELECT sumIf(x, y > 1), countMerge(s), uniqExactIfState(id), groupArrayArray(a), argMaxOrNull(a, b) FROM t;` + ); + expect(result).toBe(dedent` + SELECT + sumIf(x, y > 1), + countMerge(s), + uniqExactIfState(id), + groupArrayArray(a), + argMaxOrNull(a, b) + FROM + t; + `); + }); + }); + describe('Window functions', () => { it('formats SELECT with window function', () => { expect(