mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 -->
This commit is contained in:
1 parent
67c983caef
commit
4c8ed105d2
15 files changed
+425
-36
No files matched your search
@@ -27,7 +27,7 @@ export type EditorController = {
|
||||
isReady: () => boolean
|
||||
getValue: () => string | undefined
|
||||
getSelectionStartLine: () => number | undefined
|
||||
getSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined
|
||||
getSql: (snippetContent?: string) => UntrustedSqlFragment | undefined
|
||||
replaceAll: (text: string, source: string) => void
|
||||
focus: () => void
|
||||
revealLineInCenter: (line: number) => void
|
||||
|
||||
@@ -457,7 +457,7 @@ export function applyAutoLimit(
|
||||
*/
|
||||
export function getEditorSql(
|
||||
editor: IStandaloneCodeEditor,
|
||||
snippetContent?: UntrustedSqlFragment
|
||||
snippetContent?: string
|
||||
): UntrustedSqlFragment {
|
||||
const selection = editor.getSelection()
|
||||
const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Monaco } from '@monaco-editor/react'
|
||||
import type { UntrustedSqlFragment } from '@supabase/pg-meta'
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
@@ -103,7 +102,7 @@ export const SQLEditorProvider = ({
|
||||
[]
|
||||
)
|
||||
|
||||
const getSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => {
|
||||
const getSqlFromEditor = useCallback((snippetContent?: string) => {
|
||||
const editorInstance = editorRef.current
|
||||
if (!editorInstance) return undefined
|
||||
return getEditorSql(editorInstance, snippetContent)
|
||||
|
||||
@@ -15,17 +15,25 @@ import {
|
||||
} from 'react'
|
||||
|
||||
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
|
||||
import { type QuerySource } from './querySource'
|
||||
import type { UtilityTab } from './SQLEditor.types'
|
||||
import { useSQLEditorContext } from './SQLEditorContext'
|
||||
import { useAddDefinitions } from './useAddDefinitions'
|
||||
import { useEditorMount } from './useEditorMount'
|
||||
import { useLogsSqlExecution } from './useLogsSqlExecution'
|
||||
import { usePrettifyQuery } from './usePrettifyQuery'
|
||||
import { useRunSource } from './useRunSource'
|
||||
import { useSnippetIdentity } from './useSnippetIdentity'
|
||||
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
|
||||
import { useSqlEditorAi } from './useSqlEditorAi'
|
||||
import { useSqlEditorExecution } from './useSqlEditorExecution'
|
||||
import { useSqlEditorShortcuts } from './useSqlEditorShortcuts'
|
||||
import { isValidConnString } from '@/data/fetchers'
|
||||
import {
|
||||
untrustedLogSql,
|
||||
type SafeLogSqlFragment,
|
||||
type UntrustedLogSqlFragment,
|
||||
} from '@/data/logs/safe-analytics-sql'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
||||
@@ -73,6 +81,9 @@ type RunContextValue = {
|
||||
potentialIssues: SqlEditorExecution['potentialIssues']
|
||||
resetPotentialIssues: () => void
|
||||
prettifyQuery: () => void
|
||||
runSource: QuerySource
|
||||
executeLogsQuery: (sql: SafeLogSqlFragment) => void
|
||||
readEditorLogsSql: () => UntrustedLogSqlFragment | undefined
|
||||
}
|
||||
|
||||
/** Editor-surface UI state: selection, the active results tab, and mount. */
|
||||
@@ -131,7 +142,9 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) =>
|
||||
const { id, urlId, generatedNewSnippetName, isLoading } = useSnippetIdentity()
|
||||
const { onMount, editorMountCount } = useEditorMount({ id })
|
||||
|
||||
useAddDefinitions(id, monacoRef.current)
|
||||
const runSource = useRunSource(id)
|
||||
|
||||
useAddDefinitions(id, monacoRef.current, { enabled: runSource.type !== 'logs' })
|
||||
|
||||
const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery(
|
||||
{
|
||||
@@ -152,13 +165,31 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) =>
|
||||
return editor.getSql(fallback)
|
||||
}, [editor, id])
|
||||
|
||||
const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } =
|
||||
useSqlEditorExecution({
|
||||
id,
|
||||
isDiffOpen,
|
||||
hasSelection,
|
||||
setAiTitle,
|
||||
})
|
||||
// Reads the SQL to run from the editor as an UntrustedLogSqlFragment — the logs
|
||||
// sibling of readEditorSql. Promotion (acceptUntrustedLogsSql) happens at each
|
||||
// user-action site, never here.
|
||||
const readEditorLogsSql = useCallback((): UntrustedLogSqlFragment | undefined => {
|
||||
const snippet = getSqlEditorV2StateSnapshot().snippets[id]?.snippet
|
||||
const fallback = snippet?.type === 'log_sql' ? snippet.content?.unchecked_sql : undefined
|
||||
const sql = editor.getSql(fallback)
|
||||
return sql === undefined ? undefined : untrustedLogSql(sql)
|
||||
}, [editor, id])
|
||||
|
||||
const {
|
||||
executeQuery,
|
||||
isExecuting: isExecutingDb,
|
||||
potentialIssues,
|
||||
resetPotentialIssues,
|
||||
} = useSqlEditorExecution({
|
||||
id,
|
||||
isDiffOpen,
|
||||
hasSelection,
|
||||
setAiTitle,
|
||||
})
|
||||
|
||||
const { executeLogsQuery, isExecuting: isExecutingLogs } = useLogsSqlExecution({ id })
|
||||
|
||||
const isExecuting = isExecutingDb || isExecutingLogs
|
||||
|
||||
const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt })
|
||||
const { acceptAiHandler, discardAiHandler } = ai
|
||||
@@ -215,8 +246,21 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) =>
|
||||
potentialIssues,
|
||||
resetPotentialIssues,
|
||||
prettifyQuery,
|
||||
runSource,
|
||||
executeLogsQuery,
|
||||
readEditorLogsSql,
|
||||
}),
|
||||
[executeQuery, readEditorSql, isExecuting, potentialIssues, resetPotentialIssues, prettifyQuery]
|
||||
[
|
||||
executeQuery,
|
||||
readEditorSql,
|
||||
isExecuting,
|
||||
potentialIssues,
|
||||
resetPotentialIssues,
|
||||
prettifyQuery,
|
||||
runSource,
|
||||
executeLogsQuery,
|
||||
readEditorLogsSql,
|
||||
]
|
||||
)
|
||||
|
||||
const uiValue = useMemo<UiContextValue>(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useSqlEditorUi,
|
||||
} from './SQLEditorControllers'
|
||||
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
|
||||
import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql'
|
||||
import { detectOS } from '@/lib/helpers'
|
||||
|
||||
// Load the monaco editor client-side only (does not behave well server-side)
|
||||
@@ -124,16 +125,28 @@ const SQLEditorMainView = () => {
|
||||
const { diff, prompt } = useSqlEditorAssistant()
|
||||
const { isDiffOpen } = diff
|
||||
const { promptState, openPrompt } = prompt
|
||||
const { executeQuery, readEditorSql, prettifyQuery } = useSqlEditorRun()
|
||||
const {
|
||||
executeQuery,
|
||||
readEditorSql,
|
||||
prettifyQuery,
|
||||
runSource,
|
||||
executeLogsQuery,
|
||||
readEditorLogsSql,
|
||||
} = useSqlEditorRun()
|
||||
const { onMount, setHasSelection } = useSqlEditorUi()
|
||||
|
||||
const os = detectOS()
|
||||
|
||||
// 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])
|
||||
if (runSource.type === 'logs') {
|
||||
const sql = readEditorLogsSql()
|
||||
if (sql !== undefined) void executeLogsQuery(acceptUntrustedLogsSql(sql))
|
||||
} else {
|
||||
const sql = readEditorSql()
|
||||
if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql))
|
||||
}
|
||||
}, [executeLogsQuery, executeQuery, readEditorLogsSql, readEditorSql, runSource.type])
|
||||
|
||||
return (
|
||||
<div key={id} className="w-full h-full relative">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { SQLEditorEditorPanel } from './SQLEditorEditorPanel'
|
||||
import { UtilityActions } from './UtilityPanel/UtilityActions'
|
||||
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
|
||||
import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql'
|
||||
|
||||
const SQLEditorRunWarningModal = () => {
|
||||
const { refocusEditor, clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext()
|
||||
@@ -58,16 +59,38 @@ const SQLEditorToolbar = () => {
|
||||
const { clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext()
|
||||
const { id } = useSqlEditorSnippet()
|
||||
const { diff } = useSqlEditorAssistant()
|
||||
const { executeQuery, readEditorSql, isExecuting, prettifyQuery } = useSqlEditorRun()
|
||||
const {
|
||||
executeQuery,
|
||||
readEditorSql,
|
||||
isExecuting,
|
||||
prettifyQuery,
|
||||
runSource,
|
||||
executeLogsQuery,
|
||||
readEditorLogsSql,
|
||||
} = useSqlEditorRun()
|
||||
const { hasSelection } = useSqlEditorUi()
|
||||
|
||||
// Run gesture from the toolbar button — promote here, at the user action.
|
||||
const runQuery = useCallback(() => {
|
||||
markRefocusAfterRun()
|
||||
const sql = readEditorSql()
|
||||
if (sql === undefined) return clearPendingRunRefocus()
|
||||
void executeQuery(acceptUntrustedSql(sql))
|
||||
}, [clearPendingRunRefocus, executeQuery, markRefocusAfterRun, readEditorSql])
|
||||
if (runSource.type === 'logs') {
|
||||
const sql = readEditorLogsSql()
|
||||
if (sql === undefined) return clearPendingRunRefocus()
|
||||
void executeLogsQuery(acceptUntrustedLogsSql(sql))
|
||||
} else {
|
||||
const sql = readEditorSql()
|
||||
if (sql === undefined) return clearPendingRunRefocus()
|
||||
void executeQuery(acceptUntrustedSql(sql))
|
||||
}
|
||||
}, [
|
||||
clearPendingRunRefocus,
|
||||
executeLogsQuery,
|
||||
executeQuery,
|
||||
markRefocusAfterRun,
|
||||
readEditorLogsSql,
|
||||
readEditorSql,
|
||||
runSource.type,
|
||||
])
|
||||
|
||||
return (
|
||||
<UtilityActions
|
||||
|
||||
@@ -68,6 +68,14 @@ export const DEFAULT_LOG_DATE_RANGE: LogDateRange = {
|
||||
last: { amount: 1, unit: 'hour' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime query source for a snippet, pairing the database/logs discriminant
|
||||
* with the extra state each backend needs to run. A logs run carries the active
|
||||
* time range (session state, re-resolved at every run); a database run needs
|
||||
* nothing beyond the connection the execution pipeline already resolves.
|
||||
*/
|
||||
export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogDateRange }
|
||||
|
||||
/**
|
||||
* Parse a date-picker helper's label (e.g. "Last hour", "Last 3 hours", "Last 30
|
||||
* minutes") into a relative amount/unit. Covers both the static presets in
|
||||
|
||||
@@ -15,7 +15,11 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { formatSql } from '@/lib/formatSql'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
export const useAddDefinitions = (id: string, monaco: Monaco | null) => {
|
||||
export const useAddDefinitions = (
|
||||
id: string,
|
||||
monaco: Monaco | null,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
@@ -29,28 +33,28 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: intellisenseEnabled }
|
||||
{ enabled: enabled && intellisenseEnabled }
|
||||
)
|
||||
const { data: functions, isSuccess: isFunctionsSuccess } = useDatabaseFunctionsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: intellisenseEnabled }
|
||||
{ enabled: enabled && intellisenseEnabled }
|
||||
)
|
||||
const { data: schemas, isSuccess: isSchemasSuccess } = useSchemasQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: intellisenseEnabled }
|
||||
{ enabled: enabled && intellisenseEnabled }
|
||||
)
|
||||
const { data: tableColumns, isSuccess: isTableColumnsSuccess } = useTableColumnsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: intellisenseEnabled }
|
||||
{ enabled: enabled && intellisenseEnabled }
|
||||
)
|
||||
|
||||
const pgInfoRef = useRef<any>(null)
|
||||
@@ -58,6 +62,7 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => {
|
||||
const filteredSchemas = useSchemasFilteredForHighAvailability(schemas)
|
||||
|
||||
const isPgInfoReady =
|
||||
enabled &&
|
||||
intellisenseEnabled &&
|
||||
isTableColumnsSuccess &&
|
||||
isSchemasSuccess &&
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useLogsSqlExecution } from './useLogsSqlExecution'
|
||||
import {
|
||||
acceptUntrustedLogsSql,
|
||||
untrustedLogSql,
|
||||
type SafeLogSqlFragment,
|
||||
} from '@/data/logs/safe-analytics-sql'
|
||||
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import {
|
||||
renderSqlEditorHook,
|
||||
resetSqlEditorStores,
|
||||
seedSnippet,
|
||||
setupSqlEditorMocks,
|
||||
} from '@/tests/lib/sql-editor-test-utils'
|
||||
|
||||
const SNIPPET_ID = 'logs-execution-snippet'
|
||||
|
||||
/** Promote raw text to the `SafeLogSqlFragment` the run pipeline expects, exactly
|
||||
* as the toolbar/editor-panel promote it right at the user action. */
|
||||
const logsSql = (text: string): SafeLogSqlFragment => acceptUntrustedLogsSql(untrustedLogSql(text))
|
||||
|
||||
type CapturedBody = { sql: string; iso_timestamp_start: string; iso_timestamp_end: string }
|
||||
|
||||
function mockLogsAllOtel(rows: unknown[] = []) {
|
||||
const captured: CapturedBody[] = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: async ({ request }) => {
|
||||
const body = (await request.json()) as CapturedBody
|
||||
captured.push(body)
|
||||
return HttpResponse.json<any>({ result: rows })
|
||||
},
|
||||
})
|
||||
return captured
|
||||
}
|
||||
|
||||
/** Renders the hook with ClickHouse logs enabled by default; pass
|
||||
* `{ otelLegacyLogs: false }` to exercise the not-available-yet guard. */
|
||||
function renderLogsExecution(flags: Record<string, boolean> = { otelLegacyLogs: true }) {
|
||||
return renderSqlEditorHook(() => useLogsSqlExecution({ id: SNIPPET_ID }), { flags })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSqlEditorStores()
|
||||
setupSqlEditorMocks()
|
||||
seedSnippet({ id: SNIPPET_ID, source: 'logs' })
|
||||
})
|
||||
|
||||
describe('useLogsSqlExecution', () => {
|
||||
it('runs a logs query and writes the result to the session store', async () => {
|
||||
const rows = [{ event_message: 'hello' }]
|
||||
const captured = mockLogsAllOtel(rows)
|
||||
|
||||
const { result } = renderLogsExecution()
|
||||
|
||||
act(() => {
|
||||
result.current.executeLogsQuery(logsSql('select event_message from edge_logs'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined())
|
||||
expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows)
|
||||
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0].sql).toContain('select event_message from edge_logs')
|
||||
expect(captured[0].iso_timestamp_start.length).toBeGreaterThan(0)
|
||||
expect(captured[0].iso_timestamp_end.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('records a structured 200-body error on the result instead of throwing', async () => {
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: async () =>
|
||||
HttpResponse.json<any>({
|
||||
error: {
|
||||
code: 400,
|
||||
errors: [{ domain: 'global', message: 'Missing column', reason: 'invalid' }],
|
||||
message: 'Missing column',
|
||||
status: 'INVALID_ARGUMENT',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const { result } = renderLogsExecution()
|
||||
|
||||
act(() => {
|
||||
result.current.executeLogsQuery(logsSql('select does_not_exist from edge_logs'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined())
|
||||
expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe('Missing column')
|
||||
})
|
||||
|
||||
it('resolves a relative session range to a from/to window around now', async () => {
|
||||
const captured = mockLogsAllOtel([])
|
||||
sqlEditorSessionState.setLogRange(SNIPPET_ID, {
|
||||
kind: 'relative',
|
||||
last: { amount: 2, unit: 'hour' },
|
||||
})
|
||||
|
||||
const { result } = renderLogsExecution()
|
||||
|
||||
act(() => {
|
||||
result.current.executeLogsQuery(logsSql('select 1'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(captured).toHaveLength(1))
|
||||
const from = Date.parse(captured[0].iso_timestamp_start)
|
||||
const to = Date.parse(captured[0].iso_timestamp_end)
|
||||
expect(Number.isNaN(from)).toBe(false)
|
||||
expect(Number.isNaN(to)).toBe(false)
|
||||
expect(from).toBeLessThan(to)
|
||||
})
|
||||
|
||||
it('records an unavailable message and fires no request when ClickHouse logs are off', async () => {
|
||||
const captured = mockLogsAllOtel([])
|
||||
|
||||
const { result } = renderLogsExecution({ otelLegacyLogs: false })
|
||||
|
||||
act(() => {
|
||||
result.current.executeLogsQuery(logsSql('select 1'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined())
|
||||
expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe(
|
||||
"Querying logs from the SQL editor isn't available for this project yet."
|
||||
)
|
||||
// The doomed request never left the client.
|
||||
expect(captured).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource'
|
||||
import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation'
|
||||
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
|
||||
import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import {
|
||||
getSqlEditorSessionSnapshot,
|
||||
useSqlEditorSessionSnapshot,
|
||||
} from '@/state/sql-editor/sql-editor-session-state'
|
||||
|
||||
type UseLogsSqlExecutionArgs = { id: string }
|
||||
|
||||
/**
|
||||
* Logs counterpart to `useSqlEditorExecution`. Runs a promoted
|
||||
* `SafeLogSqlFragment` against the analytics OTEL endpoint with the snippet's
|
||||
* active time range attached as request params.
|
||||
*/
|
||||
export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
|
||||
const track = useTrack()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
|
||||
const { mutate, isPending: isExecuting } = useExecuteLogsSqlMutation({
|
||||
onSuccess: (data) => {
|
||||
sessionSnap.addResult(id, data.rows)
|
||||
},
|
||||
onError: (error) => {
|
||||
sessionSnap.addResultError(id, error)
|
||||
},
|
||||
})
|
||||
|
||||
const executeLogsQuery = useCallback(
|
||||
(sql: SafeLogSqlFragment) => {
|
||||
if (isExecuting || projectRef === undefined) return
|
||||
|
||||
if (!isOtelLogsEnabled) {
|
||||
getSqlEditorSessionSnapshot().addResultError(id, {
|
||||
message: "Querying logs from the SQL editor isn't available for this project yet.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-read imperatively so a range picked immediately before the run is
|
||||
// honored; relative ranges re-resolve against `now` here.
|
||||
const range = resolveLogRunRange(
|
||||
getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE
|
||||
)
|
||||
|
||||
mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) })
|
||||
|
||||
track('sql_editor_query_run_button_clicked', { source: 'logs' })
|
||||
},
|
||||
[id, isExecuting, isOtelLogsEnabled, mutate, projectRef, track]
|
||||
)
|
||||
|
||||
return { executeLogsQuery, isExecuting }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE } from './querySource'
|
||||
import { useRunSource } from './useRunSource'
|
||||
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import {
|
||||
renderSqlEditorHook,
|
||||
resetSqlEditorStores,
|
||||
seedSnippet,
|
||||
setupSqlEditorMocks,
|
||||
} from '@/tests/lib/sql-editor-test-utils'
|
||||
|
||||
beforeEach(() => {
|
||||
resetSqlEditorStores()
|
||||
setupSqlEditorMocks()
|
||||
})
|
||||
|
||||
describe('useRunSource', () => {
|
||||
it('resolves a database snippet to a database source', () => {
|
||||
const id = 'database-snippet'
|
||||
seedSnippet({ id, source: 'database' })
|
||||
|
||||
const { result } = renderSqlEditorHook(() => useRunSource(id))
|
||||
|
||||
expect(result.current).toEqual({ type: 'database' })
|
||||
})
|
||||
|
||||
it('resolves a logs snippet with no session range to the default range', () => {
|
||||
const id = 'logs-snippet-default-range'
|
||||
seedSnippet({ id, source: 'logs' })
|
||||
|
||||
const { result } = renderSqlEditorHook(() => useRunSource(id))
|
||||
|
||||
expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE })
|
||||
})
|
||||
|
||||
it('resolves a logs snippet to its session-stored range when one is set', () => {
|
||||
const id = 'logs-snippet-custom-range'
|
||||
seedSnippet({ id, source: 'logs' })
|
||||
sqlEditorSessionState.setLogRange(id, {
|
||||
kind: 'relative',
|
||||
last: { amount: 2, unit: 'hour' },
|
||||
})
|
||||
|
||||
const { result } = renderSqlEditorHook(() => useRunSource(id))
|
||||
|
||||
expect(result.current).toEqual({
|
||||
type: 'logs',
|
||||
dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE, type QuerySource } from './querySource'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
/**
|
||||
* Resolves where a snippet's query runs. A `log_sql` snippet targets the logs
|
||||
* backend and carries its session time range (falling back to the default when
|
||||
* the user hasn't picked one); every other snippet targets the database. Source
|
||||
* is derived from the snippet's content type and is NOT flag-gated here — a
|
||||
* URL-opened logs snippet routes correctly even with the feature flag off.
|
||||
*/
|
||||
export function useRunSource(id: string): QuerySource {
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
|
||||
const snippetType = snapV2.snippets[id]?.snippet.type
|
||||
const logRange = sessionSnap.logRange[id]
|
||||
|
||||
return useMemo<QuerySource>(() => {
|
||||
if (snippetType === 'log_sql') {
|
||||
return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE }
|
||||
}
|
||||
return { type: 'database' }
|
||||
}, [snippetType, logRange])
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function useSqlEditorExecution({
|
||||
},
|
||||
})
|
||||
|
||||
track('sql_editor_query_run_button_clicked')
|
||||
track('sql_editor_query_run_button_clicked', { source: 'database' })
|
||||
},
|
||||
[
|
||||
editor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { renderHook, type RenderHookOptions } from '@testing-library/react'
|
||||
import { FeatureFlagContext } from 'common'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -323,6 +324,8 @@ type RenderSqlEditorHookOptions<TProps> = {
|
||||
aiAssistantState?: ReturnType<typeof createAiAssistantState>
|
||||
databaseSelectorState?: ReturnType<typeof createDatabaseSelectorState>
|
||||
roleImpersonationState?: ReturnType<typeof createRoleImpersonationState>
|
||||
/** ConfigCat flags to expose via FeatureFlagContext (e.g. `{ otelLegacyLogs: true }`). */
|
||||
flags?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function renderSqlEditorHook<TResult, TProps = undefined>(
|
||||
@@ -336,12 +339,10 @@ export function renderSqlEditorHook<TResult, TProps = undefined>(
|
||||
options?.roleImpersonationState ??
|
||||
createRoleImpersonationState('default', { current: async () => ({}) })
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<CustomWrapper
|
||||
queryClient={options?.queryClient}
|
||||
nuqs={options?.nuqs}
|
||||
profileContext={options?.profileContext}
|
||||
>
|
||||
const flags = options?.flags
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const tree = (
|
||||
<RoleImpersonationStateContext.Provider value={roleImpersonationState}>
|
||||
<DatabaseSelectorStateContext.Provider value={databaseSelectorState}>
|
||||
<AiAssistantStateContext.Provider value={aiAssistantState}>
|
||||
@@ -351,8 +352,24 @@ export function renderSqlEditorHook<TResult, TProps = undefined>(
|
||||
</AiAssistantStateContext.Provider>
|
||||
</DatabaseSelectorStateContext.Provider>
|
||||
</RoleImpersonationStateContext.Provider>
|
||||
</CustomWrapper>
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomWrapper
|
||||
queryClient={options?.queryClient}
|
||||
nuqs={options?.nuqs}
|
||||
profileContext={options?.profileContext}
|
||||
>
|
||||
{flags ? (
|
||||
<FeatureFlagContext.Provider value={{ configcat: flags, posthog: {}, hasLoaded: true }}>
|
||||
{tree}
|
||||
</FeatureFlagContext.Provider>
|
||||
) : (
|
||||
tree
|
||||
)}
|
||||
</CustomWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const result = renderHook(hook, {
|
||||
initialProps: options?.initialProps,
|
||||
|
||||
@@ -1311,6 +1311,10 @@ export interface ImportDataAddedEvent {
|
||||
*/
|
||||
export interface SqlEditorQueryRunButtonClickedEvent {
|
||||
action: 'sql_editor_query_run_button_clicked'
|
||||
properties: {
|
||||
/** Which backend the query ran against. */
|
||||
source: 'database' | 'logs'
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user