From 7cd10aa49ed22435b0aef9fc04d471818b7108dc Mon Sep 17 00:00:00 2001 From: Sarah Zinger Date: Fri, 5 Dec 2025 10:14:02 -0500 Subject: [PATCH] SQL Expressions: Fix alerts with sql expressions that have a cte (#114852) Fix for #114377 - fix alerts with sql expressions that have a cte --- .../components/rule-editor/dag.test.ts | 55 +++++++++++++++++++ .../unified/components/rule-editor/dag.ts | 44 +++++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts index 189bcb6f25e..423f035ddc9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts @@ -293,6 +293,61 @@ SELECT * FROM table1`) expect(parseRefsFromSqlExpression('SELECT * FROM\ntable1')).toEqual(['table1']); }); }); + + describe('CTE (Common Table Expression) handling', () => { + it('should exclude single CTE name from results', () => { + const query = 'WITH my_cte AS (SELECT * FROM table1) SELECT * FROM my_cte'; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1']); + }); + + it('should exclude multiple CTE names from results', () => { + const query = ` + WITH cte1 AS (SELECT * FROM table1), + cte2 AS (SELECT * FROM table2) + SELECT * FROM cte1 JOIN cte2 ON cte1.id = cte2.id + `; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1', 'table2']); + }); + + it('should handle CTEs with external table references in main query', () => { + const query = ` + WITH summary AS (SELECT id, count FROM table1) + SELECT * FROM summary JOIN table2 ON summary.id = table2.id + `; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1', 'table2']); + }); + + it('should handle CTE names case-insensitively', () => { + const query = 'WITH MyCte AS (SELECT * FROM table1) SELECT * FROM mycte'; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1']); + }); + + it('should handle RECURSIVE CTEs', () => { + const query = ` + WITH RECURSIVE cte AS ( + SELECT * FROM table1 + UNION ALL + SELECT * FROM cte WHERE depth < 10 + ) + SELECT * FROM cte + `; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1']); + }); + + it('should handle queries without CTEs normally', () => { + const query = 'SELECT * FROM table1 JOIN table2 ON table1.id = table2.id'; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1', 'table2']); + }); + + it('should handle CTE that references another CTE', () => { + const query = ` + WITH cte1 AS (SELECT * FROM table1), + cte2 AS (SELECT * FROM cte1) + SELECT * FROM cte2 + `; + expect(parseRefsFromSqlExpression(query)).toEqual(['table1']); + }); + }); }); describe('fingerprints', () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.ts b/public/app/features/alerting/unified/components/rule-editor/dag.ts index 9666186f039..c7078ca7517 100644 --- a/public/app/features/alerting/unified/components/rule-editor/dag.ts +++ b/public/app/features/alerting/unified/components/rule-editor/dag.ts @@ -132,10 +132,15 @@ export function parseRefsFromSqlExpression(input: string): string[] { .replace(/\s+/g, ' ') // Remove any potential multi line comments .replace(/\/\*[\s\S]*?\*\//g, ''); + + // Extract CTE names to exclude them from table references + const cteNames = parseCteNames(query); + const tableMatches = []; // Extract tables after FROM - case insensitive with /i flag - const fromRegex = /from\s+([^;]*?)(?:\s+(?:join|where|group|having|order|limit)|\s*$)/gi; + // Terminate on: SQL keywords, closing paren (for CTEs/subqueries), or end of string + const fromRegex = /from\s+([^;)]*?)(?:\s+(?:join|where|group|having|order|limit|on|select)|\)|$)/gi; for (const match of query.matchAll(fromRegex)) { const fromClause = match[1].trim(); @@ -153,13 +158,44 @@ export function parseRefsFromSqlExpression(input: string): string[] { tableMatches.push(cleanTableName(match[1])); } - return compact(uniq(tableMatches)); + // Filter out CTE names - they're local definitions, not external references + const externalRefs = tableMatches.filter((table) => !cteNames.has(table.toLowerCase())); + + return compact(uniq(externalRefs)); +} + +/** + * Parse CTE (Common Table Expression) names from a SQL query. + * CTEs are defined with: WITH cte_name AS (...), another_cte AS (...) + */ +function parseCteNames(query: string): Set { + const cteNames = new Set(); + + // Match the WITH clause - handles both regular and RECURSIVE CTEs + const withMatch = query.match(/^\s*with\s+(?:recursive\s+)?(.*?)(?:\s+select\s)/i); + + if (!withMatch) { + return cteNames; + } + + const withClause = withMatch[1]; + + // Match CTE names - they appear before "AS" keyword followed by opening paren + // This handles: cte_name AS (, "quoted_name" AS ( + const cteNameRegex = /([a-zA-Z0-9_]+|"[^"]+"|'[^']+')\s+as\s*\(/gi; + + for (const match of withClause.matchAll(cteNameRegex)) { + const cteName = match[1].replace(/['"]/g, '').toLowerCase(); + cteNames.add(cteName); + } + + return cteNames; } // Helper function to clean table names function cleanTableName(tableName: string): string { - // Remove quotes - let name = tableName.replace(/['"]/g, ''); + // Remove quotes and parentheses + let name = tableName.replace(/['"()]/g, ''); // Remove alias if present (both "AS alias" and "alias" forms) if (name.includes(' as ')) {