diff --git a/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap b/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap index 32e82905..bf8ee1ef 100644 --- a/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap +++ b/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap @@ -14,7 +14,7 @@ exports[`renderChartConfig HAVING clause should not render HAVING clause when no exports[`renderChartConfig HAVING clause should render HAVING clause with SQL language 1`] = `"SELECT count(),severity FROM default.logs WHERE (timestamp >= fromUnixTimestamp64Milli(1739318400000) AND timestamp <= fromUnixTimestamp64Milli(1739491200000)) GROUP BY severity HAVING count(*) > 100 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; -exports[`renderChartConfig HAVING clause should render HAVING clause with granularity and groupBy 1`] = `"SELECT count(),event_type,toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM default.events WHERE (timestamp >= fromUnixTimestamp64Milli(1739318400000) AND timestamp <= fromUnixTimestamp64Milli(1739491200000)) GROUP BY event_type,toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) AS \`__hdx_time_bucket\` HAVING count(*) > 50 ORDER BY toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) AS \`__hdx_time_bucket\` SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; +exports[`renderChartConfig HAVING clause should render HAVING clause with granularity and groupBy 1`] = `"SELECT count(),event_type,toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM default.events WHERE (timestamp >= fromUnixTimestamp64Milli(1739318400000) AND timestamp <= fromUnixTimestamp64Milli(1739491200000)) GROUP BY event_type,toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) HAVING count(*) > 50 ORDER BY toStartOfInterval(toDateTime(timestamp), INTERVAL 5 minute) SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; exports[`renderChartConfig HAVING clause should render HAVING clause with multiple conditions 1`] = ` "SELECT avg( @@ -588,7 +588,7 @@ exports[`renderChartConfig k8s semantic convention migrations should generate SQ ORDER BY AttributesHash, \`__hdx_time_bucket2\` ) SELECT max( toFloat64OrDefault(toString(Rate)) - ) AS \\"Value\\",toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (\`__hdx_time_bucket2\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket2\` <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ) AS \\"Value\\",toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (\`__hdx_time_bucket2\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket2\` <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) ORDER BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) LIMIT 10 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig k8s semantic convention migrations should generate SQL with metricNameSql for k8s.pod.cpu.utilization gauge metric 1`] = ` @@ -621,7 +621,7 @@ exports[`renderChartConfig k8s semantic convention migrations should generate SQ ORDER BY AttributesHash, __hdx_time_bucket2 ) SELECT avg( toFloat64OrDefault(toString(LastValue)) - ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig k8s semantic convention migrations should handle metrics without metricNameSql (backward compatibility) 1`] = ` @@ -654,7 +654,7 @@ exports[`renderChartConfig k8s semantic convention migrations should handle metr ORDER BY AttributesHash, __hdx_time_bucket2 ) SELECT avg( toFloat64OrDefault(toString(LastValue)) - ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig sample-weighted aggregations should handle complex sampleWeightExpression like SpanAttributes map access 1`] = `"SELECT sum(greatest(toUInt64OrZero(toString(SpanAttributes['SampleRate'])), 1)) FROM default.otel_traces WHERE (Timestamp >= fromUnixTimestamp64Milli(1739318400000) AND Timestamp <= fromUnixTimestamp64Milli(1739491200000)) SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; @@ -719,7 +719,7 @@ exports[`renderChartConfig should generate sql for a single gauge metric 1`] = ` FROM Source GROUP BY AttributesHash, __hdx_time_bucket2 ORDER BY AttributesHash, __hdx_time_bucket2 - ) SELECT quantile(0.95)(toFloat64OrDefault(toString(LastValue))),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ) SELECT quantile(0.95)(toFloat64OrDefault(toString(LastValue))),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig should generate sql for a single gauge metric with a delta() function applied 1`] = ` @@ -752,7 +752,7 @@ exports[`renderChartConfig should generate sql for a single gauge metric with a ORDER BY AttributesHash, __hdx_time_bucket2 ) SELECT max( toFloat64OrDefault(toString(LastValue)) - ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ),toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (__hdx_time_bucket2 >= fromUnixTimestamp64Milli(1739318400000) AND __hdx_time_bucket2 <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), INTERVAL 1 minute) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable', optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig should generate sql for a single sum metric 1`] = ` @@ -798,7 +798,7 @@ exports[`renderChartConfig should generate sql for a single sum metric 1`] = ` ORDER BY AttributesHash, \`__hdx_time_bucket2\` ) SELECT avg( toFloat64OrDefault(toString(Rate)) - ) AS \\"Value\\",toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (\`__hdx_time_bucket2\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket2\` <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` LIMIT 10 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" + ) AS \\"Value\\",toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) AS \`__hdx_time_bucket\` FROM Bucketed WHERE (\`__hdx_time_bucket2\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket2\` <= fromUnixTimestamp64Milli(1765670400000)) GROUP BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) ORDER BY toStartOfInterval(toDateTime(\`__hdx_time_bucket2\`), INTERVAL 5 minute) LIMIT 10 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000" `; exports[`renderChartConfig should not generate invalid SQL when primary key wraps toStartOfInterval 1`] = `"SELECT timestamp, cluster_id, service_id FROM default.http_request_logs WHERE (timestamp >= fromUnixTimestamp64Milli(1739319154000) AND timestamp <= fromUnixTimestamp64Milli(1739491954000)) LIMIT 200 OFFSET 0 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 640f31ab..405d3826 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -30,6 +30,7 @@ import { replaceJsonExpressions, splitAndTrimCSV, splitAndTrimWithBracket, + stripTrailingAlias, } from '../core/utils'; describe('utils', () => { @@ -252,6 +253,26 @@ describe('utils', () => { }); }); + describe('stripTrailingAlias', () => { + it('removes trailing AS alias from top-level expression', () => { + expect( + stripTrailingAlias( + 'toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute) AS `__hdx_time_bucket`', + ), + ).toBe('toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute)'); + }); + + it('preserves expressions that have no trailing alias', () => { + expect(stripTrailingAlias('TimestampTime')).toBe('TimestampTime'); + }); + + it('does not remove nested aliases inside parentheses', () => { + expect( + stripTrailingAlias('coalesce((SELECT x AS y), 0) AS final_value'), + ).toBe('coalesce((SELECT x AS y), 0)'); + }); + }); + describe('getFirstOrderingItem', () => { it('should return undefined for undefined input', () => { expect(getFirstOrderingItem(undefined)).toBeUndefined(); diff --git a/packages/common-utils/src/core/renderChartConfig.ts b/packages/common-utils/src/core/renderChartConfig.ts index 5250aada..079ec1f5 100644 --- a/packages/common-utils/src/core/renderChartConfig.ts +++ b/packages/common-utils/src/core/renderChartConfig.ts @@ -14,6 +14,7 @@ import { optimizeTimestampValueExpression, parseToStartOfFunction, splitAndTrimWithBracket, + stripTrailingAlias, } from '@/core/utils'; import { isBuilderChartConfig, isRawSqlChartConfig } from '@/guards'; import { replaceMacros } from '@/macros'; @@ -614,9 +615,30 @@ function timeBucketExpr({ timestampValueExpression: string; dateRange?: [Date, Date]; alias?: string; +}) { + const bucketExpression = timeBucketExprSql({ + interval, + timestampValueExpression, + dateRange, + }); + return chSql`${bucketExpression} AS \`${{ + UNSAFE_RAW_SQL: alias, + }}\``; +} + +function timeBucketExprSql({ + interval, + timestampValueExpression, + dateRange, +}: { + interval: SQLInterval | 'auto'; + timestampValueExpression: string; + dateRange?: [Date, Date]; }) { const unsafeTimestampValueExpression = { - UNSAFE_RAW_SQL: getFirstTimestampValueExpression(timestampValueExpression), + UNSAFE_RAW_SQL: stripTrailingAlias( + getFirstTimestampValueExpression(timestampValueExpression), + ), }; const unsafeInterval = { UNSAFE_RAW_SQL: @@ -625,9 +647,7 @@ function timeBucketExpr({ : interval, }; - return chSql`toStartOfInterval(toDateTime(${unsafeTimestampValueExpression}), INTERVAL ${unsafeInterval}) AS \`${{ - UNSAFE_RAW_SQL: alias, - }}\``; + return chSql`toStartOfInterval(toDateTime(${unsafeTimestampValueExpression}), INTERVAL ${unsafeInterval})`; } export async function timeFilterExpr({ @@ -680,7 +700,7 @@ export async function timeFilterExpr({ const whereExprs = await Promise.all( valueExpressions.map(async expr => { - const col = expr.trim(); + const col = stripTrailingAlias(expr.trim()); // If the expression includes a toStartOf...(...) function, the RHS of the // timestamp comparison must also have the same function @@ -976,7 +996,7 @@ async function renderGroupBy( ? await renderSelectList(chartConfig.groupBy, chartConfig, metadata) : [], isUsingGranularity(chartConfig) - ? timeBucketExpr({ + ? timeBucketExprSql({ interval: chartConfig.granularity, timestampValueExpression: chartConfig.timestampValueExpression, dateRange: chartConfig.dateRange, @@ -1016,7 +1036,7 @@ function renderOrderBy( return concatChSql( ',', isIncludingTimeBucket - ? timeBucketExpr({ + ? timeBucketExprSql({ interval: chartConfig.granularity, timestampValueExpression: chartConfig.timestampValueExpression, dateRange: chartConfig.dateRange, diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 362f438e..1369b209 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -96,7 +96,81 @@ export function splitAndTrimWithBracket(input: string): string[] { // If a user specifies a timestampValueExpression with multiple columns, // this will return the first one. We'll want to refine this over time export function getFirstTimestampValueExpression(valueExpression: string) { - return splitAndTrimWithBracket(valueExpression)[0]; + const firstExpression = splitAndTrimWithBracket(valueExpression)[0]; + return firstExpression + ? stripTrailingAlias(firstExpression) + : firstExpression; +} + +/** + * Removes a trailing SQL alias from a single expression, e.g. + * `toStartOfInterval(ts, INTERVAL 1 minute) AS bucket` -> `toStartOfInterval(ts, INTERVAL 1 minute)`. + * Keeps interior aliases untouched by only stripping the last top-level alias token. + */ +export function stripTrailingAlias(valueExpression: string): string { + const expression = valueExpression.trim(); + if (!expression) return expression; + + let parenCount = 0; + let squareCount = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let aliasSplitIndex: number | undefined; + + for (let i = 0; i < expression.length; i++) { + const c = expression[i]; + const prev = i > 0 ? expression[i - 1] : ''; + + if (c === "'" && !inDoubleQuote && !inBacktick && prev !== '\\') { + inSingleQuote = !inSingleQuote; + continue; + } + if (c === '"' && !inSingleQuote && !inBacktick && prev !== '\\') { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (c === '`' && !inSingleQuote && !inDoubleQuote && prev !== '\\') { + inBacktick = !inBacktick; + continue; + } + if (inSingleQuote || inDoubleQuote || inBacktick) continue; + + if (c === '(') { + parenCount++; + continue; + } + if (c === ')') { + parenCount--; + continue; + } + if (c === '[') { + squareCount++; + continue; + } + if (c === ']') { + squareCount--; + continue; + } + + if (parenCount === 0 && squareCount === 0) { + if (/\s/.test(c)) { + let j = i; + while (j < expression.length && /\s/.test(expression[j])) j++; + if ( + expression.slice(j, j + 2).toUpperCase() === 'AS' && + j + 2 < expression.length && + /\s/.test(expression[j + 2]) + ) { + aliasSplitIndex = i; + } + } + } + } + + return aliasSplitIndex == null + ? expression + : expression.slice(0, aliasSplitIndex).trim(); } /** Returns true if the given expression is a JSON expression, eg. `col.key.nestedKey` or "json_col"."key" */