Files
supabase/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts
Charis 4c8ed105d2 feat(studio): logs SQL execution wiring + source-aware run gestures (#48414)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature (SQL editor: execution wiring for logs-source snippets). Part of
the stacked SQL-editor "Database vs Logs" query-source series.

## What is the current behavior?

The SQL editor only ever runs queries against the user's Postgres
database. There is no execution path for a logs (`log_sql`) snippet, and
the run-button telemetry event carries no backend discriminator.

## What is the new behavior?

- `useRunSource(id)` derives the run backend from the snippet type; a
`log_sql` snippet resolves to `{ type: 'logs', dateRange }`, pairing the
run with its session time range (default: last hour).
- `useLogsSqlExecution` runs a promoted `SafeLogSqlFragment` against the
analytics OTEL (ClickHouse) endpoint with the resolved time range as
`iso_timestamp_start`/`iso_timestamp_end` request params. The endpoint
is **pinned to OTEL** — a snippet's dialect must not flip with org
migration.
- The run gestures (toolbar button and Cmd+Enter) branch on the source
and promote with the matching `acceptUntrusted*` right at the user
action, preserving the auditable promotion-at-gesture boundary. pg
intellisense is gated off for logs snippets.
- The `sql_editor_query_run_button_clicked` telemetry event gains a
required `{ source: 'database' | 'logs' }` property, fired from both
execution paths.
- Capability guard: a `log_sql` snippet is reachable by direct URL
regardless of the (later) entry-point flag gating, so `executeLogsQuery`
short-circuits when `otelLegacyLogs` is off — recording a clear "not
available yet" result message instead of firing a request that would
only return an opaque backend error on a non-ClickHouse project. This is
a guard on the gesture, not endpoint selection.
- Tests: `useRunSource` routing, `useLogsSqlExecution`
endpoint/range/structured-error/capability-guard, and a reusable `flags`
option on `renderSqlEditorHook`.

No UI entry points are added — the feature runs dark until the
flag-gated creation/nav PRs later in the stack.

## Additional context

Stacked on the query-source series; base branch is `master` now that PR
4 (log date range domain + session state, #48401) is merged. Follow-ups
in the stack add the toolbar/creation UI (with a run-affordance gate on
`otelLegacyLogs`), nav section, AI dialect support, and reports guard.

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

## Summary by CodeRabbit

* **New Features**
  * Added support for running log queries directly from the SQL editor.
* Log query results, errors, and time ranges are now handled within the
editor session.
* Added automatic selection between database and log query execution,
including support for custom date ranges.
* SQL assistance is disabled while editing log queries where database
definitions do not apply.

* **Tests**
* Added coverage for log query execution, date ranges, feature
availability, and execution source selection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 10:43:48 -04:00

167 lines
5.1 KiB
TypeScript

import { type SafeSqlFragment } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { IS_PLATFORM, useParams } from 'common'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import type { PotentialIssues } from './SQLEditor.types'
import {
analyzeQueryIssues,
buildExecuteParams,
hasBlockingIssues,
resolveConnectionString,
shouldAutoGenerateTitle,
} from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
import { isValidConnString } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
import { getSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
type UseSqlEditorExecutionArgs = {
id: string
isDiffOpen: boolean
hasSelection: boolean
setAiTitle: (id: string, sql: string) => void
}
export function useSqlEditorExecution({
id,
isDiffOpen,
hasSelection,
setAiTitle,
}: UseSqlEditorExecutionArgs) {
const { ref } = useParams()
const { editor, clearPendingRunRefocus, refocusEditorAfterRunIfNeeded } = useSQLEditorContext()
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const track = useTrack()
const sessionSnap = useSqlEditorSessionSnapshot()
const limit = sessionSnap.limit
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const { aiOptInLevel } = useOrgAiOptInLevel()
const { data: databases } = useReadReplicasQuery(
{ projectRef: ref },
{ enabled: isValidConnString(project?.connectionString) }
)
const { data: eventTriggers } = useDatabaseEventTriggersQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ enabled: isValidConnString(project?.connectionString) }
)
const [potentialIssues, setPotentialIssues] = useState<PotentialIssues>()
const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({
onSuccess(data, vars) {
if (id) {
sessionSnap.addResult(id, data.result, vars.autoLimit)
}
// revalidate lint query
queryClient.invalidateQueries({ queryKey: lintKeys.lint(ref) })
refocusEditorAfterRunIfNeeded()
},
onError(error: any, vars) {
if (id) {
editor.highlightErrorLine(error, hasSelection)
sessionSnap.addResultError(id, error, vars.autoLimit)
}
refocusEditorAfterRunIfNeeded()
},
})
const executeQuery = useCallback(
async (sql: SafeSqlFragment, force: boolean = false) => {
if (isDiffOpen) {
clearPendingRunRefocus()
return
}
if (!editor.isReady() || isExecuting || project === undefined) {
clearPendingRunRefocus()
return
}
const issues = analyzeQueryIssues(sql, eventTriggers)
if (hasBlockingIssues(issues, force)) {
setPotentialIssues(issues)
return
}
// use the latest state for the title-generation check
const snippet = getSqlEditorV2StateSnapshot().snippets[id]
if (
shouldAutoGenerateTitle({
aiOptInLevel,
snippetName: snippet?.snippet.name,
isPlatform: IS_PLATFORM,
})
) {
// Intentionally don't await title gen (lazy)
setAiTitle(id, sql)
}
editor.clearHighlights()
const impersonatedRoleState = getImpersonatedRoleState()
const connectionString = resolveConnectionString(
databases,
databaseSelectorState.selectedDatabaseId
)
if (!isValidConnString(connectionString)) {
clearPendingRunRefocus()
return toast.error('Unable to run query: Connection string is missing')
}
execute({
...buildExecuteParams({
sql,
limit,
connectionString,
projectRef: project.ref,
impersonatedRoleState,
}),
handleError: (error) => {
throw error
},
})
track('sql_editor_query_run_button_clicked', { source: 'database' })
},
[
editor,
clearPendingRunRefocus,
isDiffOpen,
id,
isExecuting,
project,
aiOptInLevel,
execute,
getImpersonatedRoleState,
setAiTitle,
databaseSelectorState.selectedDatabaseId,
databases,
eventTriggers,
limit,
track,
]
)
const resetPotentialIssues = useCallback(() => setPotentialIssues(undefined), [])
return { executeQuery, isExecuting, potentialIssues, resetPotentialIssues }
}