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')
+ })
+})