From fc3f6aaea8fb60da31f6465e714e5535d6cbee09 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 14 Aug 2026 01:07:20 +0700 Subject: [PATCH] Add source selector for explorer query tab (#49063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context This is just pulling out the relevant changes from https://github.com/supabase/supabase/pull/49028 as I might have messed up the stack while making changes down the PRs 🙏 Builds on the Query Tab in the Explorer UI, adds the source selector component to run either a database query or a logs query - will subsequently be looking into have the source selector component in the QueryCell as well (within notebooks) image image ## Summary by CodeRabbit - **New Features** - Added query-source selection for Explorer and notebook queries. - Supports log queries, database selection, and read-replica connections. - Added validation for custom log time ranges and retention limits, with upgrade guidance when applicable. - Query source choices are saved and restored across sessions. - Changing sources clears previous results to prevent stale data. - **Bug Fixes** - Improved handling of unavailable log querying and missing database connections. - Legacy saved queries now fall back safely to the default database source. --- .../Explorer/ExplorerQuerySourceMenu.tsx | 153 ++++++++++++++++++ .../interfaces/Explorer/QueryEditor.tsx | 77 ++++++++- .../interfaces/Explorer/QueryTab.tsx | 2 + apps/studio/state/explorer-query.test.ts | 45 ++++++ apps/studio/state/explorer-query.ts | 63 ++++++-- 5 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx new file mode 100644 index 00000000000..89102a59e4b --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -0,0 +1,153 @@ +import { useFlag, useParams } from 'common' +import dayjs from 'dayjs' +import { Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' +import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' +import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' +import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' +import { + createDefaultCellSource, + QUERY_SOURCE_LABELS, + QUERY_SOURCES, + type CellSource, +} from '@/data/query-sources/query-source-registry' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' + +export type ExplorerQuerySourceMenuProps = { + source: CellSource + onSourceChange: (source: CellSource) => void +} + +/** + * Source binding and parameter controls shared by standalone Explorer queries + * and notebook query-cell toolbars. The consumer owns the binding; this menu + * only emits complete, validated-by-construction `CellSource` values. + */ +export const ExplorerQuerySourceMenu = ({ + source, + onSourceChange, +}: ExplorerQuerySourceMenuProps) => { + const { ref } = useParams() + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const availableSources = QUERY_SOURCES.filter( + (candidate) => + candidate.type !== 'logs' || + (isLogsSourceEnabled && isOtelLogsEnabled) || + source.type === 'logs' + ) + + const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { + const fromIso = dayjs(from).startOf('day').toISOString() + if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { + setShowUpgradePrompt(true) + return + } + + onSourceChange({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { + type: 'absolute', + from: fromIso, + to: dayjs(to).endOf('day').toISOString(), + }, + }, + }) + } + + return ( + <> + + + + + + {availableSources.map((candidate) => ( + { + event.preventDefault() + if (candidate.id !== source.id) { + onSourceChange(createDefaultCellSource(candidate.id)) + } + }} + > + + + {QUERY_SOURCE_LABELS[candidate.id]} + + {source.id === candidate.id && } + + ))} + + + + {source.type === 'database' ? ( + + onSourceChange({ + id: 'database', + type: 'database', + parameters: { identifier }, + }) + } + /> + ) : ( + + onSourceChange({ + id: 'logs', + type: 'logs', + parameters: { time_range: timeRange }, + }) + } + onOpenCustomRange={() => setIsCustomRangeOpen(true)} + onShowUpgrade={() => setShowUpgradePrompt(true)} + /> + )} + + + + {source.type === 'logs' && ( + <> + + + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index d1ecf65fd9f..537e858d23d 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -1,8 +1,10 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' +import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' import { useState, type ReactNode } from 'react' import { cn } from 'ui' +import { resolveLogTimeRange } from '../QuerySources/LogTimeRange.utils' import { ExplorerQuery, ExplorerQueryEditor, @@ -10,6 +12,7 @@ import { ExplorerQueryResults, ExplorerQueryViewport, } from './ExplorerQuery' +import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu' import { ExplorerToolbar, ExplorerToolbarAction, @@ -23,6 +26,15 @@ import { QueryResultTable } from './QueryResultTable' import { type QueryDisplay, type QueryResult } from './types' import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { isValidConnString } from '@/data/fetchers' +import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' +import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + createDefaultCellSource, + QUERY_SOURCE_REGISTRY, + type CellSource, +} from '@/data/query-sources/query-source-registry' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -32,6 +44,7 @@ export type QueryEditorProps = { variant: 'embedded' | 'viewport' title: string sql: string + source?: CellSource result?: QueryResult rowLimit: number display?: QueryDisplay @@ -39,6 +52,7 @@ export type QueryEditorProps = { onTitleChange: (title: string) => void onSqlChange: (sql: string) => void onSqlCommit?: (sql: string) => void + onSourceChange?: (source: CellSource) => void onResultChange: (result: QueryResult) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -53,6 +67,7 @@ export const QueryEditor = ({ variant, title, sql, + source, result, rowLimit, display, @@ -60,35 +75,88 @@ export const QueryEditor = ({ onTitleChange, onSqlChange, onSqlCommit, + onSourceChange, onResultChange, onDisplayChange, }: QueryEditorProps) => { const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) + const isOtelLogsEnabled = useFlag('otelLegacyLogs') const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery() const view = display?.view ?? 'table' const columns = Object.keys(result?.rows?.[0] ?? {}) + const sourceBinding = source ?? createDefaultCellSource('database') const [showQuery, setShowQuery] = useState(true) - const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({ + const databaseIdentifier = + sourceBinding.type === 'database' ? sourceBinding.parameters.identifier : undefined + + const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( + { projectRef: project?.ref }, + { + enabled: + databaseIdentifier !== undefined && + project?.ref !== undefined && + databaseIdentifier !== project.ref, + } + ) + + const { mutate: executeSql, isPending: isExecutingSql } = useExecuteSqlMutation({ onSuccess: (data) => onResultChange({ rows: data.result }), onError: (error) => onResultChange({ error }), }) + const { mutate: executeLogsSql, isPending: isExecutingLogs } = useExecuteLogsSqlMutation({ + onSuccess: (data) => onResultChange({ rows: data.rows as readonly Record[] }), + onError: (error) => onResultChange({ error }), + }) + + const isResolvingDatabase = + databaseIdentifier !== undefined && databaseIdentifier !== project?.ref && isLoadingDatabases + const isExecuting = isExecutingSql || isExecutingLogs + const isBusy = isLoadingProject || isResolvingDatabase || isExecuting + const handleRunQuery = (sqlToRun: string = sql) => { - if (!project || isLoadingProject || isExecuting || sqlToRun.trim().length === 0) return + if (!project || isBusy || sqlToRun.trim().length === 0) return onSqlCommit?.(sql) + if (sourceBinding.type === 'logs') { + if (!isOtelLogsEnabled) { + onResultChange({ + error: { message: "Querying logs isn't available for this project yet." }, + }) + return + } + + executeLogsSql({ + projectRef: project.ref, + sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)), + range: resolveLogTimeRange(sourceBinding.parameters.time_range), + endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, + }) + return + } + const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun)) const limitedSql = applyAutoLimit(safeSql, rowLimit) + const connectionString = + databaseIdentifier === undefined || databaseIdentifier === project.ref + ? project.connectionString + : databases?.find((database) => database.identifier === databaseIdentifier) + ?.connectionString + + if (!isValidConnString(connectionString)) { + onResultChange({ error: { message: 'Unable to run query: Connection string is missing' } }) + return + } executeSql({ projectRef: project.ref, - connectionString: project.connectionString, + connectionString, sql: limitedSql.sql, autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined, contextualInvalidation: true, @@ -107,6 +175,9 @@ export const QueryEditor = ({ {title} {toolbarActions} + {source && onSourceChange && ( + + )} {display && onDisplayChange && ( { variant="viewport" title={draft.name} sql={draft.uncheckedSql} + source={draft.source} result={result} rowLimit={QUERY_ROW_LIMIT} onTitleChange={(value) => { @@ -83,6 +84,7 @@ export const QueryTab = () => { tabs.updateTab(createTabId('query', { id }), { label: name }) }} onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })} + onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })} onResultChange={handleResultChange} /> ) diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index b1c653430ba..e63364b756c 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -26,12 +26,57 @@ describe('explorer query drafts', () => { expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) expect(secondState.drafts['query-1']).toMatchObject({ name: 'Active users', + source: { id: 'database', type: 'database', parameters: {} }, uncheckedSql: 'select * from users', projectRef: 'project-a', }) expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false) }) + it('persists source parameters and clears stale results when they change', () => { + const storage = createMemoryStorage() + const state = createExplorerQueryState(storage) + + state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' }) + state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } }) + state.updateDraft({ + id: 'query-1', + source: { + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + }, + }) + + expect(state.results['query-1']).toBeUndefined() + + const restored = createExplorerQueryState(storage) + expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(restored.drafts['query-1'].source).toEqual({ + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + }) + }) + + it('restores pre-source drafts as database queries', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1'].source).toEqual({ + id: 'database', + type: 'database', + parameters: {}, + }) + }) + it('removes the persisted draft and its session result when its tab closes', () => { const storage = createMemoryStorage() const state = createExplorerQueryState(storage) diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index 28df97f7b59..7a10a41f5df 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -3,11 +3,17 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { proxy, ref, snapshot, useSnapshot } from 'valtio' import { type QueryResult } from '@/components/interfaces/Explorer/types' +import { + cellSourceSchema, + createDefaultCellSource, + type CellSource, +} from '@/data/query-sources/query-source-registry' export type ExplorerQueryDraft = { id: string projectRef: string name: string + source: CellSource uncheckedSql: UntrustedSqlFragment updatedAt: number } @@ -18,6 +24,7 @@ export type ExplorerQueryResult = QueryResult & { type PersistedExplorerQueryDraft = { name: string + source: CellSource sql: string updatedAt: number } @@ -35,18 +42,27 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} return Object.fromEntries( - Object.entries(parsed).filter((entry): entry is [string, PersistedExplorerQueryDraft] => { - const draft = entry[1] - return ( - draft !== null && - typeof draft === 'object' && - 'name' in draft && - typeof draft.name === 'string' && - 'sql' in draft && - typeof draft.sql === 'string' && - 'updatedAt' in draft && - typeof draft.updatedAt === 'number' - ) + Object.entries(parsed).flatMap(([id, value]) => { + if ( + value === null || + typeof value !== 'object' || + !('name' in value) || + typeof value.name !== 'string' || + !('sql' in value) || + typeof value.sql !== 'string' || + !('updatedAt' in value) || + typeof value.updatedAt !== 'number' + ) { + return [] + } + + const parsedSource = + 'source' in value ? cellSourceSchema.safeParse(value.source) : { success: false as const } + const source = parsedSource.success + ? parsedSource.data + : createDefaultCellSource('database') + + return [[id, { name: value.name, source, sql: value.sql, updatedAt: value.updatedAt }]] }) ) } catch { @@ -74,23 +90,26 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage projectRef, name = 'Untitled query', sql = '', + source = createDefaultCellSource('database'), }: { id: string projectRef: string name?: string sql?: string + source?: CellSource }) => { const draft: ExplorerQueryDraft = { id, projectRef, name, + source: cellSourceSchema.parse(source), uncheckedSql: untrustedSql(sql), updatedAt: Date.now(), } state.drafts[id] = draft const persisted = readPersistedDrafts(storage, projectRef) - persisted[id] = { name, sql, updatedAt: draft.updatedAt } + persisted[id] = { name, source: draft.source, sql, updatedAt: draft.updatedAt } writePersistedDrafts(storage, projectRef, persisted) return id @@ -106,23 +125,39 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage id, projectRef, name: persisted.name, + source: persisted.source, uncheckedSql: untrustedSql(persisted.sql), updatedAt: persisted.updatedAt, } return true }, - updateDraft: ({ id, name, sql }: { id: string; name?: string; sql?: string }) => { + updateDraft: ({ + id, + name, + source, + sql, + }: { + id: string + name?: string + source?: CellSource + sql?: string + }) => { const draft = state.drafts[id] if (!draft) return if (name !== undefined) draft.name = name + if (source !== undefined) { + draft.source = cellSourceSchema.parse(source) + delete state.results[id] + } if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql) draft.updatedAt = Date.now() const persisted = readPersistedDrafts(storage, draft.projectRef) persisted[id] = { name: draft.name, + source: draft.source, sql: draft.uncheckedSql, updatedAt: draft.updatedAt, }