Skip to content

Commit 9a8fc9c

Browse files
committed
fix(clickhouse): validate column types structurally and normalize FORMAT around SETTINGS
1 parent 19e2e3d commit 9a8fc9c

1 file changed

Lines changed: 58 additions & 9 deletions

File tree

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

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

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,29 @@ const READ_ONLY_STATEMENT = /^(select|with|show|describe|desc|explain|exists)\b/
136136

137137
/**
138138
* Normalizes the output format of a read statement to JSON so the HTTP response
139-
* can always be parsed into rows: strips any trailing `FORMAT <x>` (e.g. CSV or
140-
* TabSeparated, which `parseRowsResult` cannot read) and appends `FORMAT JSON`.
139+
* can always be parsed into rows. Strips every `FORMAT <name>` clause — wherever
140+
* it sits relative to a trailing `SETTINGS` clause — and appends a single canonical
141+
* `FORMAT JSON`. The `format()` function and `FORMAT`/format names appearing inside
142+
* strings or comments are ignored (the scan runs on comment/string-masked SQL).
141143
* Non-read statements are returned untouched (their own FORMAT, e.g. JSONEachRow
142144
* for inserts, is preserved).
143145
*/
144146
function ensureJsonFormat(query: string): string {
145147
const trimmed = query.trim().replace(/;+\s*$/, '')
146-
if (READ_ONLY_STATEMENT.test(trimmed)) {
147-
const withoutFormat = trimmed.replace(/\s+format\s+[a-z0-9_]+\s*$/i, '')
148-
return `${withoutFormat}\nFORMAT JSON`
148+
if (!READ_ONLY_STATEMENT.test(trimmed)) {
149+
return trimmed
149150
}
150-
return trimmed
151+
const masked = maskSqlNoise(trimmed)
152+
const formatClause = /\bformat\s+[a-z0-9_]+\b/gi
153+
const spans: Array<[number, number]> = []
154+
for (let match = formatClause.exec(masked); match !== null; match = formatClause.exec(masked)) {
155+
spans.push([match.index, match.index + match[0].length])
156+
}
157+
let result = trimmed
158+
for (let i = spans.length - 1; i >= 0; i--) {
159+
result = result.slice(0, spans[i][0]) + result.slice(spans[i][1])
160+
}
161+
return `${result.replace(/\s+$/, '')}\nFORMAT JSON`
151162
}
152163

153164
/**
@@ -637,6 +648,46 @@ export async function executeClickHouseDropDatabase(
637648
await clickhouseRequest(config, `DROP DATABASE IF EXISTS ${sanitizeIdentifier(name)}`)
638649
}
639650

651+
/**
652+
* Validates a single ClickHouse column type. Types may legitimately contain
653+
* commas, single-quoted strings, `=`, and `-` inside their parameter parentheses
654+
* (e.g. `Decimal(10, 2)`, `Enum8('a' = 1, 'b' = -2)`, `Map(String, UInt64)`,
655+
* `Array(Tuple(a UInt8, b String))`). We allow those but reject anything that
656+
* could break out of the single type literal and inject another column or SQL:
657+
* comment/terminator sequences, a top-level (unparenthesised) comma, or an
658+
* unbalanced closing paren.
659+
*/
660+
function validateColumnType(type: string): void {
661+
const trimmed = type.trim()
662+
if (!trimmed || !/^[A-Za-z_]/.test(trimmed)) {
663+
throw new Error(`Invalid column type: ${type}`)
664+
}
665+
if (!/^[A-Za-z0-9_(),.\s'"=-]+$/.test(trimmed) || /--|;/.test(trimmed)) {
666+
throw new Error(`Invalid column type: ${type}`)
667+
}
668+
let depth = 0
669+
let inString = false
670+
for (let i = 0; i < trimmed.length; i++) {
671+
const ch = trimmed[i]
672+
if (inString) {
673+
if (ch === '\\') i++
674+
else if (ch === "'") inString = false
675+
continue
676+
}
677+
if (ch === "'") inString = true
678+
else if (ch === '(') depth++
679+
else if (ch === ')') {
680+
depth--
681+
if (depth < 0) throw new Error(`Invalid column type: ${type}`)
682+
} else if (ch === ',' && depth === 0) {
683+
throw new Error(`Invalid column type: ${type}`)
684+
}
685+
}
686+
if (inString || depth !== 0) {
687+
throw new Error(`Invalid column type: ${type}`)
688+
}
689+
}
690+
640691
export async function executeClickHouseCreateTable(
641692
config: ClickHouseConnectionConfig,
642693
table: string,
@@ -653,9 +704,7 @@ export async function executeClickHouseCreateTable(
653704
if (!column?.name || !column?.type) {
654705
throw new Error('Each column requires a name and type')
655706
}
656-
if (!/^[A-Za-z0-9_(),.'"\s]+$/.test(column.type)) {
657-
throw new Error(`Invalid column type: ${column.type}`)
658-
}
707+
validateColumnType(column.type)
659708
return `${sanitizeIdentifier(column.name)} ${column.type.trim()}`
660709
})
661710

0 commit comments

Comments
 (0)