From 89e0fe4f2870151b9d5e2e6aaa5bd8c0f3ac51b8 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 24 Dec 2025 10:14:53 -0700 Subject: [PATCH] feature: Explain tab in SQL editor that shows output of explain analyze (#41569) * wip: explain tab in results editor * updated to add sql explain * updated to default back to results * updated explain function * updated case with multiple statements * updated to reset explain query results * added tests for semi colon comments * feature: add explain w/ AI on pretty-explain tab (#41588) * wip: added explain with AI * wip: updated header with new buttons * updated prompt * remove any types * removed unused flag * updated header * formatted code --- .../ExplainVisualizer.Header.tsx | 104 ++++++--- .../ExplainVisualizer/ExplainVisualizer.ai.ts | 45 ++++ .../ExplainVisualizer/ExplainVisualizer.tsx | 13 +- .../ExplainVisualizer.utils.ts | 45 +++- .../interfaces/SQLEditor/SQLEditor.tsx | 106 ++++++++- .../SQLEditor/UtilityPanel/Results.tsx | 43 ---- .../SQLEditor/UtilityPanel/UtilityActions.tsx | 2 +- .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 39 +++- .../UtilityPanel/UtilityTabExplain.tsx | 106 +++++++++ .../UtilityPanel/UtilityTabResults.tsx | 2 +- apps/studio/state/sql-editor-v2.ts | 30 +++ .../ExplainVisualizer.utils.test.ts | 216 ++++++++++++++++++ 12 files changed, 669 insertions(+), 82 deletions(-) create mode 100644 apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.ai.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx create mode 100644 apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx index d90a1305ac2..5aef13ebcf7 100644 --- a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx @@ -1,5 +1,13 @@ -import { Activity, ArrowUp, Clock, Database, GitMerge, Hash, Zap } from 'lucide-react' -import { Badge } from 'ui' +import { ArrowUp, Eye, Code } from 'lucide-react' + +import { useFlag } from 'common' +import { AiIconAnimation, Button } from 'ui' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { buildExplainPrompt } from './ExplainVisualizer.ai' +import type { QueryPlanRow } from './ExplainVisualizer.types' export interface ExplainSummary { totalTime: number @@ -11,41 +19,83 @@ export interface ExplainHeaderProps { mode: 'visual' | 'raw' onToggleMode: () => void summary?: ExplainSummary + id?: string + rows?: readonly QueryPlanRow[] } -export function ExplainHeader({ mode, onToggleMode, summary }: ExplainHeaderProps) { +export function ExplainHeader({ mode, onToggleMode, summary, id, rows }: ExplainHeaderProps) { const isVisual = mode === 'visual' + const snapV2 = useSqlEditorV2StateSnapshot() + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + const handleExplainWithAI = () => { + if (!id) return + const snippet = snapV2.snippets[id]?.snippet + if (!snippet?.content?.sql) return + + const { query, prompt } = buildExplainPrompt({ + sql: snippet.content.sql, + explainPlanRows: (rows as QueryPlanRow[]) ?? [], + }) + + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ + sqlSnippets: [ + { + label: 'Query', + content: query, + }, + ], + initialMessage: prompt, + }) + } + const hasSummaryStats = isVisual && summary && (summary.totalTime > 0 || (summary.hasSeqScan && !summary.hasIndexScan)) return (
{/* Title row */} -
-

Query Execution Plan

- {/* Summary stats - only show in visual mode when we have the data */} - {hasSummaryStats && ( -
- {summary.totalTime > 0 && ( -
- / - Total time - - {summary.totalTime.toFixed(2)}ms - -
- )} -
- )} - +
+
+

Query Execution Plan

+ {/* Summary stats - only show in visual mode when we have the data */} + {hasSummaryStats && ( +
+ {summary.totalTime > 0 && ( +
+ / + Total time + + {summary.totalTime.toFixed(2)}ms + +
+ )} +
+ )} +
+
+ {id && rows && ( + + )} + +
{/* How to read */} diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.ai.ts b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.ai.ts new file mode 100644 index 00000000000..1258cb526b4 --- /dev/null +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.ai.ts @@ -0,0 +1,45 @@ +import type { QueryPlanRow } from './ExplainVisualizer.types' + +export interface ExplainPromptInput { + sql: string + explainPlanRows: QueryPlanRow[] +} + +export interface ExplainPromptOutput { + query: string + prompt: string +} + +export function buildExplainPrompt({ + sql, + explainPlanRows, +}: ExplainPromptInput): ExplainPromptOutput { + const explainPlan = explainPlanRows.map((row) => row['QUERY PLAN']).join('\n') + + const prompt = `Explain this PostgreSQL EXPLAIN ANALYZE output in simple terms: + +\`\`\`sql +${sql} +\`\`\` + +\`\`\` +${explainPlan} +\`\`\` + +Format your response with: + +**What it does** - 1-2 sentences. + +**How it runs** - Briefly explain the plan from bottom to top in plain English. Mention key operations (scans, joins, sorts) and why PostgreSQL chose them. + +**Issues** - Identify bottlenecks: slowest steps, sequential scans on large tables, inefficient joins, missing indexes. Be specific with timings from the plan. + +**Fixes** - 2-3 specific suggestions with CREATE INDEX statements if applicable. + +Keep it concise. Focus on actionable insights.` + + return { + query: sql, + prompt, + } +} diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.tsx b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.tsx index df339cd52dc..764e89a345b 100644 --- a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.tsx +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.tsx @@ -7,9 +7,10 @@ import type { QueryPlanRow } from './ExplainVisualizer.types' export interface ExplainVisualizerProps { rows: readonly QueryPlanRow[] onShowRaw?: () => void + id?: string } -export function ExplainVisualizer({ rows, onShowRaw }: ExplainVisualizerProps) { +export function ExplainVisualizer({ rows, onShowRaw, id }: ExplainVisualizerProps) { const parsedTree = useMemo(() => createNodeTree(rows), [rows]) const maxDuration = useMemo(() => calculateMaxDuration(parsedTree), [parsedTree]) const summary = useMemo(() => calculateSummary(parsedTree), [parsedTree]) @@ -26,7 +27,15 @@ export function ExplainVisualizer({ rows, onShowRaw }: ExplainVisualizerProps) { return (
- {onShowRaw && } + {onShowRaw && ( + + )} {/* Plan nodes */}
diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts index b44e2797a8b..5887d9c02b1 100644 --- a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts @@ -126,10 +126,15 @@ export function getOperationColor(operation: string): string { return 'text-foreground-light' } -export function isExplainQuery(rows: readonly any[]): boolean { - return ( - rows.length > 0 && rows[0].hasOwnProperty('QUERY PLAN') && Object.keys(rows[0]).length === 1 - ) +export function isExplainQuery(rows: readonly unknown[]): boolean { + if (rows.length === 0) return false + const firstRow = rows[0] + if (typeof firstRow !== 'object' || firstRow === null) return false + return 'QUERY PLAN' in firstRow && Object.keys(firstRow).length === 1 +} + +export function isExplainSql(sql: string): boolean { + return /^\s*explain\b/i.test(sql) } export function formatNodeDuration(ms: number | undefined): string { @@ -185,3 +190,35 @@ export function getScanBorderColor(operation: string): string { // Default neutral color for other operations return 'border-l-border-muted' } + +export function splitSqlStatements(sql: string): string[] { + // Enhanced tokenizer that handles: + // - Single-quoted strings: '...' (with '' escaping) + // - Double-quoted strings: "..." (with "" escaping) + // - Dollar-quoted strings: $tag$...$tag$ + // - Line comments: -- (until end of line) + // - Block comments: /* ... */ (may be multiline) + // - Semicolons: ; + const tokens = + sql.match( + /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|--[^\r\n]*|\/\*[\s\S]*?\*\/|;|[^'"$;\-\/]+|./g + ) || [] + + const statements: string[] = [] + let current = '' + + for (const token of tokens) { + if (token === ';') { + if (current.trim()) statements.push(current.trim()) + current = '' + } else { + current += token + } + } + + if (current.trim()) { + statements.push(current.trim()) + } + + return statements +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 8557a1b6643..c58b1f93a4b 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -6,7 +6,12 @@ import { useRouter } from 'next/router' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' +import { + isExplainQuery, + isExplainSql, + splitSqlStatements, +} from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget' import { GridFooter } from 'components/ui/GridFooter' @@ -93,6 +98,7 @@ export const SQLEditor = () => { const getImpersonatedRoleState = useGetImpersonatedRoleState() const databaseSelectorState = useDatabaseSelectorStateSnapshot() const { isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const showPrettyExplain = useFlag('ShowPrettyExplain') const { sourceSqlDiff, @@ -119,6 +125,7 @@ export const SQLEditor = () => { const [queryHasDestructiveOperations, setQueryHasDestructiveOperations] = useState(false) const [queryHasUpdateWithoutWhere, setQueryHasUpdateWithoutWhere] = useState(false) const [showWidget, setShowWidget] = useState(false) + const [activeUtilityTab, setActiveUtilityTab] = useState('results') // generate a new snippet title and an id to be used for new snippets. The dependency on urlId is to avoid a bug which // shows up when clicking on the SQL Editor while being in the SQL editor on a random snippet. @@ -151,7 +158,17 @@ export const SQLEditor = () => { const { mutate: sendEvent } = useSendEventMutation() const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({ onSuccess(data, vars) { - if (id) snapV2.addResult(id, data.result, vars.autoLimit) + if (id) { + snapV2.addResult(id, data.result, vars.autoLimit) + + if (showPrettyExplain && isExplainQuery(data.result)) { + snapV2.addExplainResult(id, data.result) + setActiveUtilityTab('explain') + } else if (activeUtilityTab === 'explain') { + // If on Explain tab but ran a non-EXPLAIN query, switch to Results tab + setActiveUtilityTab('results') + } + } // revalidate lint query queryClient.invalidateQueries({ queryKey: lintKeys.lint(ref) }) @@ -194,6 +211,21 @@ export const SQLEditor = () => { }, }) + const { mutate: executeExplain, isPending: isExplainExecuting } = useExecuteSqlMutation({ + onSuccess(data) { + if (id) { + snapV2.addExplainResult(id, data.result) + setActiveUtilityTab('explain') + } + }, + onError(error) { + if (id) { + snapV2.addExplainResultError(id, error) + setActiveUtilityTab('explain') + } + }, + }) + const setAiTitle = useCallback( async (id: string, sql: string) => { try { @@ -334,6 +366,72 @@ export const SQLEditor = () => { ] ) + const executeExplainQuery = useCallback(async () => { + if (isDiffOpen) return + + // use the latest state + const state = getSqlEditorV2StateSnapshot() + const snippet = state.snippets[id] + + if (editorRef.current !== null && !isExplainExecuting && project !== undefined) { + const editor = editorRef.current + const selection = editor.getSelection() + const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined + + const sql = snippet + ? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content?.sql + : selectedValue || editorRef.current?.getValue() + + // Check for multiple statements - EXPLAIN only works on a single statement + const statements = splitSqlStatements(sql) + if (statements.length > 1) { + snapV2.addExplainResultError(id, { + message: + 'EXPLAIN only works on a single SQL statement. Please select just one query to analyze.', + }) + setActiveUtilityTab('explain') + return + } + + if (lineHighlights.length > 0) { + editor?.deltaDecorations(lineHighlights, []) + setLineHighlights([]) + } + + const impersonatedRoleState = getImpersonatedRoleState() + const connectionString = databases?.find( + (db) => db.identifier === databaseSelectorState.selectedDatabaseId + )?.connectionString + if (!isValidConnString(connectionString)) { + return toast.error('Unable to run query: Connection string is missing') + } + + // Wrap the query with EXPLAIN ANALYZE only if it's not already an EXPLAIN query + const explainSql = isExplainSql(sql) ? sql : `EXPLAIN ANALYZE ${sql}` + + executeExplain({ + projectRef: project.ref, + connectionString: connectionString, + sql: wrapWithRoleImpersonation(explainSql, impersonatedRoleState), + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + handleError: (error) => { + throw error + }, + }) + } + }, [ + isDiffOpen, + id, + isExplainExecuting, + project, + executeExplain, + getImpersonatedRoleState, + databaseSelectorState.selectedDatabaseId, + databases, + lineHighlights, + snapV2, + ]) + const handleNewQuery = useCallback( async (sql: string, name: string) => { if (!ref) return console.error('Project ref is required') @@ -807,11 +905,15 @@ export const SQLEditor = () => { )} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index df65ad64d7e..222fb6ab931 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -12,10 +12,6 @@ import { copyToClipboard, } from 'ui' import { CellDetailPanel } from './CellDetailPanel' -import { ExplainHeader } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.Header' -import { ExplainVisualizer } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer' -import { useFeatureFlags, useFlag } from 'common' -import { isExplainQuery } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' function formatClipboardValue(value: any) { if (value === null) return '' @@ -28,12 +24,6 @@ function formatClipboardValue(value: any) { const Results = ({ rows }: { rows: readonly any[] }) => { const [expandCell, setExpandCell] = useState(false) const [cellPosition, setCellPosition] = useState<{ column: any; row: any; rowIdx: number }>() - const [showRaw, setShowRaw] = useState(false) - - const showPrettyExplain = useFlag('ShowPrettyExplain') - - // Check if this is an EXPLAIN query result - const isValidExplainQuery = isExplainQuery(rows) const formatter = (column: any, row: any) => { const cellValue = row[column] @@ -116,39 +106,6 @@ const Results = ({ rows }: { rows: readonly any[] }) => { } }) - // Show pretty explain query results as diagram - if (showPrettyExplain && isValidExplainQuery) { - if (showRaw) { - return ( -
- setShowRaw(false)} /> -
- '[&>.rdg-cell]:items-center'} - onSelectedCellChange={setCellPosition} - onCellKeyDown={handleCopyCell} - /> - setExpandCell(false)} - /> -
-
- ) - } - - return ( -
- setShowRaw(true)} /> -
- ) - } - return ( <> {rows.length === 0 ? ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 93147e806ca..ac0cae6e8f3 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -67,7 +67,7 @@ const UtilityActions = ({ const removeFavorite = () => snapV2.removeFavorite(id) const onSelectDatabase = (databaseId: string) => { - snapV2.resetResult(id) + snapV2.resetResults(id) setLastSelectedDb(databaseId) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index e1554bca526..68f98ad63a7 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -1,6 +1,6 @@ import { toast } from 'sonner' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { DownloadResultsButton } from 'components/ui/DownloadResultsButton' import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' import { Snippet } from 'data/content/sql-folders-query' @@ -11,16 +11,21 @@ import { TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn import { ChartConfig } from './ChartConfig' import UtilityActions from './UtilityActions' import UtilityTabResults from './UtilityTabResults' +import { UtilityTabExplain } from './UtilityTabExplain' export type UtilityPanelProps = { id: string isExecuting?: boolean + isExplainExecuting?: boolean isDebugging?: boolean isDisabled?: boolean hasSelection: boolean prettifyQuery: () => void executeQuery: () => void + executeExplainQuery: () => void onDebug: () => void + activeTab?: string + onActiveTabChange?: (tab: string) => void } const DEFAULT_CHART_CONFIG: ChartConfig = { @@ -35,20 +40,33 @@ const DEFAULT_CHART_CONFIG: ChartConfig = { const UtilityPanel = ({ id, isExecuting, + isExplainExecuting, isDebugging, isDisabled, hasSelection, prettifyQuery, executeQuery, + executeExplainQuery, onDebug, + activeTab = 'results', + onActiveTabChange, }: UtilityPanelProps) => { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() const snapV2 = useSqlEditorV2StateSnapshot() + const showPrettyExplain = useFlag('ShowPrettyExplain') const snippet = snapV2.snippets[id]?.snippet const result = snapV2.results[id]?.[0] + const handleTabChange = (tab: string) => { + // When switching to the explain tab, trigger the explain query + if (tab === 'explain') { + executeExplainQuery() + } + onActiveTabChange?.(tab) + } + const { mutate: sendEvent } = useSendEventMutation() const { mutate: upsertContent } = useContentUpsertMutation({ @@ -110,12 +128,22 @@ const UtilityPanel = ({ } return ( - +
Results + {/* Only show Explain tab if ShowPrettyExplain flag is on */} + {showPrettyExplain && ( + + Explain + + )} Chart @@ -164,6 +192,13 @@ const UtilityPanel = ({ /> + {/* Only render Explain tab content if ShowPrettyExplain flag is on */} + {showPrettyExplain && ( + + + + )} + diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx new file mode 100644 index 00000000000..e799c83ca56 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx @@ -0,0 +1,106 @@ +import { Loader2 } from 'lucide-react' +import { useState } from 'react' + +import CopyButton from 'components/ui/CopyButton' +import { ExplainVisualizer } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer' +import { ExplainHeader } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.Header' +import { isExplainQuery } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import Results from './Results' + +export type UtilityTabExplainProps = { + id: string + isExecuting?: boolean +} + +export function UtilityTabExplain({ id, isExecuting }: UtilityTabExplainProps) { + const snapV2 = useSqlEditorV2StateSnapshot() + const explainResult = snapV2.explainResults[id] + const [mode, setMode] = useState<'visual' | 'raw'>('visual') + + if (isExecuting) { + return ( +
+ +

Running EXPLAIN ANALYZE...

+
+ ) + } + + if (explainResult?.error) { + const formattedError = (explainResult.error?.formattedError?.split('\n') ?? []).filter( + (x: string) => x.length > 0 + ) + + return ( +
+
+
+ {formattedError.length > 0 ? ( + formattedError.map((x: string, i: number) => ( +
+                  {x}
+                
+ )) + ) : ( +

+ Error: {explainResult.error?.message} +

+ )} +
+ +
+ {formattedError.length > 0 && ( + + + + + + Copy error + + + )} +
+
+
+ ) + } + + if (!explainResult || explainResult.rows.length === 0) { + return ( +
+

+ No execution plan available. The query will be analyzed when you switch to this tab. +

+
+ ) + } + + const isValidExplain = isExplainQuery(explainResult.rows) + + if (!isValidExplain) { + return ( +
+

+ Unable to parse explain results. Please try running the query again. +

+
+ ) + } + + const toggleMode = () => setMode(mode === 'visual' ? 'raw' : 'visual') + + return ( +
+ {mode === 'visual' ? ( + + ) : ( + <> + + + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index 7b52fd47632..31cd91d7926 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -138,7 +138,7 @@ const UtilityTabResults = forwardRef( type="default" onClick={() => { state.setSelectedDatabaseId(ref) - snapV2.resetResult(id) + snapV2.resetResults(id) }} > Switch to primary database diff --git a/apps/studio/state/sql-editor-v2.ts b/apps/studio/state/sql-editor-v2.ts index 671153c60c6..fc6e4c45f96 100644 --- a/apps/studio/state/sql-editor-v2.ts +++ b/apps/studio/state/sql-editor-v2.ts @@ -5,6 +5,7 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' import { devtools, proxyMap } from 'valtio/utils' import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import type { QueryPlanRow } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.types' import { upsertContent, UpsertContentPayload } from 'data/content/content-upsert-mutation' import { contentKeys } from 'data/content/keys' import { createSQLSnippetFolder } from 'data/content/sql-folder-create-mutation' @@ -53,6 +54,13 @@ export const sqlEditorState = proxy({ autoLimit?: number }[] }, + // Explain results, if any, for a snippet + explainResults: {} as { + [snippetId: string]: { + rows: QueryPlanRow[] + error?: { message: string; formattedError?: string } + } + }, // Synchronous saving of folders and snippets (debounce behavior) // key is the snippet id, value is shouldInvalidate needsSaving: proxyMap([]), @@ -83,6 +91,7 @@ export const sqlEditorState = proxy({ sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet } sqlEditorState.results[snippet.id] = [] + sqlEditorState.explainResults[snippet.id] = { rows: [] } sqlEditorState.savingStates[snippet.id] = 'IDLE' }, @@ -154,6 +163,9 @@ export const sqlEditorState = proxy({ const { [id]: result, ...otherResults } = sqlEditorState.results sqlEditorState.results = otherResults + const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults + sqlEditorState.explainResults = otherExplainResults + if (!skipSave) sqlEditorState.needsSaving.delete(id) }, @@ -255,6 +267,24 @@ export const sqlEditorState = proxy({ sqlEditorState.results[id] = [] } }, + + addExplainResult: (id: string, results: QueryPlanRow[]) => { + // Use ref() to prevent Valtio from creating proxies for each row object + sqlEditorState.explainResults[id] = { rows: ref(results) } + }, + + addExplainResultError: (id: string, error: { message: string; formattedError?: string }) => { + sqlEditorState.explainResults[id] = { rows: ref([]), error } + }, + + resetExplainResult: (id: string) => { + sqlEditorState.explainResults[id] = { rows: [] } + }, + + resetResults: (id: string) => { + sqlEditorState.resetResult(id) + sqlEditorState.resetExplainResult(id) + }, }) // ======================================================================== diff --git a/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts b/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts new file mode 100644 index 00000000000..15aa91be03b --- /dev/null +++ b/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts @@ -0,0 +1,216 @@ +import { describe, test, expect } from 'vitest' +import { + splitSqlStatements, + isExplainQuery, + isExplainSql, + formatNodeDuration, +} from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' + +describe('isExplainQuery', () => { + test('returns true for valid EXPLAIN result rows', () => { + const rows = [{ 'QUERY PLAN': 'Seq Scan on users' }] + expect(isExplainQuery(rows)).toBe(true) + }) + + test('returns false for empty array', () => { + expect(isExplainQuery([])).toBe(false) + }) + + test('returns false for regular query results', () => { + const rows = [{ id: 1, name: 'John' }] + expect(isExplainQuery(rows)).toBe(false) + }) +}) + +describe('isExplainSql', () => { + test('returns true for EXPLAIN queries', () => { + expect(isExplainSql('EXPLAIN SELECT * FROM users')).toBe(true) + expect(isExplainSql('explain analyze SELECT * FROM users')).toBe(true) + expect(isExplainSql(' EXPLAIN SELECT 1')).toBe(true) + }) + + test('returns false for non-EXPLAIN queries', () => { + expect(isExplainSql('SELECT * FROM users')).toBe(false) + expect(isExplainSql('INSERT INTO users VALUES (1)')).toBe(false) + }) +}) + +describe('formatNodeDuration', () => { + test('returns "-" for undefined', () => { + expect(formatNodeDuration(undefined)).toBe('-') + }) + + test('formats seconds for large values', () => { + expect(formatNodeDuration(1500)).toBe('1.50s') + }) + + test('formats milliseconds for medium values', () => { + expect(formatNodeDuration(25.5)).toBe('25.50ms') + }) + + test('formats microseconds for small values', () => { + expect(formatNodeDuration(0.0005)).toBe('0.5µs') + }) +}) + +describe('splitSqlStatements', () => { + test('splits multiple statements by semicolon', () => { + const sql = 'SELECT * FROM users; SELECT * FROM orders;' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT * FROM users') + expect(result[1]).toBe('SELECT * FROM orders') + }) + + test('handles single statement', () => { + const sql = 'SELECT * FROM users' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(1) + expect(result[0]).toBe('SELECT * FROM users') + }) + + test('ignores semicolons inside single quotes', () => { + const sql = "SELECT * FROM users WHERE name = 'foo;bar'; SELECT 1" + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe("SELECT * FROM users WHERE name = 'foo;bar'") + }) + + test('ignores semicolons inside dollar quotes', () => { + const sql = 'SELECT $$ text with ; semicolon $$; SELECT 1' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT $$ text with ; semicolon $$') + }) + + test('returns empty array for empty input', () => { + expect(splitSqlStatements('')).toHaveLength(0) + expect(splitSqlStatements(' ')).toHaveLength(0) + }) + + test('ignores semicolons inside line comments', () => { + const sql = 'SELECT * FROM users -- this is a comment; with semicolon' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(1) + expect(result[0]).toBe('SELECT * FROM users -- this is a comment; with semicolon') + }) + + test('treats semicolons after line comments as separators', () => { + const sql = 'SELECT * FROM users -- comment\n; SELECT * FROM orders' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT * FROM users -- comment') + expect(result[1]).toBe('SELECT * FROM orders') + }) + + test('ignores semicolons inside block comments', () => { + const sql = `SELECT * FROM users /* single-line; comment */ WHERE id = 1; +SELECT * FROM orders +/* multi-line comment + with semicolon; inside + multiple lines */ +WHERE status = 'active'` + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT * FROM users /* single-line; comment */ WHERE id = 1') + expect(result[1]).toContain('/* multi-line comment') + expect(result[1]).toContain('with semicolon; inside') + }) + + test('handles mixed line comments and real semicolons', () => { + const sql = `SELECT * FROM users -- first query; fake semicolon +; SELECT * FROM orders -- another comment; fake +WHERE status = 'active';` + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toContain('SELECT * FROM users') + expect(result[0]).toContain('-- first query; fake semicolon') + expect(result[1]).toContain('SELECT * FROM orders') + expect(result[1]).toContain("WHERE status = 'active'") + }) + + test('handles mixed block comments and real semicolons', () => { + const sql = 'SELECT 1; /* comment; with semicolon */ SELECT 2;' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT 1') + expect(result[1]).toBe('/* comment; with semicolon */ SELECT 2') + }) + + test('handles multiple consecutive line comments', () => { + const sql = `-- Comment 1; with semicolon +-- Comment 2; another semicolon +SELECT * FROM users` + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(1) + expect(result[0]).toContain('-- Comment 1; with semicolon') + expect(result[0]).toContain('-- Comment 2; another semicolon') + expect(result[0]).toContain('SELECT * FROM users') + }) + + test('handles comments at end of statement', () => { + const sql = `SELECT * FROM users; -- end comment; with semicolon +SELECT * FROM orders /* block comment; */` + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT * FROM users') + expect(result[1]).toContain('SELECT * FROM orders') + expect(result[1]).toContain('/* block comment; */') + }) + + test('handles complex mix of strings, comments, and semicolons', () => { + const sql = `-- Initial comment; with semicolon +SELECT 'string; with semicolon' FROM users /* block; comment */; +-- Next query +SELECT "quoted; identifier" FROM orders -- line; comment +WHERE data = $$ dollar; quote $$;` + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(2) + expect(result[0]).toContain("SELECT 'string; with semicolon' FROM users") + expect(result[0]).toContain('/* block; comment */') + expect(result[1]).toContain('SELECT "quoted; identifier" FROM orders') + expect(result[1]).toContain('$$ dollar; quote $$') + }) + + test('handles statement that is only a comment', () => { + const sql = '-- Just a comment; with semicolon' + const result = splitSqlStatements(sql) + + expect(result).toHaveLength(1) + expect(result[0]).toBe('-- Just a comment; with semicolon') + }) + + test('handles empty statements between semicolons', () => { + const sql = 'SELECT 1;; SELECT 2' + const result = splitSqlStatements(sql) + + // Empty statements are filtered out by trim() + expect(result).toHaveLength(2) + expect(result[0]).toBe('SELECT 1') + expect(result[1]).toBe('SELECT 2') + }) + + test('handles EXPLAIN queries with comments (single statement verification)', () => { + const sql = `-- Query to analyze; note the semicolon +EXPLAIN ANALYZE +SELECT * FROM users +WHERE created_at > NOW() - INTERVAL '1 day' -- last 24 hours; active users` + const result = splitSqlStatements(sql) + + // EXPLAIN only works with single statements - verify comments don't cause false splits + expect(result).toHaveLength(1) + expect(result[0]).toContain('EXPLAIN ANALYZE') + }) +})