Skip to content

Commit 3bc5abb

Browse files
authored
fix: reject wrapped toStartOf expressions in parseToStartOfFunction (#1876)
## Summary - Fixes a bug where `parseToStartOfFunction` incorrectly parsed `toStartOf` expressions nested inside wrapper functions (e.g. `-toInt64(toStartOfInterval(timestamp, toIntervalMinute(15)))`), causing invalid SQL with missing arguments in the time filter WHERE clause. - Adds a prefix guard that returns `undefined` when `toStartOf` is not the outermost function, preventing the broken optimization from being applied. Fixes [HDX-3662](https://linear.app/clickhouse/issue/HDX-3662/invalid-sql-generated-for-tables-with-wrapped-tostartof-in-order-by) ## Test plan - [x] Added `parseToStartOfFunction` unit tests for wrapped expressions (`-toInt64(...)`, `negate(...)`) - [x] Added `optimizeTimestampValueExpression` unit test for primary key with wrapped `toStartOf` - [x] Added `timeFilterExpr` unit test verifying no broken compound filter is generated - [x] Added `renderChartConfig` snapshot test against the full input schema from the bug report - [x] All 637 unit tests pass Made with [Cursor](https://cursor.com)
1 parent 1e6fcf1 commit 3bc5abb

5 files changed

Lines changed: 70 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
---
4+
5+
fix: Reject wrapped toStartOf expressions in parseToStartOfFunction to prevent invalid SQL generation

‎packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,3 +766,5 @@ exports[`renderChartConfig should generate sql for a single sum metric 1`] = `
766766
toFloat64OrDefault(toString(Rate))
767767
) 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"
768768
`;
769+
770+
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"`;

‎packages/common-utils/src/__tests__/renderChartConfig.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,18 @@ describe('renderChartConfig', () => {
11761176
"toStartOfMinute(timestamp), ServiceName, ResourceAttributes['timestamp'], timestamp",
11771177
expected: `(toStartOfMinute(timestamp) >= toStartOfMinute(fromUnixTimestamp64Milli(1739319154000)) AND toStartOfMinute(timestamp) <= toStartOfMinute(fromUnixTimestamp64Milli(1739491954000)))`,
11781178
},
1179+
{
1180+
description:
1181+
'with wrapped toStartOfInterval in primary key (should not optimize)',
1182+
timestampValueExpression: `timestamp`,
1183+
dateRange: [
1184+
new Date('2025-02-12 00:12:34Z'),
1185+
new Date('2025-02-14 00:12:34Z'),
1186+
],
1187+
primaryKey:
1188+
'-toInt64(toStartOfInterval(timestamp, toIntervalMinute(15))), service_id, timestamp',
1189+
expected: `(timestamp >= fromUnixTimestamp64Milli(1739319154000) AND timestamp <= fromUnixTimestamp64Milli(1739491954000))`,
1190+
},
11791191
];
11801192

11811193
beforeEach(() => {
@@ -1223,6 +1235,40 @@ describe('renderChartConfig', () => {
12231235
);
12241236
});
12251237

1238+
it('should not generate invalid SQL when primary key wraps toStartOfInterval', async () => {
1239+
mockMetadata.getTableMetadata.mockResolvedValue({
1240+
primary_key:
1241+
'proxy_tier, status, is_customer_content, -toInt64(toStartOfInterval(timestamp, toIntervalMinute(15))), service_id',
1242+
} as any);
1243+
1244+
const config: ChartConfigWithOptDateRange = {
1245+
displayType: DisplayType.Table,
1246+
connection: 'test-connection',
1247+
from: {
1248+
databaseName: 'default',
1249+
tableName: 'http_request_logs',
1250+
},
1251+
select: 'timestamp, cluster_id, service_id',
1252+
where: '',
1253+
whereLanguage: 'sql',
1254+
timestampValueExpression: 'timestamp',
1255+
dateRange: [
1256+
new Date('2025-02-12 00:12:34Z'),
1257+
new Date('2025-02-14 00:12:34Z'),
1258+
],
1259+
limit: { limit: 200, offset: 0 },
1260+
};
1261+
1262+
const generatedSql = await renderChartConfig(
1263+
config,
1264+
mockMetadata,
1265+
querySettings,
1266+
);
1267+
const actual = parameterizedQueryToSql(generatedSql);
1268+
expect(actual).not.toContain('toStartOfInterval(fromUnixTimestamp64Milli');
1269+
expect(actual).toMatchSnapshot();
1270+
});
1271+
12261272
describe('Aggregate Merge Functions', () => {
12271273
it('should generate SQL for an aggregate merge function', async () => {
12281274
const config: ChartConfigWithOptDateRange = {

‎packages/common-utils/src/__tests__/utils.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,14 @@ describe('utils', () => {
13751375
expr: 'toStartOfDay(',
13761376
expected: undefined,
13771377
},
1378+
{
1379+
expr: '-toInt64(toStartOfInterval(timestamp, toIntervalMinute(15)))',
1380+
expected: undefined,
1381+
},
1382+
{
1383+
expr: 'negate(toStartOfMinute(timestamp))',
1384+
expected: undefined,
1385+
},
13781386
])('Should parse $expr', ({ expr, expected }) => {
13791387
expect(parseToStartOfFunction(expr)).toEqual(expected);
13801388
});
@@ -1466,6 +1474,12 @@ describe('utils', () => {
14661474
primaryKey: 'toStartOfMinute(`Time stamp`), other_column, `Time stamp`',
14671475
expected: '`Time stamp`, toStartOfMinute(`Time stamp`)',
14681476
},
1477+
{
1478+
timestampValueExpression: 'Timestamp',
1479+
primaryKey:
1480+
'-toInt64(toStartOfInterval(Timestamp, toIntervalMinute(15))), service_id, Timestamp',
1481+
expected: 'Timestamp',
1482+
},
14691483
] as const;
14701484

14711485
it.each(testCases)(

‎packages/common-utils/src/core/utils.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,9 @@ export function parseToStartOfFunction(
624624
const toStartOfMatches = expr.match(/(toStartOf\w+)\s*\(/);
625625

626626
if (toStartOfMatches) {
627+
const prefix = expr.substring(0, toStartOfMatches.index!);
628+
if (prefix.trim() !== '') return undefined;
629+
627630
const [toStartOfSubstring, toStartOfFunction] = toStartOfMatches;
628631

629632
const argsStartIndex =

0 commit comments

Comments
 (0)