From 3b04cbdd2dfc58f0b23ff33c9dc629a20ffb6d61 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 1 Jul 2026 01:36:06 +0200 Subject: [PATCH] fix(alerts): pretty-print + highlight raw SQL, stop raw-query chart flash On the alert overview, raw-SQL rules showed their query as an unstyled, right-aligned blob and flashed a "Raw SQL has no live preview" placeholder before the checks-driven chart loaded. - Add formatSql() (apps/web/src/lib/sql-format.ts): a conservative, dependency-free, ClickHouse-aware pretty-printer that breaks top-level clauses, SELECT columns, and WHERE AND/OR onto their own lines while keeping strings, comments, $__macros, Map['key'] access, and function-call commas atomic. Falls back to the original text on any failure. Unit-tested. - Render the Raw SQL in the Configuration card as a full-width, syntax- highlighted code block (reusing tokenizeSql) that matches the dashboard SQL editor. - Gate the hero chart's loading state on checks for raw_query rules (Result.isInitial(checksResult)) so the placeholder no longer flashes before the checks-driven chart appears; the genuine "no checks yet" empty state is unchanged. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/sql-format.test.ts | 72 ++++++++ apps/web/src/lib/sql-format.ts | 235 +++++++++++++++++++++++++ apps/web/src/routes/alerts/$ruleId.tsx | 31 +++- 3 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/sql-format.test.ts create mode 100644 apps/web/src/lib/sql-format.ts diff --git a/apps/web/src/lib/sql-format.test.ts b/apps/web/src/lib/sql-format.test.ts new file mode 100644 index 00000000..0c8b1b7a --- /dev/null +++ b/apps/web/src/lib/sql-format.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" +import { formatSql } from "./sql-format" + +describe("formatSql", () => { + it("reflows the canonical alert query onto clause/column/condition lines", () => { + const input = + "SELECT quantile(0.95)(Duration / 1e6) AS value, count() AS samples FROM traces WHERE ServiceName = 'api-v2' AND SpanName = 'ChartService.getChartData' AND SpanAttributes['cache.fluxSeconds'] = '604800' AND $__timeFilter(Timestamp) AND $__orgFilter" + + expect(formatSql(input)).toBe( + [ + "SELECT", + " quantile(0.95)(Duration / 1e6) AS value,", + " count() AS samples", + "FROM traces", + "WHERE ServiceName = 'api-v2'", + " AND SpanName = 'ChartService.getChartData'", + " AND SpanAttributes['cache.fluxSeconds'] = '604800'", + " AND $__timeFilter(Timestamp)", + " AND $__orgFilter", + ].join("\n"), + ) + }) + + it("never splits commas or parens inside a function call", () => { + const out = formatSql("SELECT quantile(0.95)(Duration / 1e6) AS v FROM t") + expect(out).toContain("quantile(0.95)(Duration / 1e6)") + // the only newline-introduced column line is the single SELECT item + expect(out).toBe(["SELECT", " quantile(0.95)(Duration / 1e6) AS v", "FROM t"].join("\n")) + }) + + it("keeps map access and string literals intact", () => { + const out = formatSql("SELECT a FROM t WHERE SpanAttributes['cache.fluxSeconds'] = '604800'") + expect(out).toContain("SpanAttributes['cache.fluxSeconds'] = '604800'") + }) + + it("keeps $__macros atomic and breaks the AND before them", () => { + const out = formatSql("SELECT a FROM t WHERE x = 1 AND $__timeFilter(Timestamp) AND $__orgFilter") + expect(out).toContain("$__timeFilter(Timestamp)") + expect(out).toContain("\n AND $__orgFilter") + }) + + it("never reformats inside string literals", () => { + const out = formatSql("SELECT 'a, from b WHERE c' AS lit FROM t") + expect(out).toContain("'a, from b WHERE c'") + // the literal's lowercase from/where must not be treated as clauses + expect(out).toBe(["SELECT", " 'a, from b WHERE c' AS lit", "FROM t"].join("\n")) + }) + + it("only breaks top-level AND, not those nested in parentheses", () => { + const out = formatSql("SELECT a FROM t WHERE (x AND y) AND z") + expect(out).toBe(["SELECT", " a", "FROM t", "WHERE (x AND y)", " AND z"].join("\n")) + }) + + it("does not break GROUP BY columns or the BY keyword", () => { + const out = formatSql("SELECT a, b FROM t GROUP BY a, b ORDER BY a") + expect(out).toContain("GROUP BY a, b") + expect(out).toContain("ORDER BY a") + }) + + it("is idempotent", () => { + const input = + "SELECT quantile(0.95)(Duration / 1e6) AS value, count() AS samples FROM traces WHERE a = 1 AND b = 2 AND $__orgFilter" + const once = formatSql(input) + expect(formatSql(once)).toBe(once) + }) + + it("returns empty for blank input and does not throw on odd input", () => { + expect(formatSql(" ")).toBe("") + expect(() => formatSql("SELECT (((")).not.toThrow() + expect(() => formatSql("@#$ %^&")).not.toThrow() + }) +}) diff --git a/apps/web/src/lib/sql-format.ts b/apps/web/src/lib/sql-format.ts new file mode 100644 index 00000000..c5ef6f10 --- /dev/null +++ b/apps/web/src/lib/sql-format.ts @@ -0,0 +1,235 @@ +/** + * Conservative, dependency-free SQL pretty-printer tuned for the ClickHouse + * dialect Maple writes (Grafana-style `$__macros`, `Map['key']` access, the + * double-call `quantile(0.95)(x)` form). It reflows a single statement so the + * major clauses, SELECT columns, and WHERE conditions land on their own lines — + * nothing more. It never rewrites the query's meaning: string literals, + * comments, macros, and bracketed access are treated as atomic, and any failure + * falls back to the original text. + * + * Pairs with `tokenizeSql` (sql-highlight.ts): format first, then highlight the + * formatted string. + */ + +const INDENT = " " + +/** Keywords that start a top-level clause and force a line break before them. */ +const CLAUSE_BREAK = new Set([ + "SELECT", + "FROM", + "WHERE", + "PREWHERE", + "GROUP", + "ORDER", + "HAVING", + "LIMIT", + "UNION", + "SETTINGS", + "JOIN", + "INNER", + "LEFT", + "RIGHT", + "FULL", + "CROSS", +]) + +/** Join modifiers that chain into one `LEFT OUTER JOIN`-style line, not a break each. */ +const JOIN_CHAIN = new Set(["INNER", "LEFT", "RIGHT", "FULL", "CROSS", "OUTER", "JOIN"]) + +/** + * Reserved words — used to tell `count(` (function, no space) from `IN (` + * (keyword, keep the space). Superset of CLAUSE_BREAK plus operators/modifiers. + */ +const KEYWORDS = new Set([ + ...CLAUSE_BREAK, + "BY", + "ON", + "AS", + "AND", + "OR", + "NOT", + "NULL", + "IN", + "IS", + "LIKE", + "ILIKE", + "BETWEEN", + "INTERVAL", + "CASE", + "WHEN", + "THEN", + "ELSE", + "END", + "DISTINCT", + "ALL", + "ANY", + "ASC", + "DESC", + "USING", + "WITH", + "OUTER", + "ARRAY", + "TUPLE", + "ASOF", + "FINAL", + "SAMPLE", + "FORMAT", + "VALUES", + "OFFSET", + "SEMI", + "ANTI", + "TRUE", + "FALSE", +]) + +type TokType = "comment" | "string" | "macro" | "op" | "num" | "word" | "punct" | "other" + +interface Tok { + type: TokType + value: string + /** Uppercased value for word/op/punct comparisons; "" for the rest. */ + up: string +} + +const LEX = + /(\/\*[\s\S]*?\*\/|--[^\n]*)|('(?:''|\\.|[^'\\])*'|"(?:""|\\.|[^"\\])*")|(\$__[A-Za-z_][A-Za-z0-9_]*)|([<>=!]=|<>|->|::|\|\|)|(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([A-Za-z_][A-Za-z0-9_]*)|(\s+)|([(),[\].;])|([^\s])/g + +function lex(sql: string): Tok[] { + const toks: Tok[] = [] + LEX.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = LEX.exec(sql)) !== null) { + const [, comment, str, macro, op, num, word, ws, punct, other] = m + if (ws !== undefined) continue + if (comment !== undefined) toks.push({ type: "comment", value: comment, up: "" }) + else if (str !== undefined) toks.push({ type: "string", value: str, up: "" }) + else if (macro !== undefined) toks.push({ type: "macro", value: macro, up: "" }) + else if (op !== undefined) toks.push({ type: "op", value: op, up: op }) + else if (num !== undefined) toks.push({ type: "num", value: num, up: "" }) + else if (word !== undefined) toks.push({ type: "word", value: word, up: word.toUpperCase() }) + else if (punct !== undefined) toks.push({ type: "punct", value: punct, up: punct }) + else if (other !== undefined) toks.push({ type: "other", value: other, up: other }) + } + return toks +} + +/** A name that takes a call/index with no space before its `(` or `[`. */ +function isCallable(t: Tok | undefined): boolean { + if (!t) return false + if (t.type === "macro") return true + if (t.type === "word") return !KEYWORDS.has(t.up) + return false +} + +function clauseFor(up: string): string { + switch (up) { + case "SELECT": + return "select" + case "WHERE": + case "PREWHERE": + return "where" + case "HAVING": + return "having" + case "FROM": + return "from" + case "GROUP": + return "group" + case "ORDER": + return "order" + case "INNER": + case "LEFT": + case "RIGHT": + case "FULL": + case "CROSS": + case "JOIN": + return "join" + case "UNION": + return "union" + case "LIMIT": + return "limit" + case "SETTINGS": + return "settings" + default: + return "other" + } +} + +/** Inline spacing between two adjacent tokens (no line break involved). */ +function spacer(prev: Tok, cur: Tok): string { + const cv = cur.value + const pv = prev.value + if (cv === "," || cv === ";" || cv === ")" || cv === "]" || cv === ".") return "" + if (pv === "(" || pv === "[" || pv === ".") return "" + if (cur.type === "op" && cur.value === "::") return "" + if (prev.type === "op" && prev.value === "::") return "" + if ((cv === "(" || cv === "[") && (isCallable(prev) || pv === ")" || pv === "]")) return "" + return " " +} + +export function formatSql(sql: string): string { + try { + const input = sql.trim() + if (!input) return input + const toks = lex(input) + if (toks.length === 0) return input + + let out = "" + let depth = 0 // () nesting + let bracket = 0 // [] nesting + let clause = "" + let prev: Tok | undefined + + for (const t of toks) { + const top = depth === 0 && bracket === 0 + + const isClauseBreak = + t.type === "word" && + top && + CLAUSE_BREAK.has(t.up) && + !(JOIN_CHAIN.has(t.up) && prev?.type === "word" && JOIN_CHAIN.has(prev.up)) + + const isAndOrBreak = + t.type === "word" && + top && + (t.up === "AND" || t.up === "OR") && + (clause === "where" || clause === "having" || clause === "on") + + const afterSelectComma = + prev?.type === "punct" && prev.value === "," && clause === "select" && top + + const firstSelectItem = + clause === "select" && + prev?.type === "word" && + (prev.up === "SELECT" || prev.up === "DISTINCT") && + !(t.type === "word" && t.up === "DISTINCT") + + let sep: string + if (!prev) sep = "" + else if (prev.type === "comment" && prev.value.startsWith("--")) sep = "\n" + else if (isClauseBreak) sep = "\n" + else if (isAndOrBreak || afterSelectComma || firstSelectItem) sep = `\n${INDENT}` + else sep = spacer(prev, t) + + out += sep + t.value + + if (t.type === "word" && top) { + if (isClauseBreak) clause = clauseFor(t.up) + else if (t.up === "ON") clause = "on" + } + + if (t.type === "punct") { + if (t.value === "(") depth++ + else if (t.value === ")") depth = Math.max(0, depth - 1) + else if (t.value === "[") bracket++ + else if (t.value === "]") bracket = Math.max(0, bracket - 1) + } + + prev = t + } + + const result = out.trim() + return result.length > 0 ? result : input + } catch { + return sql.trim() + } +} diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 090a5ee6..9b791eb1 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -62,6 +62,8 @@ import { DropdownMenuTrigger, } from "@maple/ui/components/ui/dropdown-menu" import { useAlertRuleChart } from "@/hooks/use-alert-rule-chart" +import { tokenizeSql } from "@/lib/sql-highlight" +import { formatSql } from "@/lib/sql-format" const tabValues = ["overview", "history"] as const type RuleDetailTab = (typeof tabValues)[number] @@ -431,7 +433,10 @@ function RuleDetailContent() { comparator={rule.comparator} signalType={rule.signalType} window={timelineRange} - loading={chartLoading} + loading={ + chartLoading || + (rule.signalType === "raw_query" && Result.isInitial(checksResult)) + } chartError={chartError} /> @@ -543,11 +548,25 @@ function RuleDetailContent() { )} {rule.signalType === "raw_query" && rule.rawQuerySql && ( - -
-												{rule.rawQuerySql}
-											
-
+
+
Raw SQL
+
+
+													
+														{tokenizeSql(formatSql(rule.rawQuerySql)).map(
+															(token) => (
+																
+																	{token.text}
+																
+															),
+														)}
+													
+												
+
+
)}