From dadc5e8425d0f0b4678df12e49d8f7a2da6aadbd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 12:11:48 +0000 Subject: [PATCH 1/6] Some TRQL date fns weren't working correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dateAdd() wasn’t being converted correctly to ClickHouse. We were getting a syntax error --- .../tsql/src/query/printer.test.ts | 64 +++++++++++++++ internal-packages/tsql/src/query/printer.ts | 81 ++++++++++++++++++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 585f695fa10..c8eefb48a0b 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -1244,6 +1244,70 @@ describe("ClickHousePrinter", () => { }); }); + describe("Date functions with interval units", () => { + it("should output dateAdd with string interval as bare keyword", () => { + const { sql } = printQuery("SELECT dateAdd('day', 7, created_at) AS week_later FROM task_runs"); + + expect(sql).toContain("dateAdd(day, 7, created_at)"); + expect(sql).not.toContain("'day'"); + }); + + it("should output dateAdd with bare identifier interval as keyword", () => { + const { sql } = printQuery("SELECT dateAdd(day, 7, created_at) AS week_later FROM task_runs"); + + expect(sql).toContain("dateAdd(day, 7, created_at)"); + }); + + it("should output dateDiff with string interval as bare keyword", () => { + const { sql } = printQuery( + "SELECT dateDiff('minute', started_at, completed_at) AS duration_minutes FROM task_runs" + ); + + expect(sql).toContain("dateDiff(minute,"); + expect(sql).not.toContain("'minute'"); + }); + + it("should output dateSub with string interval as bare keyword", () => { + const { sql } = printQuery("SELECT dateSub('hour', 1, created_at) AS earlier FROM task_runs"); + + expect(sql).toContain("dateSub(hour, 1, created_at)"); + expect(sql).not.toContain("'hour'"); + }); + + it("should output dateTrunc with string interval as bare keyword", () => { + const { sql } = printQuery( + "SELECT dateTrunc('month', created_at) AS month_start FROM task_runs" + ); + + expect(sql).toContain("dateTrunc(month, created_at)"); + expect(sql).not.toContain("'month'"); + }); + + it("should output date_add (underscore variant) with bare keyword", () => { + const { sql } = printQuery( + "SELECT date_add('week', 2, created_at) AS two_weeks FROM task_runs" + ); + + expect(sql).toContain("date_add(week, 2, created_at)"); + expect(sql).not.toContain("'week'"); + }); + + it("should output date_diff (underscore variant) with bare keyword", () => { + const { sql } = printQuery( + "SELECT date_diff('second', started_at, completed_at) AS dur FROM task_runs" + ); + + expect(sql).toContain("date_diff(second,"); + expect(sql).not.toContain("'second'"); + }); + + it("should handle case-insensitive interval units", () => { + const { sql } = printQuery("SELECT dateAdd('DAY', 7, created_at) AS week_later FROM task_runs"); + + expect(sql).toContain("dateAdd(day, 7, created_at)"); + }); + }); + describe("Tenant isolation", () => { it("should inject tenant guards for single table", () => { const context = createTestContext({ diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 4ac85b559eb..b261085ba08 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -2855,7 +2855,7 @@ export class ClickHousePrinter { if (funcMeta) { validateFunctionArgs(node.args, funcMeta.minArgs, funcMeta.maxArgs, name); - const args = node.args.map((arg) => this.visit(arg)); + const args = this.visitCallArgs(name, node.args); const params = node.params ? node.params.map((p) => this.visit(p)) : null; const paramsPart = params ? `(${params.join(", ")})` : ""; return `${funcMeta.clickhouseName}${paramsPart}(${args.join(", ")})`; @@ -2865,6 +2865,85 @@ export class ClickHousePrinter { throw new QueryError(`Unknown function: ${name}`); } + /** + * Valid ClickHouse interval unit keywords used by date functions like dateAdd, dateDiff, etc. + */ + private static readonly INTERVAL_UNITS = new Set([ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year", + ]); + + /** + * Date functions whose first argument is an interval unit keyword. + * ClickHouse requires the unit as a bare keyword (e.g., `dateAdd(day, 7, col)`), + * not a string literal (e.g., `dateAdd('day', 7, col)` fails). + */ + private static readonly DATE_FUNCTIONS_WITH_INTERVAL_UNIT = new Set([ + "dateadd", + "datesub", + "datediff", + "datetrunc", + "date_add", + "date_sub", + "date_diff", + "date_trunc", + ]); + + /** + * Visit function call arguments, handling date functions that require an interval unit + * keyword as their first argument. For these functions, the first arg is output as a + * bare keyword instead of being parameterized or resolved as a column reference. + */ + private visitCallArgs(functionName: string, args: Expression[]): string[] { + const lowerName = functionName.toLowerCase(); + + if ( + ClickHousePrinter.DATE_FUNCTIONS_WITH_INTERVAL_UNIT.has(lowerName) && + args.length > 0 + ) { + const firstArg = args[0]; + const intervalUnit = this.extractIntervalUnit(firstArg); + + if (intervalUnit) { + return [intervalUnit, ...args.slice(1).map((arg) => this.visit(arg))]; + } + } + + return args.map((arg) => this.visit(arg)); + } + + /** + * Try to extract a valid interval unit keyword from an expression. + * Handles both string constants ('day') and bare identifiers (day). + * Returns the bare keyword string if valid, or null if not an interval unit. + */ + private extractIntervalUnit(expr: Expression): string | null { + if (expr.expression_type === "constant") { + const value = (expr as Constant).value; + if (typeof value === "string" && ClickHousePrinter.INTERVAL_UNITS.has(value.toLowerCase())) { + return value.toLowerCase(); + } + } + + if (expr.expression_type === "field") { + const chain = (expr as Field).chain; + if (chain.length === 1 && typeof chain[0] === "string") { + const name = chain[0].toLowerCase(); + if (ClickHousePrinter.INTERVAL_UNITS.has(name)) { + return name; + } + } + } + + return null; + } + private visitJoinConstraint(node: JoinConstraint): string { return this.visit(node.expr); } From c283a829a6684466aefc091401cba3f92aa1e281 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 12:44:28 +0000 Subject: [PATCH 2/6] Comprehensive tests for TRQL functions --- .../clickhouse/src/tsqlFunctions.test.ts | 738 ++++++++++++++++++ .../tsql/src/query/printer.test.ts | 6 +- internal-packages/tsql/src/query/printer.ts | 2 - 3 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 internal-packages/clickhouse/src/tsqlFunctions.test.ts diff --git a/internal-packages/clickhouse/src/tsqlFunctions.test.ts b/internal-packages/clickhouse/src/tsqlFunctions.test.ts new file mode 100644 index 00000000000..10f751258ec --- /dev/null +++ b/internal-packages/clickhouse/src/tsqlFunctions.test.ts @@ -0,0 +1,738 @@ +import { clickhouseTest } from "@internal/testcontainers"; +import { z } from "zod"; +import { ClickhouseClient } from "./client/client.js"; +import { executeTSQL, type TableSchema } from "./client/tsql.js"; +import { insertTaskRuns } from "./taskRuns.js"; +import { column } from "@internal/tsql"; + +/** + * Schema definition for task_runs table used in function tests. + * Includes numeric, string, datetime, and array columns for exercising all function categories. + */ +const taskRunsSchema: TableSchema = { + name: "task_runs", + clickhouseName: "trigger_dev.task_runs_v2", + columns: { + run_id: { name: "run_id", ...column("String") }, + friendly_id: { name: "friendly_id", ...column("String") }, + status: { name: "status", ...column("String") }, + task_identifier: { name: "task_identifier", ...column("String") }, + queue: { name: "queue", ...column("String") }, + environment_id: { name: "environment_id", ...column("String") }, + environment_type: { name: "environment_type", ...column("String") }, + organization_id: { name: "organization_id", ...column("String") }, + project_id: { name: "project_id", ...column("String") }, + created_at: { name: "created_at", ...column("DateTime64") }, + updated_at: { name: "updated_at", ...column("DateTime64") }, + started_at: { name: "started_at", ...column("Nullable(DateTime64)") }, + completed_at: { name: "completed_at", ...column("Nullable(DateTime64)") }, + is_test: { name: "is_test", ...column("UInt8") }, + tags: { name: "tags", ...column("Array(String)") }, + usage_duration_ms: { name: "usage_duration_ms", ...column("UInt32") }, + cost_in_cents: { name: "cost_in_cents", ...column("Float64") }, + attempt: { name: "attempt", ...column("UInt8") }, + depth: { name: "depth", ...column("UInt8") }, + }, + tenantColumns: { + organizationId: "organization_id", + projectId: "project_id", + environmentId: "environment_id", + }, +}; + +const enforcedWhereClause = { + organization_id: { op: "eq" as const, value: "org_tenant1" }, + project_id: { op: "eq" as const, value: "proj_tenant1" }, + environment_id: { op: "eq" as const, value: "env_tenant1" }, +}; + +const defaultTaskRun = { + environment_id: "env_tenant1", + environment_type: "DEVELOPMENT", + organization_id: "org_tenant1", + project_id: "proj_tenant1", + run_id: "run_func_test_1", + friendly_id: "friendly_func_test_1", + attempt: 1, + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + task_identifier: "my-task", + queue: "my-queue", + schedule_id: "", + batch_id: "", + created_at: Date.now(), + updated_at: Date.now(), + started_at: Date.now() - 5000, + completed_at: Date.now(), + tags: ["tag-a", "tag-b"], + output: null, + error: null, + usage_duration_ms: 4500, + cost_in_cents: 1.5, + base_cost_in_cents: 0.5, + task_version: "1.0.0", + sdk_version: "4.0.0", + cli_version: "4.0.0", + machine_preset: "small-1x", + is_test: false, + span_id: "span_123", + trace_id: "trace_123", + idempotency_key: "idem_123", + expiration_ttl: "", + root_run_id: "", + parent_run_id: "", + depth: 2, + concurrency_key: "", + bulk_action_group_ids: [] as string[], + _version: "1", +}; + +/** + * Helper: execute a TSQL query and assert no errors. + */ +async function assertQueryExecutes( + client: ClickhouseClient, + tsqlQuery: string +): Promise { + const [error] = await executeTSQL(client, { + name: "func-test", + query: tsqlQuery, + schema: z.record(z.any()), + enforcedWhereClause, + tableSchema: [taskRunsSchema], + }); + + if (error) { + throw new Error(`Query failed: ${tsqlQuery}\n\nError: ${error.message}`); + } +} + +/** + * Helper: set up a client with test data inserted. + */ +async function setupClient(clickhouseContainer: { getConnectionUrl(): string }) { + const client = new ClickhouseClient({ + name: "func-test", + url: clickhouseContainer.getConnectionUrl(), + }); + + const insert = insertTaskRuns(client, { async_insert: 0 }); + const [insertError] = await insert([defaultTaskRun]); + expect(insertError).toBeNull(); + + return client; +} + +/** + * Helper: run all test cases in a single ClickHouse container. + * Each case is a [name, tsqlQuery] tuple. + */ +async function runCases( + client: ClickhouseClient, + cases: [string, string][] +): Promise { + const failures: string[] = []; + + for (const [name, query] of cases) { + try { + await assertQueryExecutes(client, query); + } catch (e) { + failures.push(` ${name}: ${(e as Error).message}`); + } + } + + if (failures.length > 0) { + throw new Error( + `${failures.length}/${cases.length} function(s) failed:\n${failures.join("\n")}` + ); + } +} + +const url = "https://user:pass@www.example.com:8080/path/page?q=1&r=2#frag"; + +describe("TSQL Function Smoke Tests", () => { + // ─── Arithmetic functions ───────────────────────────────────────────────── + + clickhouseTest("Arithmetic functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["plus", "SELECT plus(usage_duration_ms, 1) AS r FROM task_runs"], + ["minus", "SELECT minus(usage_duration_ms, 1) AS r FROM task_runs"], + ["multiply", "SELECT multiply(usage_duration_ms, 2) AS r FROM task_runs"], + ["divide", "SELECT divide(usage_duration_ms, 2) AS r FROM task_runs"], + ["intDiv", "SELECT intDiv(usage_duration_ms, 2) AS r FROM task_runs"], + ["intDivOrZero", "SELECT intDivOrZero(usage_duration_ms, 0) AS r FROM task_runs"], + ["modulo", "SELECT modulo(usage_duration_ms, 3) AS r FROM task_runs"], + ["moduloOrZero", "SELECT moduloOrZero(usage_duration_ms, 0) AS r FROM task_runs"], + ["positiveModulo", "SELECT positiveModulo(usage_duration_ms, 3) AS r FROM task_runs"], + ["negate", "SELECT negate(cost_in_cents) AS r FROM task_runs"], + ["abs", "SELECT abs(cost_in_cents) AS r FROM task_runs"], + ["gcd", "SELECT gcd(12, 8) AS r FROM task_runs"], + ["lcm", "SELECT lcm(12, 8) AS r FROM task_runs"], + ]); + }); + + // ─── Mathematical functions ─────────────────────────────────────────────── + + clickhouseTest("Mathematical functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["exp", "SELECT exp(1) AS r FROM task_runs"], + ["log", "SELECT log(2.718) AS r FROM task_runs"], + ["ln", "SELECT ln(2.718) AS r FROM task_runs"], + ["exp2", "SELECT exp2(3) AS r FROM task_runs"], + ["log2", "SELECT log2(8) AS r FROM task_runs"], + ["exp10", "SELECT exp10(2) AS r FROM task_runs"], + ["log10", "SELECT log10(100) AS r FROM task_runs"], + ["sqrt", "SELECT sqrt(16) AS r FROM task_runs"], + ["cbrt", "SELECT cbrt(27) AS r FROM task_runs"], + ["erf", "SELECT erf(1) AS r FROM task_runs"], + ["erfc", "SELECT erfc(1) AS r FROM task_runs"], + ["lgamma", "SELECT lgamma(5) AS r FROM task_runs"], + ["tgamma", "SELECT tgamma(5) AS r FROM task_runs"], + ["sin", "SELECT sin(1) AS r FROM task_runs"], + ["cos", "SELECT cos(1) AS r FROM task_runs"], + ["tan", "SELECT tan(1) AS r FROM task_runs"], + ["asin", "SELECT asin(0.5) AS r FROM task_runs"], + ["acos", "SELECT acos(0.5) AS r FROM task_runs"], + ["atan", "SELECT atan(1) AS r FROM task_runs"], + ["pow", "SELECT pow(2, 3) AS r FROM task_runs"], + ["power", "SELECT power(2, 3) AS r FROM task_runs"], + ["round", "SELECT round(3.14159, 2) AS r FROM task_runs"], + ["floor", "SELECT floor(3.7) AS r FROM task_runs"], + ["ceil", "SELECT ceil(3.2) AS r FROM task_runs"], + ["ceiling", "SELECT ceiling(3.2) AS r FROM task_runs"], + ["trunc", "SELECT trunc(3.7) AS r FROM task_runs"], + ["truncate", "SELECT truncate(3.7) AS r FROM task_runs"], + ["sign", "SELECT sign(-5) AS r FROM task_runs"], + ]); + }); + + // ─── String functions ───────────────────────────────────────────────────── + + clickhouseTest("String functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["empty", "SELECT empty(status) AS r FROM task_runs"], + ["notEmpty", "SELECT notEmpty(status) AS r FROM task_runs"], + ["length", "SELECT length(status) AS r FROM task_runs"], + ["lengthUTF8", "SELECT lengthUTF8(status) AS r FROM task_runs"], + ["char_length", "SELECT char_length(status) AS r FROM task_runs"], + ["character_length", "SELECT character_length(status) AS r FROM task_runs"], + ["lower", "SELECT lower(status) AS r FROM task_runs"], + ["upper", "SELECT upper(status) AS r FROM task_runs"], + ["lowerUTF8", "SELECT lowerUTF8(status) AS r FROM task_runs"], + ["upperUTF8", "SELECT upperUTF8(status) AS r FROM task_runs"], + ["reverse", "SELECT reverse(status) AS r FROM task_runs"], + ["reverseUTF8", "SELECT reverseUTF8(status) AS r FROM task_runs"], + ["concat", "SELECT concat(status, '-', run_id) AS r FROM task_runs"], + ["substring", "SELECT substring(status, 1, 3) AS r FROM task_runs"], + ["substr", "SELECT substr(status, 1, 3) AS r FROM task_runs"], + ["mid", "SELECT mid(status, 1, 3) AS r FROM task_runs"], + ["substringUTF8", "SELECT substringUTF8(status, 1, 3) AS r FROM task_runs"], + [ + "appendTrailingCharIfAbsent", + "SELECT appendTrailingCharIfAbsent(status, '!') AS r FROM task_runs", + ], + ["base64Encode", "SELECT base64Encode(status) AS r FROM task_runs"], + ["base64Decode", "SELECT base64Decode(base64Encode(status)) AS r FROM task_runs"], + ["tryBase64Decode", "SELECT tryBase64Decode('aGVsbG8=') AS r FROM task_runs"], + ["endsWith", "SELECT endsWith(status, 'LY') AS r FROM task_runs"], + ["startsWith", "SELECT startsWith(status, 'COM') AS r FROM task_runs"], + ["trim", "SELECT trim(status) AS r FROM task_runs"], + ["trimLeft", "SELECT trimLeft(status) AS r FROM task_runs"], + ["trimRight", "SELECT trimRight(status) AS r FROM task_runs"], + ["ltrim", "SELECT ltrim(status) AS r FROM task_runs"], + ["rtrim", "SELECT rtrim(status) AS r FROM task_runs"], + ["leftPad", "SELECT leftPad(status, 30, '*') AS r FROM task_runs"], + ["rightPad", "SELECT rightPad(status, 30, '*') AS r FROM task_runs"], + ["leftPadUTF8", "SELECT leftPadUTF8(status, 30, '*') AS r FROM task_runs"], + ["rightPadUTF8", "SELECT rightPadUTF8(status, 30, '*') AS r FROM task_runs"], + ["left", "SELECT left(status, 3) AS r FROM task_runs"], + ["right", "SELECT right(status, 3) AS r FROM task_runs"], + ["repeat", "SELECT repeat(status, 2) AS r FROM task_runs"], + ["space", "SELECT space(5) AS r FROM task_runs"], + ["replace", "SELECT replace(status, 'COMPLETED', 'DONE') AS r FROM task_runs"], + ["replaceOne", "SELECT replaceOne(status, 'COMPLETED', 'DONE') AS r FROM task_runs"], + ["replaceAll", "SELECT replaceAll(status, 'COMPLETED', 'DONE') AS r FROM task_runs"], + ["replaceRegexpOne", "SELECT replaceRegexpOne(status, '[A-Z]+', 'X') AS r FROM task_runs"], + ["replaceRegexpAll", "SELECT replaceRegexpAll(status, '[A-Z]', 'x') AS r FROM task_runs"], + ["position", "SELECT position(status, 'COM') AS r FROM task_runs"], + ["positionCaseInsensitive", "SELECT positionCaseInsensitive(status, 'com') AS r FROM task_runs"], + ["positionUTF8", "SELECT positionUTF8(status, 'COM') AS r FROM task_runs"], + ["positionCaseInsensitiveUTF8", "SELECT positionCaseInsensitiveUTF8(status, 'com') AS r FROM task_runs"], + ["locate", "SELECT locate(status, 'COM') AS r FROM task_runs"], + ["match", "SELECT match(status, 'COMPLETED.*') AS r FROM task_runs"], + ["like", "SELECT like(status, '%COMPLETED%') AS r FROM task_runs"], + ["ilike", "SELECT ilike(status, '%completed%') AS r FROM task_runs"], + ["notLike", "SELECT notLike(status, '%PENDING%') AS r FROM task_runs"], + ["notILike", "SELECT notILike(status, '%pending%') AS r FROM task_runs"], + ["splitByChar", "SELECT splitByChar('_', status) AS r FROM task_runs"], + ["splitByString", "SELECT splitByString('_', status) AS r FROM task_runs"], + ["splitByRegexp", "SELECT splitByRegexp('_', status) AS r FROM task_runs"], + ["arrayStringConcat", "SELECT arrayStringConcat(tags, ',') AS r FROM task_runs"], + ["format", "SELECT format('{0}-{1}', status, run_id) AS r FROM task_runs"], + ]); + }); + + // ─── Null functions ─────────────────────────────────────────────────────── + + clickhouseTest("Null functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["coalesce", "SELECT coalesce(started_at, now()) AS r FROM task_runs"], + ["ifNull", "SELECT ifNull(started_at, now()) AS r FROM task_runs"], + ["nullIf", "SELECT nullIf(status, 'PENDING') AS r FROM task_runs"], + ["assumeNotNull", "SELECT assumeNotNull(started_at) AS r FROM task_runs"], + ["toNullable", "SELECT toNullable(status) AS r FROM task_runs"], + ["isNull", "SELECT isNull(started_at) AS r FROM task_runs"], + ["isNotNull", "SELECT isNotNull(started_at) AS r FROM task_runs"], + ]); + }); + + // ─── Conditional functions ──────────────────────────────────────────────── + + clickhouseTest("Conditional functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["if", "SELECT if(usage_duration_ms > 1000, 'slow', 'fast') AS r FROM task_runs"], + [ + "multiIf", + "SELECT multiIf(usage_duration_ms > 5000, 'slow', usage_duration_ms > 1000, 'medium', 'fast') AS r FROM task_runs", + ], + ]); + }); + + // ─── Comparison functions ───────────────────────────────────────────────── + + clickhouseTest("Comparison functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["equals", "SELECT equals(status, 'PENDING') AS r FROM task_runs"], + ["notEquals", "SELECT notEquals(status, 'PENDING') AS r FROM task_runs"], + ["less", "SELECT less(usage_duration_ms, 9999) AS r FROM task_runs"], + ["greater", "SELECT greater(usage_duration_ms, 0) AS r FROM task_runs"], + ["lessOrEquals", "SELECT lessOrEquals(usage_duration_ms, 9999) AS r FROM task_runs"], + ["greaterOrEquals", "SELECT greaterOrEquals(usage_duration_ms, 0) AS r FROM task_runs"], + ]); + }); + + // ─── Logical functions ──────────────────────────────────────────────────── + + clickhouseTest("Logical functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["and", "SELECT and(usage_duration_ms > 0, is_test = 0) AS r FROM task_runs"], + ["or", "SELECT or(usage_duration_ms > 9999, is_test = 0) AS r FROM task_runs"], + ["xor", "SELECT xor(usage_duration_ms > 0, is_test = 1) AS r FROM task_runs"], + ["not", "SELECT not(is_test) AS r FROM task_runs"], + ]); + }); + + // ─── Type conversion functions ──────────────────────────────────────────── + + clickhouseTest("Type conversion functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + // toString: Skipped - TSQL_COMPARISON_MAPPING uses `in` operator which matches + // Object.prototype.toString, causing it to be treated as a comparison function. + // ["toString", "SELECT toString(usage_duration_ms) AS r FROM task_runs"], + ["toFixedString", "SELECT toFixedString(status, 30) AS r FROM task_runs"], + ["toUInt8", "SELECT toUInt8(is_test) AS r FROM task_runs"], + ["toUInt16", "SELECT toUInt16(usage_duration_ms) AS r FROM task_runs"], + ["toUInt32", "SELECT toUInt32(usage_duration_ms) AS r FROM task_runs"], + ["toUInt64", "SELECT toUInt64(usage_duration_ms) AS r FROM task_runs"], + ["toInt8", "SELECT toInt8(1) AS r FROM task_runs"], + ["toInt16", "SELECT toInt16(1) AS r FROM task_runs"], + ["toInt32", "SELECT toInt32(1) AS r FROM task_runs"], + ["toInt64", "SELECT toInt64(usage_duration_ms) AS r FROM task_runs"], + ["toInt128", "SELECT toInt128(1) AS r FROM task_runs"], + ["toInt256", "SELECT toInt256(1) AS r FROM task_runs"], + ["toUInt128", "SELECT toUInt128(1) AS r FROM task_runs"], + ["toUInt256", "SELECT toUInt256(1) AS r FROM task_runs"], + ["toFloat32", "SELECT toFloat32(cost_in_cents) AS r FROM task_runs"], + ["toFloat64", "SELECT toFloat64(cost_in_cents) AS r FROM task_runs"], + ["toDecimal32", "SELECT toDecimal32(cost_in_cents, 2) AS r FROM task_runs"], + ["toDecimal64", "SELECT toDecimal64(cost_in_cents, 2) AS r FROM task_runs"], + ["toDecimal128", "SELECT toDecimal128(cost_in_cents, 2) AS r FROM task_runs"], + ["toDecimal256", "SELECT toDecimal256(cost_in_cents, 2) AS r FROM task_runs"], + ["toDate", "SELECT toDate(created_at) AS r FROM task_runs"], + ["toDateOrNull", "SELECT toDateOrNull('2024-01-01') AS r FROM task_runs"], + ["toDateOrZero", "SELECT toDateOrZero('invalid') AS r FROM task_runs"], + ["toDate32", "SELECT toDate32(created_at) AS r FROM task_runs"], + ["toDate32OrNull", "SELECT toDate32OrNull('2024-01-01') AS r FROM task_runs"], + ["toDate32OrZero", "SELECT toDate32OrZero('invalid') AS r FROM task_runs"], + ["toDateTime", "SELECT toDateTime(created_at) AS r FROM task_runs"], + ["toDateTimeOrNull", "SELECT toDateTimeOrNull('2024-01-01 00:00:00') AS r FROM task_runs"], + ["toDateTimeOrZero", "SELECT toDateTimeOrZero('invalid') AS r FROM task_runs"], + ["toDateTime64", "SELECT toDateTime64(created_at, 3) AS r FROM task_runs"], + ["toDateTime64OrNull", "SELECT toDateTime64OrNull('2024-01-01 00:00:00.000', 3) AS r FROM task_runs"], + ["toDateTime64OrZero", "SELECT toDateTime64OrZero('invalid', 3) AS r FROM task_runs"], + ["toTypeName", "SELECT toTypeName(status) AS r FROM task_runs"], + ]); + }); + + // ─── Date/time functions ────────────────────────────────────────────────── + + clickhouseTest("Date/time functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["now", "SELECT now() AS r FROM task_runs"], + ["now64", "SELECT now64() AS r FROM task_runs"], + ["today", "SELECT today() AS r FROM task_runs"], + ["yesterday", "SELECT yesterday() AS r FROM task_runs"], + ["toYear", "SELECT toYear(created_at) AS r FROM task_runs"], + ["toQuarter", "SELECT toQuarter(created_at) AS r FROM task_runs"], + ["toMonth", "SELECT toMonth(created_at) AS r FROM task_runs"], + ["toDayOfYear", "SELECT toDayOfYear(created_at) AS r FROM task_runs"], + ["toDayOfMonth", "SELECT toDayOfMonth(created_at) AS r FROM task_runs"], + ["toDayOfWeek", "SELECT toDayOfWeek(created_at) AS r FROM task_runs"], + ["toHour", "SELECT toHour(created_at) AS r FROM task_runs"], + ["toMinute", "SELECT toMinute(created_at) AS r FROM task_runs"], + ["toSecond", "SELECT toSecond(created_at) AS r FROM task_runs"], + ["toUnixTimestamp", "SELECT toUnixTimestamp(created_at) AS r FROM task_runs"], + ["toStartOfYear", "SELECT toStartOfYear(created_at) AS r FROM task_runs"], + ["toStartOfQuarter", "SELECT toStartOfQuarter(created_at) AS r FROM task_runs"], + ["toStartOfMonth", "SELECT toStartOfMonth(created_at) AS r FROM task_runs"], + ["toMonday", "SELECT toMonday(created_at) AS r FROM task_runs"], + ["toStartOfWeek", "SELECT toStartOfWeek(created_at) AS r FROM task_runs"], + ["toStartOfDay", "SELECT toStartOfDay(created_at) AS r FROM task_runs"], + ["toStartOfHour", "SELECT toStartOfHour(created_at) AS r FROM task_runs"], + ["toStartOfMinute", "SELECT toStartOfMinute(created_at) AS r FROM task_runs"], + ["toStartOfSecond", "SELECT toStartOfSecond(created_at) AS r FROM task_runs"], + ["toStartOfFiveMinutes", "SELECT toStartOfFiveMinutes(created_at) AS r FROM task_runs"], + ["toStartOfTenMinutes", "SELECT toStartOfTenMinutes(created_at) AS r FROM task_runs"], + ["toStartOfFifteenMinutes", "SELECT toStartOfFifteenMinutes(created_at) AS r FROM task_runs"], + ["toStartOfInterval", "SELECT toStartOfInterval(created_at, INTERVAL 1 hour) AS r FROM task_runs"], + ["toTime", "SELECT toTime(created_at) AS r FROM task_runs"], + ["toISOYear", "SELECT toISOYear(created_at) AS r FROM task_runs"], + ["toISOWeek", "SELECT toISOWeek(created_at) AS r FROM task_runs"], + ["toWeek", "SELECT toWeek(created_at) AS r FROM task_runs"], + ["toYearWeek", "SELECT toYearWeek(created_at) AS r FROM task_runs"], + ["dateAdd (string unit)", "SELECT dateAdd('day', 7, created_at) AS r FROM task_runs"], + ["dateAdd (keyword unit)", "SELECT dateAdd(day, 7, created_at) AS r FROM task_runs"], + ["dateSub (string unit)", "SELECT dateSub('hour', 1, created_at) AS r FROM task_runs"], + ["dateDiff (string unit)", "SELECT dateDiff('minute', created_at, updated_at) AS r FROM task_runs"], + ["dateTrunc (string unit)", "SELECT dateTrunc('month', created_at) AS r FROM task_runs"], + ["date_add (string unit)", "SELECT date_add('day', 7, created_at) AS r FROM task_runs"], + ["date_sub (string unit)", "SELECT date_sub('hour', 1, created_at) AS r FROM task_runs"], + ["date_diff (string unit)", "SELECT date_diff('minute', created_at, updated_at) AS r FROM task_runs"], + ["date_trunc (string unit)", "SELECT date_trunc('month', created_at) AS r FROM task_runs"], + ["addSeconds", "SELECT addSeconds(created_at, 10) AS r FROM task_runs"], + ["addMinutes", "SELECT addMinutes(created_at, 10) AS r FROM task_runs"], + ["addHours", "SELECT addHours(created_at, 1) AS r FROM task_runs"], + ["addDays", "SELECT addDays(created_at, 1) AS r FROM task_runs"], + ["addWeeks", "SELECT addWeeks(created_at, 1) AS r FROM task_runs"], + ["addMonths", "SELECT addMonths(created_at, 1) AS r FROM task_runs"], + ["addQuarters", "SELECT addQuarters(created_at, 1) AS r FROM task_runs"], + ["addYears", "SELECT addYears(created_at, 1) AS r FROM task_runs"], + ["subtractSeconds", "SELECT subtractSeconds(created_at, 10) AS r FROM task_runs"], + ["subtractMinutes", "SELECT subtractMinutes(created_at, 10) AS r FROM task_runs"], + ["subtractHours", "SELECT subtractHours(created_at, 1) AS r FROM task_runs"], + ["subtractDays", "SELECT subtractDays(created_at, 1) AS r FROM task_runs"], + ["subtractWeeks", "SELECT subtractWeeks(created_at, 1) AS r FROM task_runs"], + ["subtractMonths", "SELECT subtractMonths(created_at, 1) AS r FROM task_runs"], + ["subtractQuarters", "SELECT subtractQuarters(created_at, 1) AS r FROM task_runs"], + ["subtractYears", "SELECT subtractYears(created_at, 1) AS r FROM task_runs"], + ["toTimeZone", "SELECT toTimeZone(created_at, 'America/New_York') AS r FROM task_runs"], + ["formatDateTime", "SELECT formatDateTime(created_at, '%Y-%m-%d') AS r FROM task_runs"], + ["parseDateTime", "SELECT parseDateTime('2024-01-15', '%Y-%m-%d') AS r FROM task_runs"], + ["parseDateTimeBestEffort", "SELECT parseDateTimeBestEffort('2024-01-15 10:30:00') AS r FROM task_runs"], + ["parseDateTimeBestEffortOrNull", "SELECT parseDateTimeBestEffortOrNull('invalid') AS r FROM task_runs"], + ["parseDateTimeBestEffortOrZero", "SELECT parseDateTimeBestEffortOrZero('invalid') AS r FROM task_runs"], + ["parseDateTime64BestEffort", "SELECT parseDateTime64BestEffort('2024-01-15 10:30:00.123') AS r FROM task_runs"], + ["parseDateTime64BestEffortOrNull", "SELECT parseDateTime64BestEffortOrNull('invalid') AS r FROM task_runs"], + ["parseDateTime64BestEffortOrZero", "SELECT parseDateTime64BestEffortOrZero('invalid') AS r FROM task_runs"], + ]); + }); + + // ─── Interval functions ─────────────────────────────────────────────────── + + clickhouseTest("Interval functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["toIntervalSecond", "SELECT toIntervalSecond(10) AS r FROM task_runs"], + ["toIntervalMinute", "SELECT toIntervalMinute(5) AS r FROM task_runs"], + ["toIntervalHour", "SELECT toIntervalHour(1) AS r FROM task_runs"], + ["toIntervalDay", "SELECT toIntervalDay(7) AS r FROM task_runs"], + ["toIntervalWeek", "SELECT toIntervalWeek(2) AS r FROM task_runs"], + ["toIntervalMonth", "SELECT toIntervalMonth(3) AS r FROM task_runs"], + ["toIntervalQuarter", "SELECT toIntervalQuarter(1) AS r FROM task_runs"], + ["toIntervalYear", "SELECT toIntervalYear(1) AS r FROM task_runs"], + ]); + }); + + // ─── Array functions ────────────────────────────────────────────────────── + + clickhouseTest("Array functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["array", "SELECT array(1, 2, 3) AS r FROM task_runs"], + ["range", "SELECT range(5) AS r FROM task_runs"], + ["arrayElement", "SELECT arrayElement(tags, 1) AS r FROM task_runs"], + ["has", "SELECT has(tags, 'tag-a') AS r FROM task_runs"], + ["hasAll", "SELECT hasAll(tags, array('tag-a')) AS r FROM task_runs"], + ["hasAny", "SELECT hasAny(tags, array('tag-a', 'tag-c')) AS r FROM task_runs"], + ["hasSubstr", "SELECT hasSubstr(tags, array('tag-a')) AS r FROM task_runs"], + ["indexOf", "SELECT indexOf(tags, 'tag-a') AS r FROM task_runs"], + ["arrayCount", "SELECT arrayCount(array(1, 0, 1, 0)) AS r FROM task_runs"], + ["countEqual", "SELECT countEqual(tags, 'tag-a') AS r FROM task_runs"], + ["arrayEnumerate", "SELECT arrayEnumerate(tags) AS r FROM task_runs"], + ["arrayEnumerateDense", "SELECT arrayEnumerateDense(tags) AS r FROM task_runs"], + ["arrayEnumerateUniq", "SELECT arrayEnumerateUniq(tags) AS r FROM task_runs"], + ["arrayPopBack", "SELECT arrayPopBack(tags) AS r FROM task_runs"], + ["arrayPopFront", "SELECT arrayPopFront(tags) AS r FROM task_runs"], + ["arrayPushBack", "SELECT arrayPushBack(tags, 'tag-new') AS r FROM task_runs"], + ["arrayPushFront", "SELECT arrayPushFront(tags, 'tag-new') AS r FROM task_runs"], + ["arrayResize", "SELECT arrayResize(tags, 5, '') AS r FROM task_runs"], + ["arraySlice", "SELECT arraySlice(tags, 1, 1) AS r FROM task_runs"], + ["arraySort", "SELECT arraySort(tags) AS r FROM task_runs"], + ["arrayReverseSort", "SELECT arrayReverseSort(tags) AS r FROM task_runs"], + ["arrayShuffle", "SELECT arrayShuffle(tags) AS r FROM task_runs"], + ["arrayUniq", "SELECT arrayUniq(tags) AS r FROM task_runs"], + ["arrayDifference", "SELECT arrayDifference(array(1, 2, 5)) AS r FROM task_runs"], + ["arrayDistinct", "SELECT arrayDistinct(tags) AS r FROM task_runs"], + ["arrayIntersect", "SELECT arrayIntersect(tags, array('tag-a')) AS r FROM task_runs"], + ["arrayReduce", "SELECT arrayReduce('sum', array(1, 2, 3)) AS r FROM task_runs"], + ["arrayReverse", "SELECT arrayReverse(tags) AS r FROM task_runs"], + ["arrayFlatten", "SELECT arrayFlatten(array(array(1, 2), array(3))) AS r FROM task_runs"], + ["arrayCompact", "SELECT arrayCompact(array(1, 1, 2, 3, 3)) AS r FROM task_runs"], + ["arrayZip", "SELECT arrayZip(array(1, 2), array('a', 'b')) AS r FROM task_runs"], + // Lambda-based array functions (arrayMap, arrayFilter, arrayExists, arrayAll, + // arrayFirst, arrayLast, arrayFirstIndex, arrayLastIndex) are skipped because + // TSQL schema validation resolves lambda variables (e.g. `x`) as column references. + ["arrayMin", "SELECT arrayMin(array(1, 2, 3)) AS r FROM task_runs"], + ["arrayMax", "SELECT arrayMax(array(1, 2, 3)) AS r FROM task_runs"], + ["arraySum", "SELECT arraySum(array(1, 2, 3)) AS r FROM task_runs"], + ["arrayAvg", "SELECT arrayAvg(array(1, 2, 3)) AS r FROM task_runs"], + ["arrayCumSum", "SELECT arrayCumSum(array(1, 2, 3)) AS r FROM task_runs"], + ["arrayCumSumNonNegative", "SELECT arrayCumSumNonNegative(array(1, -2, 3)) AS r FROM task_runs"], + ["arrayProduct", "SELECT arrayProduct(array(1, 2, 3)) AS r FROM task_runs"], + ["arrayJoin", "SELECT arrayJoin(array(1, 2, 3)) AS r FROM task_runs"], + ]); + }); + + // ─── JSON functions ─────────────────────────────────────────────────────── + + clickhouseTest("JSON functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["JSONHas", `SELECT JSONHas('{"a": 1}', 'a') AS r FROM task_runs`], + ["JSONLength", `SELECT JSONLength('{"a": 1, "b": 2}') AS r FROM task_runs`], + ["JSONType", `SELECT JSONType('{"a": 1}', 'a') AS r FROM task_runs`], + ["JSONExtractUInt", `SELECT JSONExtractUInt('{"a": 1}', 'a') AS r FROM task_runs`], + ["JSONExtractInt", `SELECT JSONExtractInt('{"a": -1}', 'a') AS r FROM task_runs`], + ["JSONExtractFloat", `SELECT JSONExtractFloat('{"a": 1.5}', 'a') AS r FROM task_runs`], + ["JSONExtractBool", `SELECT JSONExtractBool('{"a": true}', 'a') AS r FROM task_runs`], + ["JSONExtractString", `SELECT JSONExtractString('{"a": "hello"}', 'a') AS r FROM task_runs`], + ["JSONExtractRaw", `SELECT JSONExtractRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`], + ["JSONExtractArrayRaw", `SELECT JSONExtractArrayRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`], + ["JSONExtractKeys", `SELECT JSONExtractKeys('{"a": 1, "b": 2}') AS r FROM task_runs`], + ["toJSONString", "SELECT toJSONString(map('a', 1)) AS r FROM task_runs"], + ]); + }); + + // ─── Tuple functions ────────────────────────────────────────────────────── + + clickhouseTest("Tuple functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["tuple", "SELECT tuple(1, 'a', 3.14) AS r FROM task_runs"], + ["tupleElement", "SELECT tupleElement(tuple(1, 'a'), 1) AS r FROM task_runs"], + ["untuple", "SELECT untuple(tuple(1, 'a')) FROM task_runs"], + ]); + }); + + // ─── Map functions ──────────────────────────────────────────────────────── + + clickhouseTest("Map functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["map", "SELECT map('a', 1, 'b', 2) AS r FROM task_runs"], + ["mapFromArrays", "SELECT mapFromArrays(array('a', 'b'), array(1, 2)) AS r FROM task_runs"], + ["mapContains", "SELECT mapContains(map('a', 1), 'a') AS r FROM task_runs"], + ["mapKeys", "SELECT mapKeys(map('a', 1, 'b', 2)) AS r FROM task_runs"], + ["mapValues", "SELECT mapValues(map('a', 1, 'b', 2)) AS r FROM task_runs"], + ]); + }); + + // ─── Hash functions ─────────────────────────────────────────────────────── + + clickhouseTest("Hash functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["MD5", "SELECT hex(MD5('hello')) AS r FROM task_runs"], + ["SHA1", "SELECT hex(SHA1('hello')) AS r FROM task_runs"], + ["SHA224", "SELECT hex(SHA224('hello')) AS r FROM task_runs"], + ["SHA256", "SELECT hex(SHA256('hello')) AS r FROM task_runs"], + ["SHA384", "SELECT hex(SHA384('hello')) AS r FROM task_runs"], + ["SHA512", "SELECT hex(SHA512('hello')) AS r FROM task_runs"], + ["sipHash64", "SELECT sipHash64('hello') AS r FROM task_runs"], + ["sipHash128", "SELECT hex(sipHash128('hello')) AS r FROM task_runs"], + ["cityHash64", "SELECT cityHash64('hello') AS r FROM task_runs"], + ["intHash32", "SELECT intHash32(42) AS r FROM task_runs"], + ["intHash64", "SELECT intHash64(42) AS r FROM task_runs"], + ["farmHash64", "SELECT farmHash64('hello') AS r FROM task_runs"], + ["farmFingerprint64", "SELECT farmFingerprint64('hello') AS r FROM task_runs"], + ["xxHash32", "SELECT xxHash32('hello') AS r FROM task_runs"], + ["xxHash64", "SELECT xxHash64('hello') AS r FROM task_runs"], + ["murmurHash2_32", "SELECT murmurHash2_32('hello') AS r FROM task_runs"], + ["murmurHash2_64", "SELECT murmurHash2_64('hello') AS r FROM task_runs"], + ["murmurHash3_32", "SELECT murmurHash3_32('hello') AS r FROM task_runs"], + ["murmurHash3_64", "SELECT murmurHash3_64('hello') AS r FROM task_runs"], + ["murmurHash3_128", "SELECT hex(murmurHash3_128('hello')) AS r FROM task_runs"], + ["hex", "SELECT hex(255) AS r FROM task_runs"], + ["unhex", "SELECT unhex('48656C6C6F') AS r FROM task_runs"], + ]); + }); + + // ─── URL functions ──────────────────────────────────────────────────────── + + clickhouseTest("URL functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["protocol", `SELECT protocol('${url}') AS r FROM task_runs`], + ["domain", `SELECT domain('${url}') AS r FROM task_runs`], + ["domainWithoutWWW", `SELECT domainWithoutWWW('${url}') AS r FROM task_runs`], + ["topLevelDomain", `SELECT topLevelDomain('${url}') AS r FROM task_runs`], + ["firstSignificantSubdomain", `SELECT firstSignificantSubdomain('${url}') AS r FROM task_runs`], + ["cutToFirstSignificantSubdomain", `SELECT cutToFirstSignificantSubdomain('${url}') AS r FROM task_runs`], + ["cutToFirstSignificantSubdomainWithWWW", `SELECT cutToFirstSignificantSubdomainWithWWW('${url}') AS r FROM task_runs`], + ["port", `SELECT port('${url}') AS r FROM task_runs`], + ["path", `SELECT path('${url}') AS r FROM task_runs`], + ["pathFull", `SELECT pathFull('${url}') AS r FROM task_runs`], + ["queryString", `SELECT queryString('${url}') AS r FROM task_runs`], + ["fragment", `SELECT fragment('${url}') AS r FROM task_runs`], + ["extractURLParameter", `SELECT extractURLParameter('${url}', 'q') AS r FROM task_runs`], + ["extractURLParameters", `SELECT extractURLParameters('${url}') AS r FROM task_runs`], + ["encodeURLComponent", "SELECT encodeURLComponent('hello world') AS r FROM task_runs"], + ["decodeURLComponent", "SELECT decodeURLComponent('hello%20world') AS r FROM task_runs"], + ]); + }); + + // ─── UUID functions ─────────────────────────────────────────────────────── + + clickhouseTest("UUID functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["generateUUIDv4", "SELECT generateUUIDv4() AS r FROM task_runs"], + ["UUIDStringToNum", "SELECT UUIDStringToNum('00000000-0000-0000-0000-000000000000') AS r FROM task_runs"], + ["UUIDNumToString", "SELECT UUIDNumToString(UUIDStringToNum('00000000-0000-0000-0000-000000000000')) AS r FROM task_runs"], + ["toUUID", "SELECT toUUID('00000000-0000-0000-0000-000000000000') AS r FROM task_runs"], + ["toUUIDOrNull", "SELECT toUUIDOrNull('not-a-uuid') AS r FROM task_runs"], + ["toUUIDOrZero", "SELECT toUUIDOrZero('not-a-uuid') AS r FROM task_runs"], + ]); + }); + + // ─── Misc functions ─────────────────────────────────────────────────────── + + clickhouseTest("Misc functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["isFinite", "SELECT isFinite(1.0) AS r FROM task_runs"], + ["isInfinite", "SELECT isInfinite(1.0 / 0) AS r FROM task_runs"], + // ifNotFinite: TSQL definition has maxArgs: 1, but ClickHouse expects 2. + // Skipped until the function definition is fixed. + // ["ifNotFinite", "SELECT ifNotFinite(1.0 / 0, 0) AS r FROM task_runs"], + ["isNaN", "SELECT isNaN(0.0 / 0) AS r FROM task_runs"], + ["bar", "SELECT bar(usage_duration_ms, 0, 10000, 20) AS r FROM task_runs"], + [ + "transform", + "SELECT transform(status, array('PENDING', 'COMPLETED_SUCCESSFULLY'), array('P', 'C'), 'X') AS r FROM task_runs", + ], + ["formatReadableDecimalSize", "SELECT formatReadableDecimalSize(1000000) AS r FROM task_runs"], + ["formatReadableSize", "SELECT formatReadableSize(1000000) AS r FROM task_runs"], + ["formatReadableQuantity", "SELECT formatReadableQuantity(1000000) AS r FROM task_runs"], + ["formatReadableTimeDelta", "SELECT formatReadableTimeDelta(3661) AS r FROM task_runs"], + ["least", "SELECT least(1, 2) AS r FROM task_runs"], + ["greatest", "SELECT greatest(1, 2) AS r FROM task_runs"], + ["min2", "SELECT min2(1, 2) AS r FROM task_runs"], + ["max2", "SELECT max2(1, 2) AS r FROM task_runs"], + ]); + }); + + // ─── Aggregate functions ────────────────────────────────────────────────── + + clickhouseTest("Aggregate functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["count()", "SELECT count() AS r FROM task_runs"], + ["count(col)", "SELECT count(run_id) AS r FROM task_runs"], + ["countDistinct", "SELECT countDistinct(status) AS r FROM task_runs"], + ["min", "SELECT min(usage_duration_ms) AS r FROM task_runs"], + ["max", "SELECT max(usage_duration_ms) AS r FROM task_runs"], + ["sum", "SELECT sum(usage_duration_ms) AS r FROM task_runs"], + ["avg", "SELECT avg(usage_duration_ms) AS r FROM task_runs"], + ["any", "SELECT any(status) AS r FROM task_runs"], + ["anyLast", "SELECT anyLast(status) AS r FROM task_runs"], + ["anyHeavy", "SELECT anyHeavy(status) AS r FROM task_runs"], + ["argMin", "SELECT argMin(run_id, usage_duration_ms) AS r FROM task_runs"], + ["argMax", "SELECT argMax(run_id, usage_duration_ms) AS r FROM task_runs"], + ["stddevPop", "SELECT stddevPop(usage_duration_ms) AS r FROM task_runs"], + ["stddevSamp", "SELECT stddevSamp(usage_duration_ms) AS r FROM task_runs"], + ["varPop", "SELECT varPop(usage_duration_ms) AS r FROM task_runs"], + ["varSamp", "SELECT varSamp(usage_duration_ms) AS r FROM task_runs"], + ["covarPop", "SELECT covarPop(usage_duration_ms, cost_in_cents) AS r FROM task_runs"], + ["covarSamp", "SELECT covarSamp(usage_duration_ms, cost_in_cents) AS r FROM task_runs"], + ["corr", "SELECT corr(usage_duration_ms, cost_in_cents) AS r FROM task_runs"], + ["groupArray", "SELECT groupArray(status) AS r FROM task_runs"], + ["groupUniqArray", "SELECT groupUniqArray(status) AS r FROM task_runs"], + ["groupArrayMovingAvg", "SELECT groupArrayMovingAvg(usage_duration_ms) AS r FROM task_runs"], + ["groupArrayMovingSum", "SELECT groupArrayMovingSum(usage_duration_ms) AS r FROM task_runs"], + ["uniq", "SELECT uniq(status) AS r FROM task_runs"], + ["uniqExact", "SELECT uniqExact(status) AS r FROM task_runs"], + ["uniqHLL12", "SELECT uniqHLL12(status) AS r FROM task_runs"], + ["uniqTheta", "SELECT uniqTheta(status) AS r FROM task_runs"], + ["median", "SELECT median(usage_duration_ms) AS r FROM task_runs"], + ["medianExact", "SELECT medianExact(usage_duration_ms) AS r FROM task_runs"], + ["quantile", "SELECT quantile(0.95)(usage_duration_ms) AS r FROM task_runs"], + ["quantiles", "SELECT quantiles(0.5, 0.9, 0.99)(usage_duration_ms) AS r FROM task_runs"], + ["topK", "SELECT topK(3)(status) AS r FROM task_runs"], + ["simpleLinearRegression", "SELECT simpleLinearRegression(usage_duration_ms, cost_in_cents) AS r FROM task_runs"], + ["groupArraySample", "SELECT groupArraySample(2)(status) AS r FROM task_runs"], + ]); + }); + + // ─── Conditional aggregate functions ────────────────────────────────────── + + clickhouseTest("Conditional aggregate functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["countIf", "SELECT countIf(usage_duration_ms > 1000) AS r FROM task_runs"], + ["countDistinctIf", "SELECT countDistinctIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["minIf", "SELECT minIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["maxIf", "SELECT maxIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["sumIf", "SELECT sumIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["avgIf", "SELECT avgIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["anyIf", "SELECT anyIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["anyLastIf", "SELECT anyLastIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["anyHeavyIf", "SELECT anyHeavyIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["groupArrayIf", "SELECT groupArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["groupUniqArrayIf", "SELECT groupUniqArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["uniqIf", "SELECT uniqIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["uniqExactIf", "SELECT uniqExactIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + ["medianIf", "SELECT medianIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["quantileIf", "SELECT quantileIf(0.95)(usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["argMinIf", "SELECT argMinIf(run_id, usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ["argMaxIf", "SELECT argMaxIf(run_id, usage_duration_ms, is_test = 0) AS r FROM task_runs"], + ]); + }); + + // ─── Search functions ───────────────────────────────────────────────────── + + clickhouseTest("Search functions", async ({ clickhouseContainer }) => { + const client = await setupClient(clickhouseContainer); + await runCases(client, [ + ["multiMatchAny", "SELECT multiMatchAny(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs"], + ["multiMatchAnyIndex", "SELECT multiMatchAnyIndex(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs"], + ["multiMatchAllIndices", "SELECT multiMatchAllIndices(status, array('COMPLETED.*', 'PEND.*')) AS r FROM task_runs"], + ["multiSearchFirstPosition", "SELECT multiSearchFirstPosition(status, array('COMP', 'PEND')) AS r FROM task_runs"], + ["multiSearchFirstIndex", "SELECT multiSearchFirstIndex(status, array('COMP', 'PEND')) AS r FROM task_runs"], + ["multiSearchAny", "SELECT multiSearchAny(status, array('COMP', 'PEND')) AS r FROM task_runs"], + ["extract", "SELECT extract(status, '[A-Z]+') AS r FROM task_runs"], + ["extractAll", "SELECT extractAll(status, '[A-Z]+') AS r FROM task_runs"], + ["extractAllGroupsHorizontal", "SELECT extractAllGroupsHorizontal(status, '([A-Z]+)') AS r FROM task_runs"], + ["extractAllGroupsVertical", "SELECT extractAllGroupsVertical(status, '([A-Z]+)') AS r FROM task_runs"], + ]); + }); +}); diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index c8eefb48a0b..323cefac409 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -1274,13 +1274,13 @@ describe("ClickHousePrinter", () => { expect(sql).not.toContain("'hour'"); }); - it("should output dateTrunc with string interval as bare keyword", () => { + it("should keep dateTrunc interval as parameterized string (ClickHouse expects string)", () => { const { sql } = printQuery( "SELECT dateTrunc('month', created_at) AS month_start FROM task_runs" ); - expect(sql).toContain("dateTrunc(month, created_at)"); - expect(sql).not.toContain("'month'"); + expect(sql).toContain("dateTrunc("); + expect(sql).not.toContain("dateTrunc(month,"); }); it("should output date_add (underscore variant) with bare keyword", () => { diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index b261085ba08..7da137b25a9 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -2888,11 +2888,9 @@ export class ClickHousePrinter { "dateadd", "datesub", "datediff", - "datetrunc", "date_add", "date_sub", "date_diff", - "date_trunc", ]); /** From 27611efb607ade5e6b4ca78948766846c16f4d6e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 13:33:56 +0000 Subject: [PATCH 3/6] Added millisecond, microsecond and nanosecond support to date fns --- .../clickhouse/src/tsqlFunctions.test.ts | 3 +++ .../tsql/src/query/printer.test.ts | 25 +++++++++++++++++++ internal-packages/tsql/src/query/printer.ts | 3 +++ 3 files changed, 31 insertions(+) diff --git a/internal-packages/clickhouse/src/tsqlFunctions.test.ts b/internal-packages/clickhouse/src/tsqlFunctions.test.ts index 10f751258ec..471f5198058 100644 --- a/internal-packages/clickhouse/src/tsqlFunctions.test.ts +++ b/internal-packages/clickhouse/src/tsqlFunctions.test.ts @@ -413,6 +413,9 @@ describe("TSQL Function Smoke Tests", () => { ["dateAdd (keyword unit)", "SELECT dateAdd(day, 7, created_at) AS r FROM task_runs"], ["dateSub (string unit)", "SELECT dateSub('hour', 1, created_at) AS r FROM task_runs"], ["dateDiff (string unit)", "SELECT dateDiff('minute', created_at, updated_at) AS r FROM task_runs"], + ["dateDiff (millisecond)", "SELECT dateDiff('millisecond', created_at, updated_at) AS r FROM task_runs"], + ["dateDiff (microsecond)", "SELECT dateDiff('microsecond', created_at, updated_at) AS r FROM task_runs"], + ["dateDiff (nanosecond)", "SELECT dateDiff('nanosecond', created_at, updated_at) AS r FROM task_runs"], ["dateTrunc (string unit)", "SELECT dateTrunc('month', created_at) AS r FROM task_runs"], ["date_add (string unit)", "SELECT date_add('day', 7, created_at) AS r FROM task_runs"], ["date_sub (string unit)", "SELECT date_sub('hour', 1, created_at) AS r FROM task_runs"], diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 323cefac409..b2732669706 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -1306,6 +1306,31 @@ describe("ClickHousePrinter", () => { expect(sql).toContain("dateAdd(day, 7, created_at)"); }); + + it("should output dateDiff with sub-second units as bare keywords", () => { + const { sql } = printQuery( + "SELECT dateDiff('millisecond', started_at, completed_at) AS dur FROM task_runs" + ); + + expect(sql).toContain("dateDiff(millisecond,"); + expect(sql).not.toContain("'millisecond'"); + }); + + it("should output dateDiff with microsecond as bare keyword", () => { + const { sql } = printQuery( + "SELECT dateDiff('microsecond', started_at, completed_at) AS dur FROM task_runs" + ); + + expect(sql).toContain("dateDiff(microsecond,"); + }); + + it("should output dateDiff with nanosecond as bare keyword", () => { + const { sql } = printQuery( + "SELECT dateDiff('nanosecond', started_at, completed_at) AS dur FROM task_runs" + ); + + expect(sql).toContain("dateDiff(nanosecond,"); + }); }); describe("Tenant isolation", () => { diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 7da137b25a9..5f75cacedc1 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -2869,6 +2869,9 @@ export class ClickHousePrinter { * Valid ClickHouse interval unit keywords used by date functions like dateAdd, dateDiff, etc. */ private static readonly INTERVAL_UNITS = new Set([ + "nanosecond", + "microsecond", + "millisecond", "second", "minute", "hour", From d354d9483a225e16df6e16b87ccc8f1ba8026bf0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 15:29:01 +0000 Subject: [PATCH 4/6] Fix for fn case sensitivity. Got tostring to work --- .../clickhouse/src/tsqlFunctions.test.ts | 184 +++++++++++++----- internal-packages/tsql/src/query/functions.ts | 42 ++-- internal-packages/tsql/src/query/printer.ts | 2 +- 3 files changed, 171 insertions(+), 57 deletions(-) diff --git a/internal-packages/clickhouse/src/tsqlFunctions.test.ts b/internal-packages/clickhouse/src/tsqlFunctions.test.ts index 471f5198058..1dc07da8461 100644 --- a/internal-packages/clickhouse/src/tsqlFunctions.test.ts +++ b/internal-packages/clickhouse/src/tsqlFunctions.test.ts @@ -90,10 +90,7 @@ const defaultTaskRun = { /** * Helper: execute a TSQL query and assert no errors. */ -async function assertQueryExecutes( - client: ClickhouseClient, - tsqlQuery: string -): Promise { +async function assertQueryExecutes(client: ClickhouseClient, tsqlQuery: string): Promise { const [error] = await executeTSQL(client, { name: "func-test", query: tsqlQuery, @@ -127,10 +124,7 @@ async function setupClient(clickhouseContainer: { getConnectionUrl(): string }) * Helper: run all test cases in a single ClickHouse container. * Each case is a [name, tsqlQuery] tuple. */ -async function runCases( - client: ClickhouseClient, - cases: [string, string][] -): Promise { +async function runCases(client: ClickhouseClient, cases: [string, string][]): Promise { const failures: string[] = []; for (const [name, query] of cases) { @@ -258,9 +252,15 @@ describe("TSQL Function Smoke Tests", () => { ["replaceRegexpOne", "SELECT replaceRegexpOne(status, '[A-Z]+', 'X') AS r FROM task_runs"], ["replaceRegexpAll", "SELECT replaceRegexpAll(status, '[A-Z]', 'x') AS r FROM task_runs"], ["position", "SELECT position(status, 'COM') AS r FROM task_runs"], - ["positionCaseInsensitive", "SELECT positionCaseInsensitive(status, 'com') AS r FROM task_runs"], + [ + "positionCaseInsensitive", + "SELECT positionCaseInsensitive(status, 'com') AS r FROM task_runs", + ], ["positionUTF8", "SELECT positionUTF8(status, 'COM') AS r FROM task_runs"], - ["positionCaseInsensitiveUTF8", "SELECT positionCaseInsensitiveUTF8(status, 'com') AS r FROM task_runs"], + [ + "positionCaseInsensitiveUTF8", + "SELECT positionCaseInsensitiveUTF8(status, 'com') AS r FROM task_runs", + ], ["locate", "SELECT locate(status, 'COM') AS r FROM task_runs"], ["match", "SELECT match(status, 'COMPLETED.*') AS r FROM task_runs"], ["like", "SELECT like(status, '%COMPLETED%') AS r FROM task_runs"], @@ -334,9 +334,7 @@ describe("TSQL Function Smoke Tests", () => { clickhouseTest("Type conversion functions", async ({ clickhouseContainer }) => { const client = await setupClient(clickhouseContainer); await runCases(client, [ - // toString: Skipped - TSQL_COMPARISON_MAPPING uses `in` operator which matches - // Object.prototype.toString, causing it to be treated as a comparison function. - // ["toString", "SELECT toString(usage_duration_ms) AS r FROM task_runs"], + ["toString", "SELECT toString(usage_duration_ms) AS r FROM task_runs"], ["toFixedString", "SELECT toFixedString(status, 30) AS r FROM task_runs"], ["toUInt8", "SELECT toUInt8(is_test) AS r FROM task_runs"], ["toUInt16", "SELECT toUInt16(usage_duration_ms) AS r FROM task_runs"], @@ -366,7 +364,10 @@ describe("TSQL Function Smoke Tests", () => { ["toDateTimeOrNull", "SELECT toDateTimeOrNull('2024-01-01 00:00:00') AS r FROM task_runs"], ["toDateTimeOrZero", "SELECT toDateTimeOrZero('invalid') AS r FROM task_runs"], ["toDateTime64", "SELECT toDateTime64(created_at, 3) AS r FROM task_runs"], - ["toDateTime64OrNull", "SELECT toDateTime64OrNull('2024-01-01 00:00:00.000', 3) AS r FROM task_runs"], + [ + "toDateTime64OrNull", + "SELECT toDateTime64OrNull('2024-01-01 00:00:00.000', 3) AS r FROM task_runs", + ], ["toDateTime64OrZero", "SELECT toDateTime64OrZero('invalid', 3) AS r FROM task_runs"], ["toTypeName", "SELECT toTypeName(status) AS r FROM task_runs"], ]); @@ -403,7 +404,10 @@ describe("TSQL Function Smoke Tests", () => { ["toStartOfFiveMinutes", "SELECT toStartOfFiveMinutes(created_at) AS r FROM task_runs"], ["toStartOfTenMinutes", "SELECT toStartOfTenMinutes(created_at) AS r FROM task_runs"], ["toStartOfFifteenMinutes", "SELECT toStartOfFifteenMinutes(created_at) AS r FROM task_runs"], - ["toStartOfInterval", "SELECT toStartOfInterval(created_at, INTERVAL 1 hour) AS r FROM task_runs"], + [ + "toStartOfInterval", + "SELECT toStartOfInterval(created_at, INTERVAL 1 hour) AS r FROM task_runs", + ], ["toTime", "SELECT toTime(created_at) AS r FROM task_runs"], ["toISOYear", "SELECT toISOYear(created_at) AS r FROM task_runs"], ["toISOWeek", "SELECT toISOWeek(created_at) AS r FROM task_runs"], @@ -412,14 +416,29 @@ describe("TSQL Function Smoke Tests", () => { ["dateAdd (string unit)", "SELECT dateAdd('day', 7, created_at) AS r FROM task_runs"], ["dateAdd (keyword unit)", "SELECT dateAdd(day, 7, created_at) AS r FROM task_runs"], ["dateSub (string unit)", "SELECT dateSub('hour', 1, created_at) AS r FROM task_runs"], - ["dateDiff (string unit)", "SELECT dateDiff('minute', created_at, updated_at) AS r FROM task_runs"], - ["dateDiff (millisecond)", "SELECT dateDiff('millisecond', created_at, updated_at) AS r FROM task_runs"], - ["dateDiff (microsecond)", "SELECT dateDiff('microsecond', created_at, updated_at) AS r FROM task_runs"], - ["dateDiff (nanosecond)", "SELECT dateDiff('nanosecond', created_at, updated_at) AS r FROM task_runs"], + [ + "dateDiff (string unit)", + "SELECT dateDiff('minute', created_at, updated_at) AS r FROM task_runs", + ], + [ + "dateDiff (millisecond)", + "SELECT dateDiff('millisecond', created_at, updated_at) AS r FROM task_runs", + ], + [ + "dateDiff (microsecond)", + "SELECT dateDiff('microsecond', created_at, updated_at) AS r FROM task_runs", + ], + [ + "dateDiff (nanosecond)", + "SELECT dateDiff('nanosecond', created_at, updated_at) AS r FROM task_runs", + ], ["dateTrunc (string unit)", "SELECT dateTrunc('month', created_at) AS r FROM task_runs"], ["date_add (string unit)", "SELECT date_add('day', 7, created_at) AS r FROM task_runs"], ["date_sub (string unit)", "SELECT date_sub('hour', 1, created_at) AS r FROM task_runs"], - ["date_diff (string unit)", "SELECT date_diff('minute', created_at, updated_at) AS r FROM task_runs"], + [ + "date_diff (string unit)", + "SELECT date_diff('minute', created_at, updated_at) AS r FROM task_runs", + ], ["date_trunc (string unit)", "SELECT date_trunc('month', created_at) AS r FROM task_runs"], ["addSeconds", "SELECT addSeconds(created_at, 10) AS r FROM task_runs"], ["addMinutes", "SELECT addMinutes(created_at, 10) AS r FROM task_runs"], @@ -440,12 +459,30 @@ describe("TSQL Function Smoke Tests", () => { ["toTimeZone", "SELECT toTimeZone(created_at, 'America/New_York') AS r FROM task_runs"], ["formatDateTime", "SELECT formatDateTime(created_at, '%Y-%m-%d') AS r FROM task_runs"], ["parseDateTime", "SELECT parseDateTime('2024-01-15', '%Y-%m-%d') AS r FROM task_runs"], - ["parseDateTimeBestEffort", "SELECT parseDateTimeBestEffort('2024-01-15 10:30:00') AS r FROM task_runs"], - ["parseDateTimeBestEffortOrNull", "SELECT parseDateTimeBestEffortOrNull('invalid') AS r FROM task_runs"], - ["parseDateTimeBestEffortOrZero", "SELECT parseDateTimeBestEffortOrZero('invalid') AS r FROM task_runs"], - ["parseDateTime64BestEffort", "SELECT parseDateTime64BestEffort('2024-01-15 10:30:00.123') AS r FROM task_runs"], - ["parseDateTime64BestEffortOrNull", "SELECT parseDateTime64BestEffortOrNull('invalid') AS r FROM task_runs"], - ["parseDateTime64BestEffortOrZero", "SELECT parseDateTime64BestEffortOrZero('invalid') AS r FROM task_runs"], + [ + "parseDateTimeBestEffort", + "SELECT parseDateTimeBestEffort('2024-01-15 10:30:00') AS r FROM task_runs", + ], + [ + "parseDateTimeBestEffortOrNull", + "SELECT parseDateTimeBestEffortOrNull('invalid') AS r FROM task_runs", + ], + [ + "parseDateTimeBestEffortOrZero", + "SELECT parseDateTimeBestEffortOrZero('invalid') AS r FROM task_runs", + ], + [ + "parseDateTime64BestEffort", + "SELECT parseDateTime64BestEffort('2024-01-15 10:30:00.123') AS r FROM task_runs", + ], + [ + "parseDateTime64BestEffortOrNull", + "SELECT parseDateTime64BestEffortOrNull('invalid') AS r FROM task_runs", + ], + [ + "parseDateTime64BestEffortOrZero", + "SELECT parseDateTime64BestEffortOrZero('invalid') AS r FROM task_runs", + ], ]); }); @@ -509,7 +546,10 @@ describe("TSQL Function Smoke Tests", () => { ["arraySum", "SELECT arraySum(array(1, 2, 3)) AS r FROM task_runs"], ["arrayAvg", "SELECT arrayAvg(array(1, 2, 3)) AS r FROM task_runs"], ["arrayCumSum", "SELECT arrayCumSum(array(1, 2, 3)) AS r FROM task_runs"], - ["arrayCumSumNonNegative", "SELECT arrayCumSumNonNegative(array(1, -2, 3)) AS r FROM task_runs"], + [ + "arrayCumSumNonNegative", + "SELECT arrayCumSumNonNegative(array(1, -2, 3)) AS r FROM task_runs", + ], ["arrayProduct", "SELECT arrayProduct(array(1, 2, 3)) AS r FROM task_runs"], ["arrayJoin", "SELECT arrayJoin(array(1, 2, 3)) AS r FROM task_runs"], ]); @@ -529,7 +569,10 @@ describe("TSQL Function Smoke Tests", () => { ["JSONExtractBool", `SELECT JSONExtractBool('{"a": true}', 'a') AS r FROM task_runs`], ["JSONExtractString", `SELECT JSONExtractString('{"a": "hello"}', 'a') AS r FROM task_runs`], ["JSONExtractRaw", `SELECT JSONExtractRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`], - ["JSONExtractArrayRaw", `SELECT JSONExtractArrayRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`], + [ + "JSONExtractArrayRaw", + `SELECT JSONExtractArrayRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`, + ], ["JSONExtractKeys", `SELECT JSONExtractKeys('{"a": 1, "b": 2}') AS r FROM task_runs`], ["toJSONString", "SELECT toJSONString(map('a', 1)) AS r FROM task_runs"], ]); @@ -598,9 +641,18 @@ describe("TSQL Function Smoke Tests", () => { ["domain", `SELECT domain('${url}') AS r FROM task_runs`], ["domainWithoutWWW", `SELECT domainWithoutWWW('${url}') AS r FROM task_runs`], ["topLevelDomain", `SELECT topLevelDomain('${url}') AS r FROM task_runs`], - ["firstSignificantSubdomain", `SELECT firstSignificantSubdomain('${url}') AS r FROM task_runs`], - ["cutToFirstSignificantSubdomain", `SELECT cutToFirstSignificantSubdomain('${url}') AS r FROM task_runs`], - ["cutToFirstSignificantSubdomainWithWWW", `SELECT cutToFirstSignificantSubdomainWithWWW('${url}') AS r FROM task_runs`], + [ + "firstSignificantSubdomain", + `SELECT firstSignificantSubdomain('${url}') AS r FROM task_runs`, + ], + [ + "cutToFirstSignificantSubdomain", + `SELECT cutToFirstSignificantSubdomain('${url}') AS r FROM task_runs`, + ], + [ + "cutToFirstSignificantSubdomainWithWWW", + `SELECT cutToFirstSignificantSubdomainWithWWW('${url}') AS r FROM task_runs`, + ], ["port", `SELECT port('${url}') AS r FROM task_runs`], ["path", `SELECT path('${url}') AS r FROM task_runs`], ["pathFull", `SELECT pathFull('${url}') AS r FROM task_runs`], @@ -619,8 +671,14 @@ describe("TSQL Function Smoke Tests", () => { const client = await setupClient(clickhouseContainer); await runCases(client, [ ["generateUUIDv4", "SELECT generateUUIDv4() AS r FROM task_runs"], - ["UUIDStringToNum", "SELECT UUIDStringToNum('00000000-0000-0000-0000-000000000000') AS r FROM task_runs"], - ["UUIDNumToString", "SELECT UUIDNumToString(UUIDStringToNum('00000000-0000-0000-0000-000000000000')) AS r FROM task_runs"], + [ + "UUIDStringToNum", + "SELECT UUIDStringToNum('00000000-0000-0000-0000-000000000000') AS r FROM task_runs", + ], + [ + "UUIDNumToString", + "SELECT UUIDNumToString(UUIDStringToNum('00000000-0000-0000-0000-000000000000')) AS r FROM task_runs", + ], ["toUUID", "SELECT toUUID('00000000-0000-0000-0000-000000000000') AS r FROM task_runs"], ["toUUIDOrNull", "SELECT toUUIDOrNull('not-a-uuid') AS r FROM task_runs"], ["toUUIDOrZero", "SELECT toUUIDOrZero('not-a-uuid') AS r FROM task_runs"], @@ -643,7 +701,10 @@ describe("TSQL Function Smoke Tests", () => { "transform", "SELECT transform(status, array('PENDING', 'COMPLETED_SUCCESSFULLY'), array('P', 'C'), 'X') AS r FROM task_runs", ], - ["formatReadableDecimalSize", "SELECT formatReadableDecimalSize(1000000) AS r FROM task_runs"], + [ + "formatReadableDecimalSize", + "SELECT formatReadableDecimalSize(1000000) AS r FROM task_runs", + ], ["formatReadableSize", "SELECT formatReadableSize(1000000) AS r FROM task_runs"], ["formatReadableQuantity", "SELECT formatReadableQuantity(1000000) AS r FROM task_runs"], ["formatReadableTimeDelta", "SELECT formatReadableTimeDelta(3661) AS r FROM task_runs"], @@ -691,7 +752,10 @@ describe("TSQL Function Smoke Tests", () => { ["quantile", "SELECT quantile(0.95)(usage_duration_ms) AS r FROM task_runs"], ["quantiles", "SELECT quantiles(0.5, 0.9, 0.99)(usage_duration_ms) AS r FROM task_runs"], ["topK", "SELECT topK(3)(status) AS r FROM task_runs"], - ["simpleLinearRegression", "SELECT simpleLinearRegression(usage_duration_ms, cost_in_cents) AS r FROM task_runs"], + [ + "simpleLinearRegression", + "SELECT simpleLinearRegression(usage_duration_ms, cost_in_cents) AS r FROM task_runs", + ], ["groupArraySample", "SELECT groupArraySample(2)(status) AS r FROM task_runs"], ]); }); @@ -702,7 +766,10 @@ describe("TSQL Function Smoke Tests", () => { const client = await setupClient(clickhouseContainer); await runCases(client, [ ["countIf", "SELECT countIf(usage_duration_ms > 1000) AS r FROM task_runs"], - ["countDistinctIf", "SELECT countDistinctIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + [ + "countDistinctIf", + "SELECT countDistinctIf(status, usage_duration_ms > 0) AS r FROM task_runs", + ], ["minIf", "SELECT minIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], ["maxIf", "SELECT maxIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], ["sumIf", "SELECT sumIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], @@ -711,7 +778,10 @@ describe("TSQL Function Smoke Tests", () => { ["anyLastIf", "SELECT anyLastIf(status, usage_duration_ms > 0) AS r FROM task_runs"], ["anyHeavyIf", "SELECT anyHeavyIf(status, usage_duration_ms > 0) AS r FROM task_runs"], ["groupArrayIf", "SELECT groupArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs"], - ["groupUniqArrayIf", "SELECT groupUniqArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs"], + [ + "groupUniqArrayIf", + "SELECT groupUniqArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs", + ], ["uniqIf", "SELECT uniqIf(status, usage_duration_ms > 0) AS r FROM task_runs"], ["uniqExactIf", "SELECT uniqExactIf(status, usage_duration_ms > 0) AS r FROM task_runs"], ["medianIf", "SELECT medianIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"], @@ -726,16 +796,40 @@ describe("TSQL Function Smoke Tests", () => { clickhouseTest("Search functions", async ({ clickhouseContainer }) => { const client = await setupClient(clickhouseContainer); await runCases(client, [ - ["multiMatchAny", "SELECT multiMatchAny(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs"], - ["multiMatchAnyIndex", "SELECT multiMatchAnyIndex(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs"], - ["multiMatchAllIndices", "SELECT multiMatchAllIndices(status, array('COMPLETED.*', 'PEND.*')) AS r FROM task_runs"], - ["multiSearchFirstPosition", "SELECT multiSearchFirstPosition(status, array('COMP', 'PEND')) AS r FROM task_runs"], - ["multiSearchFirstIndex", "SELECT multiSearchFirstIndex(status, array('COMP', 'PEND')) AS r FROM task_runs"], - ["multiSearchAny", "SELECT multiSearchAny(status, array('COMP', 'PEND')) AS r FROM task_runs"], + [ + "multiMatchAny", + "SELECT multiMatchAny(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs", + ], + [ + "multiMatchAnyIndex", + "SELECT multiMatchAnyIndex(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs", + ], + [ + "multiMatchAllIndices", + "SELECT multiMatchAllIndices(status, array('COMPLETED.*', 'PEND.*')) AS r FROM task_runs", + ], + [ + "multiSearchFirstPosition", + "SELECT multiSearchFirstPosition(status, array('COMP', 'PEND')) AS r FROM task_runs", + ], + [ + "multiSearchFirstIndex", + "SELECT multiSearchFirstIndex(status, array('COMP', 'PEND')) AS r FROM task_runs", + ], + [ + "multiSearchAny", + "SELECT multiSearchAny(status, array('COMP', 'PEND')) AS r FROM task_runs", + ], ["extract", "SELECT extract(status, '[A-Z]+') AS r FROM task_runs"], ["extractAll", "SELECT extractAll(status, '[A-Z]+') AS r FROM task_runs"], - ["extractAllGroupsHorizontal", "SELECT extractAllGroupsHorizontal(status, '([A-Z]+)') AS r FROM task_runs"], - ["extractAllGroupsVertical", "SELECT extractAllGroupsVertical(status, '([A-Z]+)') AS r FROM task_runs"], + [ + "extractAllGroupsHorizontal", + "SELECT extractAllGroupsHorizontal(status, '([A-Z]+)') AS r FROM task_runs", + ], + [ + "extractAllGroupsVertical", + "SELECT extractAllGroupsVertical(status, '([A-Z]+)') AS r FROM task_runs", + ], ]); }); }); diff --git a/internal-packages/tsql/src/query/functions.ts b/internal-packages/tsql/src/query/functions.ts index d150b0636dd..5fe1d4f21d6 100644 --- a/internal-packages/tsql/src/query/functions.ts +++ b/internal-packages/tsql/src/query/functions.ts @@ -562,25 +562,45 @@ export const TSQL_AGGREGATIONS: Record = { }; /** - * Find a function in the TSQL functions map - * Supports case-insensitive lookup for non-case-sensitive functions + * Build a lowercase lookup map from a functions record. + * Uses a null-prototype object to avoid Object.prototype pollution (e.g. "toString"). + */ +function buildLowercaseMap( + functions: Record +): Record { + const map: Record = Object.create(null); + for (const [key, value] of Object.entries(functions)) { + map[key.toLowerCase()] = value; + } + return map; +} + +const FUNCTIONS_LOWERCASE = buildLowercaseMap(TSQL_CLICKHOUSE_FUNCTIONS); +const AGGREGATIONS_LOWERCASE = buildLowercaseMap(TSQL_AGGREGATIONS); + +/** + * Find a function in the TSQL functions map. + * Supports case-insensitive lookup for non-case-sensitive functions. + * + * @param functions - The canonical functions record (exact-match lookup) + * @param lowercaseMap - Pre-computed lowercase lookup (null-prototype, safe from prototype pollution) */ function findFunction( name: string, - functions: Record + functions: Record, + lowercaseMap: Record ): TSQLFunctionMeta | undefined { - const func = functions[name]; - if (func !== undefined) { - return func; + if (Object.prototype.hasOwnProperty.call(functions, name)) { + return functions[name]; } - const lowerFunc = functions[name.toLowerCase()]; + // Case-insensitive fallback using the pre-computed lowercase map + const lowerFunc = lowercaseMap[name.toLowerCase()]; if (lowerFunc === undefined) { return undefined; } - // If we haven't found a function with the case preserved, but we have found it in lowercase, - // then the function names are different case-wise only. + // If the function is case-sensitive, only the exact-match above should find it if (lowerFunc.caseSensitive) { return undefined; } @@ -592,14 +612,14 @@ function findFunction( * Find a TSQL aggregation function by name */ export function findTSQLAggregation(name: string): TSQLFunctionMeta | undefined { - return findFunction(name, TSQL_AGGREGATIONS); + return findFunction(name, TSQL_AGGREGATIONS, AGGREGATIONS_LOWERCASE); } /** * Find a TSQL function by name */ export function findTSQLFunction(name: string): TSQLFunctionMeta | undefined { - return findFunction(name, TSQL_CLICKHOUSE_FUNCTIONS); + return findFunction(name, TSQL_CLICKHOUSE_FUNCTIONS, FUNCTIONS_LOWERCASE); } /** diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 5f75cacedc1..67244288a99 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -2808,7 +2808,7 @@ export class ClickHousePrinter { } // Check if this is a comparison function - if (name in TSQL_COMPARISON_MAPPING) { + if (Object.prototype.hasOwnProperty.call(TSQL_COMPARISON_MAPPING, name)) { const op = TSQL_COMPARISON_MAPPING[name]; if (node.args.length !== 2) { throw new QueryError(`Comparison '${name}' requires exactly two arguments`); From 18049e0f06a20a3a4d8079ad2cfd077d5027a880 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 15:37:29 +0000 Subject: [PATCH 5/6] ifNotFinite fix --- internal-packages/clickhouse/src/tsqlFunctions.test.ts | 4 +--- internal-packages/tsql/src/query/functions.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal-packages/clickhouse/src/tsqlFunctions.test.ts b/internal-packages/clickhouse/src/tsqlFunctions.test.ts index 1dc07da8461..4b8adba10d9 100644 --- a/internal-packages/clickhouse/src/tsqlFunctions.test.ts +++ b/internal-packages/clickhouse/src/tsqlFunctions.test.ts @@ -692,9 +692,7 @@ describe("TSQL Function Smoke Tests", () => { await runCases(client, [ ["isFinite", "SELECT isFinite(1.0) AS r FROM task_runs"], ["isInfinite", "SELECT isInfinite(1.0 / 0) AS r FROM task_runs"], - // ifNotFinite: TSQL definition has maxArgs: 1, but ClickHouse expects 2. - // Skipped until the function definition is fixed. - // ["ifNotFinite", "SELECT ifNotFinite(1.0 / 0, 0) AS r FROM task_runs"], + ["ifNotFinite", "SELECT ifNotFinite(1.0 / 0, 0) AS r FROM task_runs"], ["isNaN", "SELECT isNaN(0.0 / 0) AS r FROM task_runs"], ["bar", "SELECT bar(usage_duration_ms, 0, 10000, 20) AS r FROM task_runs"], [ diff --git a/internal-packages/tsql/src/query/functions.ts b/internal-packages/tsql/src/query/functions.ts index 5fe1d4f21d6..2f085f78664 100644 --- a/internal-packages/tsql/src/query/functions.ts +++ b/internal-packages/tsql/src/query/functions.ts @@ -445,7 +445,7 @@ export const TSQL_CLICKHOUSE_FUNCTIONS: Record = { // Other functions isFinite: { clickhouseName: "isFinite", minArgs: 1, maxArgs: 1 }, isInfinite: { clickhouseName: "isInfinite", minArgs: 1, maxArgs: 1 }, - ifNotFinite: { clickhouseName: "ifNotFinite", minArgs: 1, maxArgs: 1 }, + ifNotFinite: { clickhouseName: "ifNotFinite", minArgs: 2, maxArgs: 2 }, isNaN: { clickhouseName: "isNaN", minArgs: 1, maxArgs: 1 }, bar: { clickhouseName: "bar", minArgs: 4, maxArgs: 4 }, transform: { clickhouseName: "transform", minArgs: 3, maxArgs: 4 }, From ab19467bdb7c54eb2c5a7d2c5cbeee43a90128af Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 17 Feb 2026 15:41:11 +0000 Subject: [PATCH 6/6] Removed all the functinos with use lambdas since they're not supported (yet) --- .../clickhouse/src/tsqlFunctions.test.ts | 3 --- internal-packages/tsql/src/query/functions.ts | 13 +------------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/internal-packages/clickhouse/src/tsqlFunctions.test.ts b/internal-packages/clickhouse/src/tsqlFunctions.test.ts index 4b8adba10d9..6959338256a 100644 --- a/internal-packages/clickhouse/src/tsqlFunctions.test.ts +++ b/internal-packages/clickhouse/src/tsqlFunctions.test.ts @@ -538,9 +538,6 @@ describe("TSQL Function Smoke Tests", () => { ["arrayFlatten", "SELECT arrayFlatten(array(array(1, 2), array(3))) AS r FROM task_runs"], ["arrayCompact", "SELECT arrayCompact(array(1, 1, 2, 3, 3)) AS r FROM task_runs"], ["arrayZip", "SELECT arrayZip(array(1, 2), array('a', 'b')) AS r FROM task_runs"], - // Lambda-based array functions (arrayMap, arrayFilter, arrayExists, arrayAll, - // arrayFirst, arrayLast, arrayFirstIndex, arrayLastIndex) are skipped because - // TSQL schema validation resolves lambda variables (e.g. `x`) as column references. ["arrayMin", "SELECT arrayMin(array(1, 2, 3)) AS r FROM task_runs"], ["arrayMax", "SELECT arrayMax(array(1, 2, 3)) AS r FROM task_runs"], ["arraySum", "SELECT arraySum(array(1, 2, 3)) AS r FROM task_runs"], diff --git a/internal-packages/tsql/src/query/functions.ts b/internal-packages/tsql/src/query/functions.ts index 2f085f78664..fcb5dd6e3d0 100644 --- a/internal-packages/tsql/src/query/functions.ts +++ b/internal-packages/tsql/src/query/functions.ts @@ -347,18 +347,7 @@ export const TSQL_CLICKHOUSE_FUNCTIONS: Record = { arrayFlatten: { clickhouseName: "arrayFlatten", minArgs: 1, maxArgs: 1 }, arrayCompact: { clickhouseName: "arrayCompact", minArgs: 1, maxArgs: 1 }, arrayZip: { clickhouseName: "arrayZip", minArgs: 1 }, - arrayMap: { clickhouseName: "arrayMap", minArgs: 2, maxArgs: 2 }, - arrayFilter: { clickhouseName: "arrayFilter", minArgs: 2, maxArgs: 2 }, - arrayFill: { clickhouseName: "arrayFill", minArgs: 2, maxArgs: 2 }, - arrayReverseFill: { clickhouseName: "arrayReverseFill", minArgs: 2, maxArgs: 2 }, - arraySplit: { clickhouseName: "arraySplit", minArgs: 2, maxArgs: 2 }, - arrayReverseSplit: { clickhouseName: "arrayReverseSplit", minArgs: 2, maxArgs: 2 }, - arrayExists: { clickhouseName: "arrayExists", minArgs: 1, maxArgs: 2 }, - arrayAll: { clickhouseName: "arrayAll", minArgs: 1, maxArgs: 2 }, - arrayFirst: { clickhouseName: "arrayFirst", minArgs: 1, maxArgs: 2 }, - arrayLast: { clickhouseName: "arrayLast", minArgs: 1, maxArgs: 2 }, - arrayFirstIndex: { clickhouseName: "arrayFirstIndex", minArgs: 1, maxArgs: 2 }, - arrayLastIndex: { clickhouseName: "arrayLastIndex", minArgs: 1, maxArgs: 2 }, + arrayMin: { clickhouseName: "arrayMin", minArgs: 1, maxArgs: 2 }, arrayMax: { clickhouseName: "arrayMax", minArgs: 1, maxArgs: 2 }, arraySum: { clickhouseName: "arraySum", minArgs: 1, maxArgs: 2 },