Skip to content

Commit dd3c37d

Browse files
committed
fix(clickhouse): balance-check ORDER BY/PARTITION BY and skip leading comments in read-only guard
1 parent 9a8fc9c commit dd3c37d

1 file changed

Lines changed: 64 additions & 5 deletions

File tree

  • apps/sim/app/api/tools/clickhouse

apps/sim/app/api/tools/clickhouse/utils.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,36 @@ function isMutatingStatement(sql: string): boolean {
244244
return MUTATING_STATEMENT.some((pattern) => pattern.test(masked))
245245
}
246246

247+
/**
248+
* Strips leading whitespace, `--`/`/* … *​/` comments, and opening parens from a
249+
* statement so the read-only leader keyword can be detected even when a query
250+
* starts with a comment (e.g. `-- note\nSELECT …`) or wrapping parens.
251+
*/
252+
function stripLeadingNoise(sql: string): string {
253+
let s = sql.trim()
254+
for (;;) {
255+
if (s.startsWith('--')) {
256+
const newline = s.indexOf('\n')
257+
s = (newline === -1 ? '' : s.slice(newline + 1)).trim()
258+
} else if (s.startsWith('/*')) {
259+
const close = s.indexOf('*/')
260+
s = (close === -1 ? '' : s.slice(close + 2)).trim()
261+
} else if (s.startsWith('(')) {
262+
s = s.slice(1).trim()
263+
} else {
264+
return s
265+
}
266+
}
267+
}
268+
247269
export async function executeClickHouseQuery(
248270
config: ClickHouseConnectionConfig,
249271
query: string,
250272
options: { enforceReadOnly?: boolean } = {}
251273
): Promise<ClickHouseRowsResult> {
252274
if (options.enforceReadOnly) {
253-
// Strip leading parens so wrapped selects like "(SELECT ...)" still validate.
254-
const leader = query.trim().replace(/^\(+\s*/, '')
275+
// Strip leading comments/parens so wrapped or commented selects still validate.
276+
const leader = stripLeadingNoise(query)
255277
if (!READ_ONLY_STATEMENT.test(leader)) {
256278
throw new Error(
257279
'The query operation only allows read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS). Use the Execute Raw SQL operation to run writes or DDL.'
@@ -494,6 +516,43 @@ function validateExpression(expression: string, label: string): void {
494516
}
495517
}
496518

519+
/**
520+
* Validates an ORDER BY / PARTITION BY expression that is spliced inside wrapping
521+
* parentheses in the generated DDL. In addition to rejecting terminators/comments,
522+
* it requires balanced parentheses (quote-aware) so the expression cannot close
523+
* the wrapping `(...)` early and append extra clauses (e.g. `id) SETTINGS …`).
524+
*/
525+
function validateClauseExpression(expression: string, label: string): void {
526+
const trimmed = expression.trim()
527+
if (!trimmed) {
528+
throw new Error(`${label} is required`)
529+
}
530+
if (/;|--|\/\*|\*\//.test(trimmed)) {
531+
throw new Error(`${label} contains a disallowed sequence`)
532+
}
533+
let depth = 0
534+
let inString = false
535+
for (let i = 0; i < trimmed.length; i++) {
536+
const ch = trimmed[i]
537+
if (inString) {
538+
if (ch === '\\') i++
539+
else if (ch === "'") inString = false
540+
continue
541+
}
542+
if (ch === "'") inString = true
543+
else if (ch === '(') depth++
544+
else if (ch === ')') {
545+
depth--
546+
if (depth < 0) {
547+
throw new Error(`${label} has unbalanced parentheses`)
548+
}
549+
}
550+
}
551+
if (inString || depth !== 0) {
552+
throw new Error(`${label} has unbalanced parentheses or quotes`)
553+
}
554+
}
555+
497556
/**
498557
* Validates a partition value for `DROP PARTITION`. ClickHouse partition values
499558
* are literals (signed numbers or single-quoted strings) or a parenthesised tuple
@@ -716,12 +775,12 @@ export async function executeClickHouseCreateTable(
716775
if (!orderBy?.trim()) {
717776
throw new Error('ORDER BY expression is required')
718777
}
719-
validateExpression(orderBy, 'ORDER BY')
778+
validateClauseExpression(orderBy, 'ORDER BY')
720779

721780
let statement = `CREATE TABLE IF NOT EXISTS ${sanitizeIdentifier(table)} (${columnDefs.join(', ')}) ENGINE = ${engine.trim()}`
722781
if (partitionBy?.trim()) {
723-
validateExpression(partitionBy, 'PARTITION BY')
724-
statement += ` PARTITION BY ${partitionBy.trim()}`
782+
validateClauseExpression(partitionBy, 'PARTITION BY')
783+
statement += ` PARTITION BY (${partitionBy.trim()})`
725784
}
726785
statement += ` ORDER BY (${orderBy.trim()})`
727786

0 commit comments

Comments
 (0)