Files
supabase/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts
T
Charis 3fa7f086df refactor(sql-editor): extract execution + explain hooks (decompose 4/6) (#47923)
## Summary

PR **4 of 6** in the SQLEditor decomposition stack. Extracts the two
query-run concerns out of the composition root into cohesive hooks.
Behavior-preserving — the characterization suite stays green.

## New hooks

- `useSqlEditorExecution` — the execute mutation, the `executeQuery`
pipeline (destructive-query gating, lazy title generation, connection
resolution), and the `potentialIssues` warning-modal state.
- `useSqlEditorExplain` — the execute-explain mutation and the
`executeExplainQuery` pipeline.

Both pipelines accept an **already-promoted `SafeSqlFragment`**.

## Safe-SQL boundary

Per review guidance, `acceptUntrustedSql` promotion stays in the
**composition root's** run/explain gesture handlers and warning-modal
confirm handlers — as close to the explicit user action as possible, so
it's auditable. The hooks never call `acceptUntrustedSql`; they only
accept safe SQL. `executeQuery` lists its (now all-stable) dependencies
directly rather than suppressing `react-hooks/exhaustive-deps`.

## Verification

- `vitest` — 11 characterization tests pass
- `pnpm --filter studio typecheck` — clean for SQL editor files
- `eslint` / `lint:ratchet` — pass (0 new violations)
- `prettier --check` — clean

`SQLEditorContent` is down to ~530 lines (from 1056 at the start of the
stack). Remaining: PR5 (AI/diff + shortcuts), PR6 (JSX pane split +
cleanup).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved SQL query execution safeguards for potentially destructive
statements, unsafe updates, and missing row-level security coverage.
* Improved EXPLAIN handling, including clearer validation for
unsupported multi-statement queries.
* Preserved query results, errors, and EXPLAIN output more reliably in
the SQL editor.

* **Improvements**
* Added clearer execution status handling and more consistent editor
focus, highlighting, and utility-tab behavior.
* Improved connection selection and SQL execution reliability across
supported database configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 10:37:22 -04:00

118 lines
3.9 KiB
TypeScript

import { type SafeSqlFragment } from '@supabase/pg-meta'
import { useParams } from 'common'
import { useCallback } from 'react'
import { toast } from 'sonner'
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: string) => 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 }
}