From b1002723764b0801f53e1d3d5a81027a1e9fb271 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 15 Jul 2026 21:04:34 -0600 Subject: [PATCH 1/2] chore(sql-editor): remove Pretty Explain feature (#47981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the SQL Editor Pretty Explain feature — the Explain tab, the Run EXPLAIN ANALYZE action + shortcut, and its dead plumbing. It's been gated off behind the `DisablePrettyExplainOnSqlEditor` kill switch for weeks with no usage or complaints. `ExplainVisualizer` / `isExplainQuery` are kept — they're used independently by Query Insights, Query Performance, and the EditorPanel quick-runner. Manually-run `EXPLAIN` queries still render as raw rows in the Results tab. Typecheck, lint, and all affected unit tests pass. Closes FE-3930 ## Summary by CodeRabbit * **Changes** * Removed the SQL editor’s EXPLAIN execution workflow, including its toolbar action, keyboard shortcut, utility tab, and visual query-plan display. * Simplified query execution to focus on standard results and charts. * Improved result clearing when switching databases and refined execution error handling. * Updated SQL editor state and tests to reflect the streamlined experience. --- .../ExplainVisualizer.utils.ts | 42 ---- .../interfaces/SQLEditor/MonacoEditor.tsx | 20 -- .../interfaces/SQLEditor/SQLEditor.types.ts | 2 +- .../SQLEditor/SQLEditor.utils.test.ts | 17 -- .../interfaces/SQLEditor/SQLEditor.utils.ts | 27 +-- .../SQLEditor/SQLEditorControllers.tsx | 39 +--- .../SQLEditor/SQLEditorEditorPanel.tsx | 13 +- .../interfaces/SQLEditor/SQLEditorLayout.tsx | 15 +- .../SQLEditor/UtilityPanel/UtilityActions.tsx | 2 +- .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 32 +-- .../UtilityPanel/UtilityTabExplain.tsx | 124 ----------- .../UtilityPanel/UtilityTabResults.tsx | 2 +- .../SQLEditor/useSqlEditorExecution.ts | 18 +- .../SQLEditor/useSqlEditorExplain.ts | 118 ---------- .../SQLEditor/useSqlEditorShortcuts.ts | 27 +-- .../state/shortcuts/registry/sql-editor.ts | 7 - .../sql-editor-session-state.test.ts | 34 +-- .../sql-editor/sql-editor-session-state.ts | 37 +--- .../components/SQLEditor/SQLEditor.test.tsx | 33 --- .../ExplainVisualizer.utils.test.ts | 206 ------------------ 20 files changed, 26 insertions(+), 789 deletions(-) delete mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx delete mode 100644 apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts index 39998919531bb..6d7a505a65252 100644 --- a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils.ts @@ -133,16 +133,6 @@ export function isExplainQuery(rows: readonly unknown[]): boolean { return 'QUERY PLAN' in firstRow && Object.keys(firstRow).length === 1 } -export function isTextFormatExplain(rows: readonly unknown[]): boolean { - if (!isExplainQuery(rows)) return false - const firstRow = rows[0] as Record - return typeof firstRow['QUERY PLAN'] === 'string' -} - -export function isExplainSql(sql: string): boolean { - return /^\s*explain\b/i.test(sql) -} - export function formatNodeDuration(ms: number | undefined): string { if (ms === undefined) return '-' if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s` @@ -196,35 +186,3 @@ 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/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index 07f140c3bbca1..a8639b27d3341 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -24,8 +24,6 @@ export type MonacoEditorProps = { monacoRef: RefObject autoFocus?: boolean executeQuery: () => void - executeExplainQuery: () => void - showExplainAction?: boolean prettifyQuery: () => void onHasSelection: (value: boolean) => void onMount?: (editor: IStandaloneCodeEditor) => void @@ -48,8 +46,6 @@ export const MonacoEditor = ({ placeholder = '', className, executeQuery, - executeExplainQuery, - showExplainAction = true, prettifyQuery, onHasSelection, onPrompt, @@ -72,9 +68,6 @@ export const MonacoEditor = ({ const snippetRef = useRef(snippet) snippetRef.current = snippet - const executeExplainQueryRef = useRef(executeExplainQuery) - executeExplainQueryRef.current = executeExplainQuery - const prettifyQueryRef = useRef(prettifyQuery) prettifyQueryRef.current = prettifyQuery @@ -115,19 +108,6 @@ export const MonacoEditor = ({ ].join(' && ') ) - if (showExplainAction) { - editor.addAction({ - id: 'run-explain-query', - label: 'Run EXPLAIN ANALYZE', - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter], - contextMenuGroupId: 'operation', - contextMenuOrder: 1, - run: () => { - executeExplainQueryRef.current() - }, - }) - } - editor.addAction({ id: 'save-query', label: 'Save Query', diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 8c87cea61a163..67467a1d55e19 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -39,4 +39,4 @@ export type PotentialIssues = { } /** The tabs available in the SQL editor's results/utility panel. */ -export type UtilityTab = 'results' | 'explain' +export type UtilityTab = 'results' diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 73e51811ea887..389a06b9ff0f4 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -7,7 +7,6 @@ import { appendEnableRLSStatements, assembleCompletionDiff, buildDebugPromptText, - buildExplainSql, checkAlterDatabaseConnection, checkDestructiveQuery, checkIfAppendLimitRequired, @@ -1039,22 +1038,6 @@ describe('SQLEditor.utils:assembleCompletionDiff', () => { }) }) -describe('SQLEditor.utils:buildExplainSql', () => { - it('wraps a non-EXPLAIN query in EXPLAIN ANALYZE and a rollback transaction', () => { - const result = buildExplainSql(safeSql`select 1`) - expect(result).toMatch(/begin;/i) - expect(result).toMatch(/explain analyze select 1/i) - expect(result).toMatch(/rollback;/i) - }) - - it('does not double-wrap a query that is already an EXPLAIN', () => { - const result = buildExplainSql(safeSql`explain select 1`) - expect(result).toMatch(/explain select 1/i) - expect(result).not.toMatch(/analyze/i) - expect(result).toMatch(/rollback;/i) - }) -}) - describe('SQLEditor.utils:buildDebugPromptText', () => { it('builds the debug prompt with the error message and SQL block', () => { const result = buildDebugPromptText('select 1;', 'relation does not exist') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index de40b63f8eb9c..88a89a18d3ed7 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -1,10 +1,4 @@ -import { - safeSql, - untrustedSql, - type SafeSqlFragment, - type UntrustedSqlFragment, -} from '@supabase/pg-meta' -import { wrapWithRollback } from '@supabase/pg-meta/src/query' +import { untrustedSql, type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta' import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' import { @@ -15,14 +9,11 @@ import { updateWithoutWhereRegex, } from './SQLEditor.constants' import { ContentDiff, type IStandaloneCodeEditor } from './SQLEditor.types' -import { isExplainSql } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' import type { SnippetWithContent } from '@/data/content/sql-folders-query' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import { generateUuid } from '@/lib/api/snippets.browser' import { removeCommentsFromSql } from '@/lib/helpers' -import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' import { sqlEventParser } from '@/lib/sql-event-parser' -import type { RoleImpersonationState } from '@/state/role-impersonation-state' export type CreateTableWithoutRLS = { schema?: string @@ -323,22 +314,6 @@ export function assembleCompletionDiff( } } -/** - * Builds the SQL sent for an EXPLAIN run: wraps the query in `EXPLAIN ANALYZE` - * (unless it is already an EXPLAIN), applies role impersonation, and wraps the - * whole thing in a rollback transaction so EXPLAIN ANALYZE never mutates data. - * - * Takes an already-safe fragment — the untrusted→safe promotion happens at the - * caller's user-action boundary (see `acceptUntrustedSql`), not in here. - */ -export function buildExplainSql( - sql: SafeSqlFragment, - impersonatedRoleState?: RoleImpersonationState -): SafeSqlFragment { - const explainSql = isExplainSql(sql) ? sql : safeSql`EXPLAIN ANALYZE ${sql}` - return wrapWithRollback(wrapWithRoleImpersonation(explainSql, impersonatedRoleState)) -} - /** * Builds the prompt text used to ask the assistant to debug a failing snippet. */ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index 44b0998b55d2c..f85aaa6f3d53d 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -1,5 +1,5 @@ import { type UntrustedSqlFragment } from '@supabase/pg-meta' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { createContext, use, @@ -24,7 +24,6 @@ import { useSnippetIdentity } from './useSnippetIdentity' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' import { useSqlEditorAi } from './useSqlEditorAi' import { useSqlEditorExecution } from './useSqlEditorExecution' -import { useSqlEditorExplain } from './useSqlEditorExplain' import { useSqlEditorShortcuts } from './useSqlEditorShortcuts' import { isValidConnString } from '@/data/fetchers' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' @@ -44,7 +43,7 @@ import { createTabId, useTabsStateSnapshot } from '@/state/tabs' * state. * * SQL that a user runs is NOT promoted to safety here — the provider only - * exposes the already-safe `executeQuery` / `executeExplainQuery` pipelines plus + * exposes the already-safe `executeQuery` pipeline plus * `readEditorSql` (which returns an `UntrustedSqlFragment`). Each user-action * site (the toolbar/editor/results components and the shortcut handlers) * promotes with `acceptUntrustedSql` right where the user acts, so the promotion @@ -64,16 +63,13 @@ type AssistantContextValue = { } type SqlEditorExecution = ReturnType -type SqlEditorExplain = ReturnType type SqlEditorMount = ReturnType -/** Running SQL: the safe execute pipelines, their status, and formatting. */ +/** Running SQL: the safe execute pipeline, its status, and formatting. */ type RunContextValue = { executeQuery: SqlEditorExecution['executeQuery'] - executeExplainQuery: SqlEditorExplain['executeExplainQuery'] readEditorSql: () => UntrustedSqlFragment | undefined isExecuting: boolean - isExplainExecuting: boolean potentialIssues: SqlEditorExecution['potentialIssues'] resetPotentialIssues: () => void prettifyQuery: () => void @@ -108,7 +104,7 @@ export const useSqlEditorSnippet = () => useGuardedContext(SnippetContext, 'useS export const useSqlEditorAssistant = () => useGuardedContext(AssistantContext, 'useSqlEditorAssistant') -/** Run / explain execution, warnings, and query formatting. */ +/** Run execution, warnings, and query formatting. */ export const useSqlEditorRun = () => useGuardedContext(RunContext, 'useSqlEditorRun') /** Editor-surface UI state (selection, active results tab, editor mount). */ @@ -124,9 +120,6 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const snapV2 = useSqlEditorV2StateSnapshot() const { setSelectedDatabaseId } = useDatabaseSelectorStateSnapshot() - // [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points - const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor') - const diff = useSqlEditorDiff() const { isDiffOpen } = diff const prompt = useSqlEditorPrompt() @@ -163,27 +156,16 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => id, isDiffOpen, hasSelection, - activeUtilityTab, - setActiveUtilityTab, setAiTitle, }) - const { executeExplainQuery, isExplainExecuting } = useSqlEditorExplain({ - id, - isDiffOpen, - setActiveUtilityTab, - }) - const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt }) const { acceptAiHandler, discardAiHandler } = ai useSqlEditorShortcuts({ isDiffOpen, isPromptOpen: promptState.isOpen, - disablePrettyExplain, prettifyQuery, - readEditorSql, - executeExplainQuery, acceptAiHandler, discardAiHandler, resetPrompt, @@ -227,24 +209,13 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const runValue = useMemo( () => ({ executeQuery, - executeExplainQuery, readEditorSql, isExecuting, - isExplainExecuting, potentialIssues, resetPotentialIssues, prettifyQuery, }), - [ - executeQuery, - executeExplainQuery, - readEditorSql, - isExecuting, - isExplainExecuting, - potentialIssues, - resetPotentialIssues, - prettifyQuery, - ] + [executeQuery, readEditorSql, isExecuting, potentialIssues, resetPotentialIssues, prettifyQuery] ) const uiValue = useMemo( diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index bc29180ca05ab..78084f48f5c2a 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -1,5 +1,4 @@ import { acceptUntrustedSql } from '@supabase/pg-meta' -import { useFlag } from 'common' import { Loader2 } from 'lucide-react' import dynamic from 'next/dynamic' import { useCallback } from 'react' @@ -125,23 +124,17 @@ const SQLEditorMainView = () => { const { diff, prompt } = useSqlEditorAssistant() const { isDiffOpen } = diff const { promptState, openPrompt } = prompt - const { executeQuery, executeExplainQuery, readEditorSql, prettifyQuery } = useSqlEditorRun() + const { executeQuery, readEditorSql, prettifyQuery } = useSqlEditorRun() const { onMount, setHasSelection } = useSqlEditorUi() const os = detectOS() - const showExplainAction = !useFlag('DisablePrettyExplainOnSqlEditor') - // Run/explain gestures from the editor — promote here, at the user action. + // Run gesture from the editor — promote here, at the user action. const runQuery = useCallback(() => { const sql = readEditorSql() if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql)) }, [executeQuery, readEditorSql]) - const runExplain = useCallback(() => { - const sql = readEditorSql() - if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql)) - }, [executeExplainQuery, readEditorSql]) - return (
{ editorRef={editorRef} monacoRef={monacoRef} executeQuery={runQuery} - executeExplainQuery={runExplain} - showExplainAction={showExplainAction} prettifyQuery={prettifyQuery} onHasSelection={setHasSelection} onMount={onMount} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx index 85bc92e32717f..7401f7c38745d 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx @@ -1,5 +1,5 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { LOCAL_STORAGE_KEYS } from 'common' import { Loader2 } from 'lucide-react' import { useCallback } from 'react' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' @@ -86,17 +86,9 @@ const SQLEditorToolbar = () => { const SQLEditorResultsPanel = () => { const { id, isLoading } = useSqlEditorSnippet() const { diff, ai } = useSqlEditorAssistant() - const { executeExplainQuery, readEditorSql, isExecuting, isExplainExecuting } = useSqlEditorRun() + const { isExecuting } = useSqlEditorRun() const { activeUtilityTab, setActiveUtilityTab } = useSqlEditorUi() - const showExplainTab = !useFlag('DisablePrettyExplainOnSqlEditor') - - // Explain gesture from the results panel — promote here, at the user action. - const runExplain = useCallback(() => { - const sql = readEditorSql() - if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql)) - }, [executeExplainQuery, readEditorSql]) - return isLoading ? (
@@ -105,10 +97,7 @@ const SQLEditorResultsPanel = () => { snapV2.removeFavorite(id) const onSelectDatabase = (databaseId: string) => { - sessionSnap.resetResults(id) + sessionSnap.resetResult(id) setLastSelectedDb(databaseId) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index 9b6e904a88f90..af507d38feee2 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -11,7 +11,6 @@ import { } from 'ui' import { ChartConfig } from './ChartConfig' -import { UtilityTabExplain } from './UtilityTabExplain' import { UtilityTabResults } from './UtilityTabResults' import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' @@ -23,11 +22,8 @@ import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state export type UtilityPanelProps = { id: string isExecuting?: boolean - isExplainExecuting?: boolean isDebugging?: boolean isDisabled?: boolean - executeExplainQuery: () => void - showExplainTab?: boolean onDebug: () => void buildDebugPrompt: () => string activeTab?: string @@ -46,11 +42,8 @@ const DEFAULT_CHART_CONFIG: ChartConfig = { export const UtilityPanel = ({ id, isExecuting, - isExplainExecuting, isDebugging, isDisabled, - executeExplainQuery, - showExplainTab = true, onDebug, buildDebugPrompt, activeTab = 'results', @@ -64,14 +57,6 @@ export const UtilityPanel = ({ const snippet = snapV2.snippets[id]?.snippet const result = sessionSnap.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: upsertContent } = useContentUpsertMutation({ invalidateQueriesOnSuccess: false, // Optimistic update to the cache @@ -131,17 +116,16 @@ export const UtilityPanel = ({ } return ( - +
Results - {showExplainTab && ( - - Explain - - )} Chart @@ -199,12 +183,6 @@ export const UtilityPanel = ({ /> - {showExplainTab && ( - - - - )} - diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx deleted file mode 100644 index f7d2e1c226727..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { useState } from 'react' -import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' - -import { Results } from './Results' -import { ExplainVisualizer } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer' -import { ExplainHeader } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header' -import { - isExplainQuery, - isTextFormatExplain, -} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' -import CopyButton from '@/components/ui/CopyButton' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' - -export type UtilityTabExplainProps = { - id: string - isExecuting?: boolean -} - -export function UtilityTabExplain({ id, isExecuting }: UtilityTabExplainProps) { - const sessionSnap = useSqlEditorSessionSnapshot() - const explainResult = sessionSnap.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) - const isTextFormat = isTextFormatExplain(explainResult.rows) - - if (!isValidExplain) { - return ( -
-

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

-
- ) - } - - // Handle non-TEXT formats (JSON, YAML, XML) - show raw output only - if (!isTextFormat) { - return ( -
-
- - Visual execution plan is only available for TEXT format. Showing raw output. - -
- -
- ) - } - - 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 37ac910923dfd..441af7bbca32e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -139,7 +139,7 @@ export const UtilityTabResults = forwardRef { state.setSelectedDatabaseId(ref) - sessionSnap.resetResults(id) + sessionSnap.resetResult(id) }} > Switch to primary database diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 00a49baf654f2..7b926512e0b63 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -1,11 +1,11 @@ import { type SafeSqlFragment } from '@supabase/pg-meta' import { useQueryClient } from '@tanstack/react-query' -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { useCallback, useState } from 'react' import { toast } from 'sonner' import { untitledSnippetTitle } from './SQLEditor.constants' -import type { PotentialIssues, UtilityTab } from './SQLEditor.types' +import type { PotentialIssues } from './SQLEditor.types' import { checkAlterDatabaseConnection, checkDestructiveQuery, @@ -17,7 +17,6 @@ import { suffixWithLimit, } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' -import { isExplainQuery } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query' import { isValidConnString } from '@/data/fetchers' import { lintKeys } from '@/data/lint/keys' @@ -39,8 +38,6 @@ type UseSqlEditorExecutionArgs = { id: string isDiffOpen: boolean hasSelection: boolean - activeUtilityTab: UtilityTab - setActiveUtilityTab: (tab: UtilityTab) => void setAiTitle: (id: string, sql: string) => void } @@ -48,8 +45,6 @@ export function useSqlEditorExecution({ id, isDiffOpen, hasSelection, - activeUtilityTab, - setActiveUtilityTab, setAiTitle, }: UseSqlEditorExecutionArgs) { const { ref } = useParams() @@ -69,7 +64,6 @@ export function useSqlEditorExecution({ const databaseSelectorState = useDatabaseSelectorStateSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() const { aiOptInLevel } = useOrgAiOptInLevel() - const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor') const { data: databases } = useReadReplicasQuery( { projectRef: ref }, @@ -86,14 +80,6 @@ export function useSqlEditorExecution({ onSuccess(data, vars) { if (id) { sessionSnap.addResult(id, data.result, vars.autoLimit) - - if (!disablePrettyExplain && isExplainQuery(data.result)) { - sessionSnap.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 diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts deleted file mode 100644 index 32c4cfe8f00a0..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { type SafeSqlFragment } from '@supabase/pg-meta' -import { useParams } from 'common' -import { useCallback } from 'react' -import { toast } from 'sonner' - -import type { UtilityTab } from './SQLEditor.types' -import { buildExplainSql } from './SQLEditor.utils' -import { useSQLEditorContext } from './SQLEditorContext' -import { splitSqlStatements } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' -import { isValidConnString } from '@/data/fetchers' -import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' -import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' -import { - isRoleImpersonationEnabled, - useGetImpersonatedRoleState, -} from '@/state/role-impersonation-state' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' - -type UseSqlEditorExplainArgs = { - id: string - isDiffOpen: boolean - setActiveUtilityTab: (tab: UtilityTab) => void -} - -export function useSqlEditorExplain({ - id, - isDiffOpen, - setActiveUtilityTab, -}: UseSqlEditorExplainArgs) { - const { ref } = useParams() - const { editorRef, clearHighlights } = useSQLEditorContext() - const { data: project } = useSelectedProjectQuery() - const sessionSnap = useSqlEditorSessionSnapshot() - const databaseSelectorState = useDatabaseSelectorStateSnapshot() - const getImpersonatedRoleState = useGetImpersonatedRoleState() - - const { data: databases } = useReadReplicasQuery( - { projectRef: ref }, - { enabled: isValidConnString(project?.connectionString) } - ) - - const { mutate: executeExplain, isPending: isExplainExecuting } = useExecuteSqlMutation({ - onSuccess(data) { - if (id) { - sessionSnap.addExplainResult(id, data.result) - setActiveUtilityTab('explain') - } - }, - onError(error) { - if (id) { - sessionSnap.addExplainResultError(id, error) - setActiveUtilityTab('explain') - } - }, - }) - - const executeExplainQuery = useCallback( - async (sql: SafeSqlFragment) => { - if (isDiffOpen) return - - if (editorRef.current !== null && !isExplainExecuting && project !== undefined) { - // Check for multiple statements - EXPLAIN only works on a single statement - const statements = splitSqlStatements(sql) - if (statements.length > 1) { - sessionSnap.addExplainResultError(id, { - message: - 'EXPLAIN only works on a single SQL statement. Please select just one query to analyze.', - }) - setActiveUtilityTab('explain') - return - } - - clearHighlights() - - 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 in EXPLAIN ANALYZE (unless already an EXPLAIN), apply role - // impersonation, and wrap in a rollback transaction so EXPLAIN ANALYZE - // INSERT/UPDATE/DELETE queries don't actually modify data. - const explainSqlWithTransaction = buildExplainSql(sql, impersonatedRoleState) - - executeExplain({ - projectRef: project.ref, - connectionString: connectionString, - sql: explainSqlWithTransaction, - isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), - handleError: (error) => { - throw error - }, - }) - } - }, - [ - editorRef, - isDiffOpen, - id, - isExplainExecuting, - project, - executeExplain, - getImpersonatedRoleState, - databaseSelectorState.selectedDatabaseId, - databases, - clearHighlights, - sessionSnap, - setActiveUtilityTab, - ] - ) - - return { executeExplainQuery, isExplainExecuting } -} diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index 19204b6b1c4d0..d5c95518c8b27 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -1,8 +1,3 @@ -import { - acceptUntrustedSql, - type SafeSqlFragment, - type UntrustedSqlFragment, -} from '@supabase/pg-meta' import { useParams } from 'common' import { useRouter } from 'next/router' import { useCallback, useEffect } from 'react' @@ -15,10 +10,7 @@ import { useShortcut } from '@/state/shortcuts/useShortcut' type UseSqlEditorShortcutsArgs = { isDiffOpen: boolean isPromptOpen: boolean - disablePrettyExplain: boolean prettifyQuery: () => void - readEditorSql: () => UntrustedSqlFragment | undefined - executeExplainQuery: (sql: SafeSqlFragment) => unknown acceptAiHandler: () => void discardAiHandler: () => void resetPrompt: () => void @@ -26,16 +18,13 @@ type UseSqlEditorShortcutsArgs = { /** * Registers the SQL editor's keyboard shortcuts (focus editor, new snippet, - * format, explain) plus the window keydown that accepts/discards an open AI diff + * format) plus the window keydown that accepts/discards an open AI diff * or dismisses the prompt. */ export function useSqlEditorShortcuts({ isDiffOpen, isPromptOpen, - disablePrettyExplain, prettifyQuery, - readEditorSql, - executeExplainQuery, acceptAiHandler, discardAiHandler, resetPrompt, @@ -53,15 +42,6 @@ export function useSqlEditorShortcuts({ router.push(`/project/${ref}/sql/new?skip=true`) }, [ref, router]) - // The Explain keyboard shortcut is an explicit user action, so the - // untrusted→safe promotion (acceptUntrustedSql) happens here in the shortcut - // handler itself — right next to where it is registered — before the SQL - // reaches the explain pipeline. - const runExplainShortcut = useCallback(() => { - const sql = readEditorSql() - if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql)) - }, [executeExplainQuery, readEditorSql]) - useShortcut(SHORTCUT_IDS.SQL_EDITOR_FOCUS_EDITOR, refocusEditor, { registerInCommandMenu: true, }) @@ -74,11 +54,6 @@ export function useSqlEditorShortcuts({ registerInCommandMenu: true, }) - useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, runExplainShortcut, { - enabled: !disablePrettyExplain, - registerInCommandMenu: true, - }) - useEffect(() => { const handler = (e: KeyboardEvent) => { if (!isDiffOpen && !isPromptOpen) return diff --git a/apps/studio/state/shortcuts/registry/sql-editor.ts b/apps/studio/state/shortcuts/registry/sql-editor.ts index c2d91122ed91e..062a10ddfcb00 100644 --- a/apps/studio/state/shortcuts/registry/sql-editor.ts +++ b/apps/studio/state/shortcuts/registry/sql-editor.ts @@ -4,7 +4,6 @@ export const SQL_EDITOR_SHORTCUT_IDS = { SQL_EDITOR_BLUR_EDITOR: 'sql-editor.blur-editor', SQL_EDITOR_FOCUS_EDITOR: 'sql-editor.focus-editor', SQL_EDITOR_FORMAT: 'sql-editor.format', - SQL_EDITOR_EXPLAIN: 'sql-editor.explain', SQL_EDITOR_NEW_SNIPPET: 'sql-editor.new-snippet', } @@ -30,12 +29,6 @@ export const sqlEditorRegistry: RegistryDefinations = { sequence: ['Alt+Shift+F'], showInSettings: false, }, - [SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_EXPLAIN]: { - id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, - label: 'Run EXPLAIN ANALYZE', - sequence: ['Mod+Shift+Enter'], - showInSettings: false, - }, [SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET]: { id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET, label: 'New SQL snippet', diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts index 46768b2a97902..deeea009d6754 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts @@ -7,7 +7,6 @@ import { sqlEditorSessionState } from './sql-editor-session-state' // each test to keep cases independent. beforeEach(() => { sqlEditorSessionState.results = {} - sqlEditorSessionState.explainResults = {} sqlEditorSessionState.limit = 100 }) @@ -39,45 +38,14 @@ describe('sqlEditorSessionState', () => { }) }) - describe('explain results', () => { - it('addExplainResult stores rows keyed by snippet id', () => { - const rows = [{ 'QUERY PLAN': 'Seq Scan' }] - sqlEditorSessionState.addExplainResult('a', rows) - - expect(sqlEditorSessionState.explainResults['a']?.rows).toEqual(rows) - expect(sqlEditorSessionState.explainResults['a']?.error).toBeUndefined() - }) - - it('addExplainResultError stores the error with empty rows', () => { - sqlEditorSessionState.addExplainResultError('a', { message: 'bad plan' }) - - expect(sqlEditorSessionState.explainResults['a']?.rows).toEqual([]) - expect(sqlEditorSessionState.explainResults['a']?.error).toEqual({ message: 'bad plan' }) - }) - }) - - describe('resetResults', () => { - it('clears both the query result and the explain result for a snippet', () => { - sqlEditorSessionState.addResult('a', [{ x: 1 }]) - sqlEditorSessionState.addExplainResult('a', [{ 'QUERY PLAN': 'Seq Scan' }]) - - sqlEditorSessionState.resetResults('a') - - expect(sqlEditorSessionState.results['a']).toEqual([]) - expect(sqlEditorSessionState.explainResults['a']).toEqual({ rows: [] }) - }) - }) - describe('clearForSnippet', () => { - it('deletes both results and explain results for a snippet, leaving others intact', () => { + it('deletes results for a snippet, leaving others intact', () => { sqlEditorSessionState.addResult('a', [{ x: 1 }]) - sqlEditorSessionState.addExplainResult('a', [{ 'QUERY PLAN': 'Seq Scan' }]) sqlEditorSessionState.addResult('b', [{ y: 2 }]) sqlEditorSessionState.clearForSnippet('a') expect(sqlEditorSessionState.results['a']).toBeUndefined() - expect(sqlEditorSessionState.explainResults['a']).toBeUndefined() expect(sqlEditorSessionState.results['b']?.[0]?.rows).toEqual([{ y: 2 }]) }) }) diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 8cb9618c4fa53..54e82f46c1d99 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -1,12 +1,10 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' -import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types' - /** - * Ephemeral, per-session SQL editor state that is NOT persisted: query results, - * EXPLAIN results, and the row limit. Kept separate from the snippet/folder - * store (which deals with persistence) because none of this is saved — it lives - * only for the current editing session. + * Ephemeral, per-session SQL editor state that is NOT persisted: query results + * and the row limit. Kept separate from the snippet/folder store (which deals + * with persistence) because none of this is saved — it lives only for the + * current editing session. */ export const sqlEditorSessionState = proxy({ /** @@ -22,14 +20,6 @@ export const sqlEditorSessionState = proxy({ }[] }, - /** EXPLAIN results, if any, keyed by snippet id. */ - explainResults: {} as { - [snippetId: string]: { - rows: QueryPlanRow[] - error?: { message: string; formattedError?: string } - } - }, - /** * UI-imposed limit for the number of rows a query can return (a safeguard * against accidentally taking down the database with a huge SELECT). Related @@ -56,28 +46,9 @@ export const sqlEditorSessionState = proxy({ sqlEditorSessionState.results[id] = [] }, - addExplainResult: (id: string, results: QueryPlanRow[]) => { - // Use ref() to prevent Valtio from creating proxies for each row object - sqlEditorSessionState.explainResults[id] = { rows: ref(results) } - }, - - addExplainResultError: (id: string, error: { message: string; formattedError?: string }) => { - sqlEditorSessionState.explainResults[id] = { rows: ref([]), error } - }, - - resetExplainResult: (id: string) => { - sqlEditorSessionState.explainResults[id] = { rows: [] } - }, - - resetResults: (id: string) => { - sqlEditorSessionState.resetResult(id) - sqlEditorSessionState.resetExplainResult(id) - }, - /** Drop all session state for a snippet (called when the snippet is removed). */ clearForSnippet: (id: string) => { delete sqlEditorSessionState.results[id] - delete sqlEditorSessionState.explainResults[id] }, }) diff --git a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx index bb7b7ae1bac78..9d6718f24ca9d 100644 --- a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx +++ b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx @@ -95,9 +95,6 @@ vi.mock('@/components/interfaces/SQLEditor/MonacoEditor', async () => { - -
), })) @@ -281,7 +275,6 @@ function resetStores() { } const NON_EXPLAIN_ROWS = [{ id: 1, name: 'row-1' }] -const EXPLAIN_ROWS = [{ 'QUERY PLAN': 'Seq Scan on foo (cost=0.00..1.00 rows=1 width=4)' }] function mockQuerySuccess(rows: unknown[] = NON_EXPLAIN_ROWS) { addAPIMock({ @@ -346,32 +339,6 @@ describe('SQLEditor characterization', () => { expect(screen.getByTestId('active-tab')).toHaveTextContent('results') }) - test('1b. an EXPLAIN-shaped result auto-switches to the explain tab', async () => { - const addExplainResult = vi.spyOn(sqlEditorSessionState, 'addExplainResult') - mockQuerySuccess(EXPLAIN_ROWS) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(addExplainResult).toHaveBeenCalledWith(SNIPPET_ID, EXPLAIN_ROWS)) - await waitFor(() => expect(screen.getByTestId('active-tab')).toHaveTextContent('explain')) - }) - - test('1c. running a non-EXPLAIN query while on the explain tab switches back to results', async () => { - await renderEditor() - - // First run an EXPLAIN-shaped query to land on the explain tab. - mockQuerySuccess(EXPLAIN_ROWS) - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => expect(screen.getByTestId('active-tab')).toHaveTextContent('explain')) - - // Now a normal query should flip back to results. - mockQuerySuccess(NON_EXPLAIN_ROWS) - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => expect(screen.getByTestId('active-tab')).toHaveTextContent('results')) - }) - test('2. run error with position highlights the computed line and reveals it', async () => { const addResultError = vi.spyOn(sqlEditorSessionState, 'addResultError') mockQueryError({ diff --git a/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts b/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts index 7914b8dddefdf..c53f6bc2df04a 100644 --- a/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts +++ b/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.utils.test.ts @@ -3,9 +3,6 @@ import { describe, expect, test } from 'vitest' import { formatNodeDuration, isExplainQuery, - isExplainSql, - isTextFormatExplain, - splitSqlStatements, } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' describe('isExplainQuery', () => { @@ -30,47 +27,6 @@ describe('isExplainQuery', () => { }) }) -describe('isTextFormatExplain', () => { - test('returns true for TEXT format EXPLAIN result rows', () => { - const rows = [ - { 'QUERY PLAN': 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36)' }, - { 'QUERY PLAN': ' Filter: (active = true)' }, - ] - expect(isTextFormatExplain(rows)).toBe(true) - }) - - test('returns false for JSON format EXPLAIN result rows', () => { - const rows = [ - { 'QUERY PLAN': [{ Plan: { 'Node Type': 'Seq Scan', 'Relation Name': 'users' } }] }, - ] - expect(isTextFormatExplain(rows)).toBe(false) - }) - - test('returns true for YAML format EXPLAIN result rows (returned as string)', () => { - const rows = [ - { - 'QUERY PLAN': - '- Plan: \n Node Type: "Seq Scan"\n Parallel Aware: false\n Async Capable: false\n Relation Name: "orders"\n Alias: "orders"\n Startup Cost: 0.00\n Total Cost: 97.00\n Plan Rows: 5000\n Plan Width: 41', - }, - ] - - expect(isTextFormatExplain(rows)).toBe(true) - }) -}) - -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('-') @@ -88,165 +44,3 @@ describe('formatNodeDuration', () => { 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') - }) -}) From ae957414b46527249c22bd9b81f0338bd0956a05 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 15 Jul 2026 21:29:00 -0600 Subject: [PATCH 2/2] fix(studio): make unified logs sidebar banner dismissible (#47977) ## Summary - Adds a close button to the "Introducing unified logs" sidebar banner, storing the dismissal in localStorage so it stays hidden. ## Test plan - [ ] Open Logs Explorer, confirm the X dismisses the banner and it stays gone after reload. ## Summary by CodeRabbit * **New Features** * Added an `X` close button to the unified logs preview banner. * Remember banner dismissal using local storage, so it stays hidden after closing. * Updated banner visibility rules to account for unified-logs preview enablement and default opt-in state. --------- Co-authored-by: Joshen Lim --- .../UnifiedLogs/UnifiedLogsBanner.tsx | 22 ++++++++++++++++--- packages/common/constants/local-storage.ts | 1 + 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx index 43d1c936c5ae5..45e7e90dbd0ab 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx @@ -1,5 +1,5 @@ -import { useParams } from 'common' -import { CircleHelpIcon, Undo2 } from 'lucide-react' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { CircleHelpIcon, Undo2, X } from 'lucide-react' import { useRouter } from 'next/router' import { Badge, Button, cn } from 'ui' @@ -8,6 +8,7 @@ import { useUnifiedLogsPreview, } from '../App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' interface UnifiedLogsBannerProps { @@ -20,9 +21,16 @@ export function UnifiedLogsBanner({ className = 'mx-4 mt-4' }: UnifiedLogsBanner const { selectFeaturePreview } = useFeaturePreviewModal() const { enable, disable, isDefaultOptIn, isEnabled } = useUnifiedLogsPreview() + const [isDismissed, setIsDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.UNIFIED_LOGS_SIDEBAR_BANNER_DISMISSED, + false + ) if (!IS_PLATFORM) return null + // Keep the "Go back" banner visible even after dismissal so manually opted-in users can switch back + if (isDismissed && !(isEnabled && !isDefaultOptIn)) return null + const cardClassName = cn( 'rounded-lg border p-4 space-y-3 text-left', 'bg-muted/10 border-border/50', @@ -60,8 +68,16 @@ export function UnifiedLogsBanner({ className = 'mx-4 mt-4' }: UnifiedLogsBanner return (
-
+
New +

Introducing unified logs

diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 04168861a23a5..6b74bf66803a6 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -114,6 +114,7 @@ export const LOCAL_STORAGE_KEYS = { FREE_MICRO_UPGRADE_BANNER_DISMISSED: (ref: string) => `free-micro-upgrade-banner-dismissed-${ref}`, UNIFIED_LOGS_BANNER_DISMISSED: 'unified-logs-banner-dismissed', + UNIFIED_LOGS_SIDEBAR_BANNER_DISMISSED: 'unified-logs-sidebar-banner-dismissed', STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED: (ref: string, bucketId: string) => `storage-public-bucket-select-policy-warning-dismissed-${ref}-${bucketId}`,