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:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user