TRQL function tests and fixes (#3076)

What changed
- Fixed some functions like dateAdd, toString, ifNotFinite
- Removed all functions that accept lambdas as they're not supported
(yet)
- Added tests for all TRQL functions that use ClickHouse
This commit is contained in:
Matt Aitken
2026-02-24 19:44:07 +00:00
committed by GitHub
parent 89c73ed8ba
commit 9ba608d2cf
6 changed files with 1054 additions and 46 deletions
+33 -24
View File
@@ -347,18 +347,7 @@ export const TSQL_CLICKHOUSE_FUNCTIONS: Record<string, TSQLFunctionMeta> = {
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 },
@@ -445,7 +434,7 @@ export const TSQL_CLICKHOUSE_FUNCTIONS: Record<string, TSQLFunctionMeta> = {
// 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 },
@@ -562,25 +551,45 @@ export const TSQL_AGGREGATIONS: Record<string, TSQLFunctionMeta> = {
};
/**
* 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<string, TSQLFunctionMeta>
): Record<string, TSQLFunctionMeta> {
const map: Record<string, TSQLFunctionMeta> = 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<string, TSQLFunctionMeta>
functions: Record<string, TSQLFunctionMeta>,
lowercaseMap: Record<string, TSQLFunctionMeta>
): 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 +601,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);
}
/**
@@ -1250,6 +1250,95 @@ 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 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(");
expect(sql).not.toContain("dateTrunc(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)");
});
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", () => {
it("should inject tenant guards for single table", () => {
const context = createTestContext({
+82 -2
View File
@@ -2879,7 +2879,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`);
@@ -2926,7 +2926,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(", ")})`;
@@ -2936,6 +2936,86 @@ 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([
"nanosecond",
"microsecond",
"millisecond",
"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",
"date_add",
"date_sub",
"date_diff",
]);
/**
* 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);
}