From 126cf1d43e76685fe44dbfa513cd28dbbe7c60d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 20 Dec 2025 18:35:30 +0000 Subject: [PATCH] Generate implicit column names when using functions with AS, like `count()` --- .../tsql/src/query/printer.test.ts | 94 +++++++++++++++++++ internal-packages/tsql/src/query/printer.ts | 82 +++++++++++++++- 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index c11726e80..36f62ac4f 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -1515,6 +1515,100 @@ describe("Column metadata", () => { expect(columns[2].customRenderType).toBeUndefined(); }); }); + + describe("Implicit column names for expressions without aliases", () => { + it("should generate implicit name for COUNT()", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT COUNT() FROM runs", ctx); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("count()"); + expect(columns[0].type).toBe("UInt64"); + }); + + it("should generate implicit name for COUNT(*)", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT COUNT(*) FROM runs", ctx); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("count(*)"); + expect(columns[0].type).toBe("UInt64"); + }); + + it("should generate implicit name for COUNT with column argument", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT COUNT(run_id) FROM runs", ctx); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("count(run_id)"); + expect(columns[0].type).toBe("UInt64"); + }); + + it("should generate implicit name for SUM", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT SUM(usage_duration_ms) FROM runs", ctx); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("sum(usage_duration_ms)"); + expect(columns[0].type).toBe("Int64"); + }); + + it("should generate implicit name for AVG", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT AVG(usage_duration_ms) FROM runs", ctx); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("avg(usage_duration_ms)"); + expect(columns[0].type).toBe("Float64"); + }); + + it("should generate implicit names for multiple aggregations without aliases", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery( + "SELECT COUNT(), status FROM runs GROUP BY status", + ctx + ); + + expect(columns).toHaveLength(2); + expect(columns[0].name).toBe("count()"); + expect(columns[0].type).toBe("UInt64"); + expect(columns[1].name).toBe("status"); + expect(columns[1].type).toBe("LowCardinality(String)"); + }); + + it("should generate implicit name for arithmetic expressions", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery( + "SELECT usage_duration_ms + 100 FROM runs", + ctx + ); + + expect(columns).toHaveLength(1); + expect(columns[0].name).toBe("plus(usage_duration_ms, 100)"); + }); + + it("should generate implicit name for constant values", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery("SELECT 42, 'hello' FROM runs", ctx); + + expect(columns).toHaveLength(2); + expect(columns[0].name).toBe("42"); + expect(columns[1].name).toBe("'hello'"); + }); + + it("should mix explicit aliases with implicit names", () => { + const ctx = createMetadataTestContext(); + const { columns } = printQuery( + "SELECT COUNT() AS total, AVG(usage_duration_ms), status AS run_status FROM runs GROUP BY status", + ctx + ); + + expect(columns).toHaveLength(3); + expect(columns[0].name).toBe("total"); + expect(columns[1].name).toBe("avg(usage_duration_ms)"); + expect(columns[2].name).toBe("run_status"); + }); + }); }); describe("Field Mapping Value Transformation", () => { diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 7ca526177..4fe02d681 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -241,7 +241,10 @@ export class ClickHousePrinter { break; default: throw new NotImplementedError( - `Unknown expression type: ${nodeType}. Node: ${JSON.stringify(node, null, 2).slice(0, 200)}` + `Unknown expression type: ${nodeType}. Node: ${JSON.stringify(node, null, 2).slice( + 0, + 200 + )}` ); } @@ -561,7 +564,7 @@ export class ClickHousePrinter { const call = col as Call; const inferredType = this.inferCallType(call); return { - outputName: null, // Computed columns without alias get auto-named by ClickHouse + outputName: this.generateImplicitName(call), sourceColumn: null, inferredType, }; @@ -572,7 +575,7 @@ export class ClickHousePrinter { const arith = col as ArithmeticOperation; const inferredType = this.inferArithmeticType(arith); return { - outputName: null, + outputName: this.generateImplicitName(arith), sourceColumn: null, inferredType, }; @@ -583,7 +586,7 @@ export class ClickHousePrinter { const constant = col as Constant; const inferredType = this.inferConstantType(constant); return { - outputName: null, + outputName: this.generateImplicitName(constant), sourceColumn: null, inferredType, }; @@ -597,6 +600,77 @@ export class ClickHousePrinter { }; } + /** + * Generate an implicit column name for an expression without an explicit alias. + * This matches how ClickHouse auto-names result columns. + * + * Examples: + * - COUNT() -> "count()" + * - COUNT(id) -> "count(id)" + * - SUM(duration_ms) -> "sum(duration_ms)" + * - 1 + 2 -> "plus(1, 2)" + */ + private generateImplicitName(expr: Expression): string { + // Handle Call (function/aggregation) + if ((expr as Call).expression_type === "call") { + const call = expr as Call; + const args = call.args.map((arg) => this.generateImplicitName(arg)); + // Use lowercase function name to match ClickHouse behavior + return `${call.name.toLowerCase()}(${args.join(", ")})`; + } + + // Handle Field + if ((expr as Field).expression_type === "field") { + const field = expr as Field; + // Return the last part of the chain (column name) + const parts = field.chain.filter((p): p is string => typeof p === "string"); + return parts.length > 0 ? parts[parts.length - 1] : "*"; + } + + // Handle ArithmeticOperation + if ((expr as ArithmeticOperation).expression_type === "arithmetic_operation") { + const arith = expr as ArithmeticOperation; + const left = this.generateImplicitName(arith.left); + const right = this.generateImplicitName(arith.right); + + switch (arith.op) { + case ArithmeticOperationOp.Add: + return `plus(${left}, ${right})`; + case ArithmeticOperationOp.Sub: + return `minus(${left}, ${right})`; + case ArithmeticOperationOp.Mult: + return `multiply(${left}, ${right})`; + case ArithmeticOperationOp.Div: + return `divide(${left}, ${right})`; + case ArithmeticOperationOp.Mod: + return `modulo(${left}, ${right})`; + default: + return "expression"; + } + } + + // Handle Constant + if ((expr as Constant).expression_type === "constant") { + const constant = expr as Constant; + if (constant.value === null) { + return "NULL"; + } + if (typeof constant.value === "string") { + return `'${constant.value}'`; + } + return String(constant.value); + } + + // Handle Alias (shouldn't normally reach here since aliases have explicit names) + if ((expr as Alias).expression_type === "alias") { + const alias = expr as Alias; + return alias.alias; + } + + // Default fallback + return "expression"; + } + /** * Resolve a field chain to its column schema and output name */