@@ -689,12 +689,15 @@ export function getAllTableNames(schema: SchemaRegistry): string[] {
689689
690690/**
691691 * Sanitize a ClickHouse error message by replacing internal ClickHouse names
692- * with their user-facing TSQL equivalents.
692+ * with their user-facing TSQL equivalents and removing internal implementation details .
693693 *
694694 * This function handles:
695695 * - Fully qualified table names (e.g., `trigger_dev.task_runs_v2` → `runs`)
696696 * - Column names with table prefix (e.g., `trigger_dev.task_runs_v2.friendly_id` → `runs.run_id`)
697697 * - Standalone column names (e.g., `friendly_id` → `run_id`)
698+ * - Removes tenant isolation filters (organization_id, project_id, environment_id)
699+ * - Removes required filters (e.g., engine = 'V2')
700+ * - Removes redundant aliases (e.g., `run_id AS run_id` → `run_id`)
698701 *
699702 * @param message - The error message from ClickHouse
700703 * @param schemas - The table schemas defining name mappings
@@ -714,10 +717,30 @@ export function sanitizeErrorMessage(message: string, schemas: TableSchema[]): s
714717 const tableNameMap = new Map < string , string > ( ) ; // clickhouseName -> tsqlName
715718 const columnNameMap = new Map < string , { tsqlName : string ; tableTsqlName : string } > ( ) ; // clickhouseName -> { tsqlName, tableTsqlName }
716719
720+ // Collect tenant column names and required filter columns to strip from errors
721+ const columnsToStrip : string [ ] = [ ] ;
722+ const tableAliasPatterns : RegExp [ ] = [ ] ;
723+
717724 for ( const table of schemas ) {
718725 // Map table names
719726 tableNameMap . set ( table . clickhouseName , table . name ) ;
720727
728+ // Collect tenant column names to strip
729+ const tenantCols = table . tenantColumns ;
730+ columnsToStrip . push ( tenantCols . organizationId , tenantCols . projectId , tenantCols . environmentId ) ;
731+
732+ // Collect required filter columns to strip
733+ if ( table . requiredFilters ) {
734+ for ( const filter of table . requiredFilters ) {
735+ columnsToStrip . push ( filter . column ) ;
736+ }
737+ }
738+
739+ // Build pattern to remove table aliases like "FROM runs AS runs"
740+ tableAliasPatterns . push (
741+ new RegExp ( `\\b${ escapeRegex ( table . name ) } \\s+AS\\s+${ escapeRegex ( table . name ) } \\b` , "gi" )
742+ ) ;
743+
721744 // Map column names
722745 for ( const col of Object . values ( table . columns ) ) {
723746 const clickhouseColName = col . clickhouseName ?? col . name ;
@@ -733,7 +756,51 @@ export function sanitizeErrorMessage(message: string, schemas: TableSchema[]): s
733756
734757 let result = message ;
735758
736- // Replace fully qualified column references first (table.column)
759+ // Step 0: Remove internal prefixes that leak implementation details
760+ result = result . replace ( / ^ U n a b l e t o q u e r y c l i c k h o u s e : \s * / i, "" ) ;
761+
762+ // Step 1: Remove tenant isolation and required filter conditions
763+ // We need to handle multiple patterns:
764+ // - (column = 'value') AND ...
765+ // - ... AND (column = 'value')
766+ // - (column = 'value') at end of expression
767+ for ( const colName of columnsToStrip ) {
768+ const escaped = escapeRegex ( colName ) ;
769+ // Match: (column = 'value') AND (with optional surrounding parens)
770+ result = result . replace ( new RegExp ( `\\(${ escaped } \\s*=\\s*'[^']*'\\)\\s*AND\\s*` , "gi" ) , "" ) ;
771+ // Match: AND (column = 'value') (handles middle/end conditions)
772+ result = result . replace ( new RegExp ( `\\s*AND\\s*\\(${ escaped } \\s*=\\s*'[^']*'\\)` , "gi" ) , "" ) ;
773+ // Match standalone: (column = 'value') with no AND (for when it's the only/last condition)
774+ result = result . replace ( new RegExp ( `\\(${ escaped } \\s*=\\s*'[^']*'\\)` , "gi" ) , "" ) ;
775+ }
776+
777+ // Step 2: Clean up any leftover empty WHERE conditions or double parentheses
778+ // Clean up empty nested parens: "(())" or "( () )" -> ""
779+ result = result . replace ( / \( \s * \( \s * \) \s * \) / g, "" ) ;
780+ // Clean up empty parens: "()" -> ""
781+ result = result . replace ( / \( \s * \) / g, "" ) ;
782+ // Clean up "WHERE AND" -> "WHERE"
783+ result = result . replace ( / \b W H E R E \s + A N D \b / gi, "WHERE" ) ;
784+ // Clean up double ANDs: "AND AND" -> "AND"
785+ result = result . replace ( / \b A N D \s + A N D \b / gi, "AND" ) ;
786+ // Clean up "WHERE ((" with user condition "))" -> "WHERE (condition)"
787+ // First normalize: "(( (condition) ))" patterns
788+ result = result . replace ( / \( \( \s * \( / g, "(" ) ;
789+ result = result . replace ( / \) \s * \) \) / g, ")" ) ;
790+ // Clean double parens around single condition
791+ result = result . replace ( / \( \( ( [ ^ ( ) ] + ) \) \) / g, "($1)" ) ;
792+ // Remove "WHERE ()" if the whole WHERE is now empty
793+ result = result . replace ( / \b W H E R E \s * \( \s * \) \s * / gi, "" ) ;
794+ // Clean up trailing " )" before ORDER/LIMIT/etc
795+ result = result . replace ( / \s + \) \s * ( O R D E R | L I M I T | G R O U P | H A V I N G ) / gi, " $1" ) ;
796+ // Remove empty WHERE clause: "WHERE ORDER" or "WHERE LIMIT" -> just "ORDER" or "LIMIT"
797+ result = result . replace ( / \b W H E R E \s + ( O R D E R | L I M I T | G R O U P | H A V I N G ) \b / gi, "$1" ) ;
798+ // Remove empty WHERE at end of string: "WHERE " at end -> ""
799+ result = result . replace ( / \b W H E R E \s * $ / gi, "" ) ;
800+ // Clean up multiple spaces
801+ result = result . replace ( / \s { 2 , } / g, " " ) ;
802+
803+ // Step 3: Replace fully qualified column references first (table.column)
737804 // This handles patterns like: trigger_dev.task_runs_v2.friendly_id
738805 for ( const table of schemas ) {
739806 for ( const col of Object . values ( table . columns ) ) {
@@ -748,7 +815,7 @@ export function sanitizeErrorMessage(message: string, schemas: TableSchema[]): s
748815 }
749816 }
750817
751- // Replace standalone table names (after column references to avoid partial matches)
818+ // Step 4: Replace standalone table names (after column references to avoid partial matches)
752819 // Sort by length descending to replace longer names first
753820 const sortedTableNames = [ ...tableNameMap . entries ( ) ] . sort ( ( a , b ) => b [ 0 ] . length - a [ 0 ] . length ) ;
754821 for ( const [ clickhouseName , tsqlName ] of sortedTableNames ) {
@@ -757,31 +824,40 @@ export function sanitizeErrorMessage(message: string, schemas: TableSchema[]): s
757824 }
758825 }
759826
760- // Replace standalone column names (for unqualified references)
827+ // Step 5: Replace standalone column names (for unqualified references)
761828 // Sort by length descending to replace longer names first
762829 const sortedColumnNames = [ ...columnNameMap . entries ( ) ] . sort ( ( a , b ) => b [ 0 ] . length - a [ 0 ] . length ) ;
763830 for ( const [ clickhouseName , { tsqlName } ] of sortedColumnNames ) {
764831 result = replaceAllOccurrences ( result , clickhouseName , tsqlName ) ;
765832 }
766833
834+ // Step 6: Remove redundant column aliases like "run_id AS run_id"
835+ result = result . replace ( / \b ( \w + ) \s + A S \s + \1\b / gi, "$1" ) ;
836+
837+ // Step 7: Remove table aliases like "runs AS runs"
838+ for ( const pattern of tableAliasPatterns ) {
839+ result = result . replace ( pattern , ( match ) => match . split ( / \s + A S \s + / i) [ 0 ] ) ;
840+ }
841+
767842 return result ;
768843}
769844
845+ /**
846+ * Escape special regex characters in a string
847+ */
848+ function escapeRegex ( str : string ) : string {
849+ return str . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
850+ }
851+
770852/**
771853 * Replace all occurrences of a string, respecting word boundaries where sensible.
772854 * Uses word-boundary matching to avoid replacing substrings within larger identifiers.
773855 */
774856function replaceAllOccurrences ( text : string , search : string , replacement : string ) : string {
775- // Escape special regex characters in the search string
776- const escaped = search . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
777-
778857 // Use word boundary matching - identifiers are typically surrounded by
779858 // non-identifier characters (spaces, quotes, parentheses, operators, etc.)
780859 // We use a pattern that matches the identifier when it's not part of a larger identifier
781- const pattern = new RegExp (
782- `(?<![a-zA-Z0-9_])${ escaped } (?![a-zA-Z0-9_])` ,
783- "g"
784- ) ;
860+ const pattern = new RegExp ( `(?<![a-zA-Z0-9_])${ escapeRegex ( search ) } (?![a-zA-Z0-9_])` , "g" ) ;
785861
786862 return text . replace ( pattern , replacement ) ;
787863}
0 commit comments