@@ -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 */
144146function ensureJsonFormat ( query : string ) : string {
145147 const trimmed = query . trim ( ) . replace ( / ; + \s * $ / , '' )
146- if ( READ_ONLY_STATEMENT . test ( trimmed ) ) {
147- const withoutFormat = trimmed . replace ( / \s + f o r m a t \s + [ a - z 0 - 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 = / \b f o r m a t \s + [ a - z 0 - 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 - Z a - z _ ] / . test ( trimmed ) ) {
663+ throw new Error ( `Invalid column type: ${ type } ` )
664+ }
665+ if ( ! / ^ [ A - Z a - z 0 - 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+
640691export 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 - Z a - z 0 - 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