@@ -156,6 +156,7 @@ export class ClickHousePrinter {
156156 print ( node : SelectQuery | SelectSetQuery ) : PrintResult {
157157 this . outputColumns = [ ] ;
158158 this . hiddenColumns = [ ] ;
159+ this . validateColumnCtesDoNotShadowTables ( node ) ;
159160 const sql = this . visit ( node ) ;
160161 const result : PrintResult = {
161162 sql,
@@ -359,11 +360,16 @@ export class ClickHousePrinter {
359360 // Build WHERE clause starting with any existing where
360361 let where : Expression | undefined = node . where ;
361362
362- // Process CTEs
363- const cteStrings : string [ ] = [ ] ;
363+ // Process subquery CTEs; column CTEs are printed after the joins below
364+ const printedCtes = new Map < string , string > ( ) ;
364365 if ( node . ctes ) {
365366 for ( const [ name , cte ] of Object . entries ( node . ctes ) ) {
366- cteStrings . push ( `${ this . printIdentifier ( name ) } AS (${ this . visit ( cte . expr ) } )` ) ;
367+ printedCtes . set (
368+ name ,
369+ cte . cte_type === "column"
370+ ? ""
371+ : `${ this . printIdentifier ( name ) } AS (${ this . visit ( cte . expr ) } )`
372+ ) ;
367373 }
368374 }
369375
@@ -403,6 +409,21 @@ export class ClickHousePrinter {
403409 }
404410 }
405411
412+ // Process column CTEs after the joins so they resolve fields against this scope's tables,
413+ // with the same internal-only column rules as SELECT
414+ if ( node . ctes ) {
415+ for ( const cte of Object . values ( node . ctes ) ) {
416+ if ( cte . cte_type === "column" ) this . selectAliases . set ( cte . name . toLowerCase ( ) , cte . name ) ;
417+ }
418+ for ( const [ name , cte ] of Object . entries ( node . ctes ) ) {
419+ if ( cte . cte_type !== "column" ) continue ;
420+ this . inProjectionContext = true ;
421+ printedCtes . set ( name , `${ this . visit ( cte . expr ) } AS ${ this . printIdentifier ( name ) } ` ) ;
422+ this . inProjectionContext = false ;
423+ }
424+ }
425+ const cteStrings = [ ...printedCtes . values ( ) ] ;
426+
406427 // Track if query has GROUP BY for JSON subfield type hint decisions
407428 // (ClickHouse requires .:String for Dynamic types in GROUP BY, and SELECT must match)
408429 const savedQueryHasGroupBy = this . queryHasGroupBy ;
@@ -1954,6 +1975,52 @@ export class ClickHousePrinter {
19541975 }
19551976 }
19561977
1978+ /**
1979+ * Reject a column CTE (`WITH expr AS name`) whose name, or dotted prefix, matches a table
1980+ * alias anywhere in the query. ClickHouse carries a WITH alias into every branch of a UNION,
1981+ * where it would replace that branch's tenant guards, so a per-scope check is not enough.
1982+ */
1983+ private validateColumnCtesDoNotShadowTables ( root : SelectQuery | SelectSetQuery ) : void {
1984+ const tableAliases = new Map < string , string > ( ) ;
1985+ const cteNames : string [ ] = [ ] ;
1986+ const seen = new WeakSet < object > ( ) ;
1987+ const walk = ( expr : unknown ) : void => {
1988+ if ( expr === null || typeof expr !== "object" || seen . has ( expr ) ) return ;
1989+ seen . add ( expr ) ;
1990+ if ( Array . isArray ( expr ) ) {
1991+ for ( const item of expr ) walk ( item ) ;
1992+ return ;
1993+ }
1994+ const candidate = expr as Partial < JoinExpr > & Partial < CTE > & { expression_type ?: string } ;
1995+ if ( candidate . expression_type === "join_expr" ) {
1996+ const table = candidate . table as Field | undefined ;
1997+ if ( table ?. expression_type === "field" && typeof table . chain [ 0 ] === "string" ) {
1998+ const alias = candidate . alias || table . chain [ 0 ] ;
1999+ tableAliases . set ( alias . toLowerCase ( ) , alias ) ;
2000+ }
2001+ } else if ( candidate . expression_type === "cte" && candidate . cte_type === "column" ) {
2002+ if ( typeof candidate . name === "string" ) cteNames . push ( candidate . name ) ;
2003+ }
2004+ for ( const [ property , value ] of Object . entries ( expr ) ) {
2005+ if ( property === "type" || property === "parent" ) continue ;
2006+ walk ( value ) ;
2007+ }
2008+ } ;
2009+ walk ( root ) ;
2010+
2011+ for ( const name of cteNames ) {
2012+ const lowerName = name . toLowerCase ( ) ;
2013+ for ( const [ lowerTableAlias , original ] of tableAliases ) {
2014+ if ( lowerName === lowerTableAlias || lowerName . startsWith ( `${ lowerTableAlias } .` ) ) {
2015+ throw new QueryError (
2016+ `Alias '${ name } ' conflicts with the table alias '${ original } '. ` +
2017+ `Choose a different alias.`
2018+ ) ;
2019+ }
2020+ }
2021+ }
2022+ }
2023+
19572024 private findShadowingAlias (
19582025 expr : unknown ,
19592026 tableAliases : Map < string , string > ,
0 commit comments