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 (
+ <>
+
+
+ }
+ iconRight={}
+ >
+ {QUERY_SOURCE_LABELS[source.id]}
+
+
+
+ {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,
}