From 0f37d29f6302e2139f3318af966d9d0ca269e1cd Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Fri, 3 Jul 2026 17:27:56 -0600
Subject: [PATCH] polish
---
.../RoleImpersonationPopover.tsx | 5 +-
.../interfaces/SQLEditor/SQLEditor.tsx | 17 +-
.../SQLEditor/SqlEditorQueryBar.tsx | 253 +++++++++++++++---
.../SQLEditor/SqlEditorSearchPathSelector.tsx | 184 +++++++++----
.../SQLEditor/SqlEditorWarehouseDemo.tsx | 114 ++++++++
.../UtilityPanel/SqlResultsSummary.tsx | 70 ++---
.../SQLEditor/UtilityPanel/UtilityPanel.tsx | 8 +-
.../components/interfaces/UserDropdown.tsx | 2 +
.../SqlEditorWarehouseDemoMenu.tsx | 89 ++++++
.../studio/components/ui/DatabaseSelector.tsx | 10 +-
apps/studio/data/sql/execute-sql-mutation.ts | 2 +
.../sql-editor-session-state.test.ts | 13 +
.../sql-editor/sql-editor-session-state.ts | 32 ++-
13 files changed, 653 insertions(+), 146 deletions(-)
create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx
create mode 100644 apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx
diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
index 27d10d83561..724232ae8c8 100644
--- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
+++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
@@ -13,6 +13,7 @@ export interface RoleImpersonationPopoverProps {
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
align?: 'center' | 'start' | 'end'
disallowAuthenticatedOption?: boolean
+ className?: string
}
export const RoleImpersonationPopover = ({
@@ -21,6 +22,7 @@ export const RoleImpersonationPopover = ({
variant = 'regular',
align = 'end',
disallowAuthenticatedOption = false,
+ className,
}: RoleImpersonationPopoverProps) => {
const state = useRoleImpersonationStateSnapshot()
@@ -38,7 +40,8 @@ export const RoleImpersonationPopover = ({
'h-[26px] pr-3 gap-0',
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
- variant === 'connected-on-both' && 'rounded-none border-x-0'
+ variant === 'connected-on-both' && 'rounded-none border-x-0',
+ className
)}
>
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
index c9d8c36ce53..91364df5aa2 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
@@ -42,6 +42,11 @@ import {
suffixWithLimit,
} from './SQLEditor.utils'
import { SqlEditorQueryBar } from './SqlEditorQueryBar'
+import {
+ DEFAULT_SQL_EDITOR_SEARCH_PATH,
+ getSqlEditorSearchPathStorageKey,
+} from './SqlEditorSearchPathSelector'
+import { resolveSqlWarehouseResultSource } from './SqlEditorWarehouseDemo'
import { useAddDefinitions } from './useAddDefinitions'
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
import {
@@ -58,6 +63,7 @@ import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { isError } from '@/data/utils/error-check'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -112,6 +118,10 @@ export const SQLEditor = () => {
const sessionSnap = useSqlEditorSessionSnapshot()
const diffRequest = useSqlEditorDiffRequestSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
+ const [searchPath] = useLocalStorageQuery(
+ getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
+ DEFAULT_SQL_EDITOR_SEARCH_PATH
+ )
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
const { aiOptInLevel } = useOrgAiOptInLevel()
@@ -220,7 +230,7 @@ export const SQLEditor = () => {
const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({
onSuccess(data, vars) {
if (id) {
- sessionSnap.addResult(id, data.result, vars.autoLimit)
+ sessionSnap.addResult(id, data.result, vars.autoLimit, vars.warehouseResultSource)
if (!disablePrettyExplain && isExplainQuery(data.result)) {
sessionSnap.addExplainResult(id, data.result)
@@ -268,7 +278,7 @@ export const SQLEditor = () => {
}
}
- sessionSnap.addResultError(id, error, vars.autoLimit)
+ sessionSnap.addResultError(id, error, vars.autoLimit, vars.warehouseResultSource)
}
refocusEditorAfterRunIfNeeded()
@@ -418,12 +428,14 @@ export const SQLEditor = () => {
const userSql = rawSql(sql)
const { appendAutoLimit } = checkIfAppendLimitRequired(userSql, limit)
const formattedSql = suffixWithLimit(userSql, limit)
+ const warehouseResultSource = resolveSqlWarehouseResultSource(userSql, searchPath)
execute({
projectRef: project.ref,
connectionString: connectionString,
sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState),
autoLimit: appendAutoLimit ? limit : undefined,
+ warehouseResultSource,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
isStatementTimeoutDisabled: true,
contextualInvalidation: true,
@@ -449,6 +461,7 @@ export const SQLEditor = () => {
databases,
eventTriggers,
limit,
+ searchPath,
track,
]
)
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx
index 11c63f02162..556ae71b56c 100644
--- a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx
@@ -1,18 +1,30 @@
import { Hotkey } from '@tanstack/react-hotkeys'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
-import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
+import { AlignLeft, ChevronDown, MoreVertical } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
KeyboardShortcut,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
} from 'ui'
+import { useSnapshot } from 'valtio'
-import { SqlEditorSearchPathSelector } from './SqlEditorSearchPathSelector'
+import {
+ DEFAULT_SQL_EDITOR_SEARCH_PATH,
+ getSqlEditorSearchPathStorageKey,
+ SqlEditorSearchPathSelector,
+} from './SqlEditorSearchPathSelector'
+import { sqlEditorWarehouseDemoStore } from './SqlEditorWarehouseDemo'
import { SqlRunButton } from './UtilityPanel/RunButton'
import SavingIndicator from './UtilityPanel/SavingIndicator'
import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector'
@@ -20,6 +32,7 @@ import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonat
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
+import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
@@ -34,6 +47,8 @@ export type SqlEditorQueryBarProps = {
executeQuery: () => void
}
+const SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH = 896
+
export function SqlEditorQueryBar({
id,
isExecuting = false,
@@ -45,6 +60,10 @@ export function SqlEditorQueryBar({
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const sessionSnap = useSqlEditorSessionSnapshot()
+ const roleImpersonationState = useRoleImpersonationStateSnapshot()
+ const warehouseDemoSnap = useSnapshot(sqlEditorWarehouseDemoStore)
+ const queryBarRef = useRef
(null)
+ const [areSeparateQueryControlsCollapsed, setAreSeparateQueryControlsCollapsed] = useState(false)
const [intelliSenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
@@ -54,24 +73,34 @@ export function SqlEditorQueryBar({
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
''
)
+ const [searchPath] = useLocalStorageQuery(
+ getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
+ DEFAULT_SQL_EDITOR_SEARCH_PATH
+ )
const snippet = snapV2.snippets[id]
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
+ const currentRole = roleImpersonationState.role?.role ?? 'postgres'
const hotkeySequnece: Hotkey | undefined =
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined
- const toggleIntellisense = () => {
- setIntellisenseEnabled(!intelliSenseEnabled)
+ const handleIntelliSenseChange = (checked: boolean) => {
+ setIntellisenseEnabled(checked)
toast.success(
- `Successfully ${intelliSenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intelliSenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}`
+ `Successfully ${checked ? 'enabled' : 'disabled'} IntelliSense. ${checked ? '' : 'Please refresh your browser for changes to take place.'}`
)
}
const toggleFavorite = () => {
- if (isFavorite) snapV2.removeFavorite(id)
- else snapV2.addFavorite(id)
+ if (isFavorite) {
+ snapV2.removeFavorite(id)
+ toast.success('Removed from favourites')
+ } else {
+ snapV2.addFavorite(id)
+ toast.success('Added to favourites')
+ }
}
const onSelectDatabase = (databaseId: string) => {
@@ -79,8 +108,36 @@ export function SqlEditorQueryBar({
setLastSelectedDb(databaseId)
}
+ useEffect(() => {
+ const queryBar = queryBarRef.current
+ if (!queryBar) return
+
+ const updateQueryControlsVisibility = (width: number) => {
+ const shouldCollapse = width < SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH
+ setAreSeparateQueryControlsCollapsed((current) =>
+ current === shouldCollapse ? current : shouldCollapse
+ )
+ }
+
+ updateQueryControlsVisibility(queryBar.getBoundingClientRect().width)
+
+ const observer = new ResizeObserver(([entry]) => {
+ updateQueryControlsVisibility(entry.contentRect.width)
+ })
+ observer.observe(queryBar)
+
+ return () => observer.disconnect()
+ }, [])
+
+ const isSeparateQueryControlMode = warehouseDemoSnap.queryControlMode === 'separate'
+ const shouldShowCollapsedQueryControls =
+ isSeparateQueryControlMode && areSeparateQueryControlsCollapsed
+
return (
-
+
{IS_PLATFORM && }
@@ -94,7 +151,23 @@ export function SqlEditorQueryBar({
aria-label="Query options"
/>
-
+
+ {shouldShowCollapsedQueryControls && (
+ <>
+
+
+ >
+ )}
+
+ Actions
+
@@ -102,40 +175,39 @@ export function SqlEditorQueryBar({
{formatKeys && }
- {IS_PLATFORM && (
- <>
-
-
-
- {isFavorite ? 'Remove from' : 'Add to'} favorites
-
- >
- )}
-
-
-
- IntelliSense enabled
-
- {intelliSenseEnabled && }
-
+
+ Preferences
+
+ {IS_PLATFORM && (
+
+ Favourite query
+
+ )}
+
+ IntelliSense
+
- {IS_PLATFORM && (
-
+
+
+ ) : (
+
)}
-
-
)
}
+
+type QueryContextDropdownProps = {
+ currentRole: string
+ onSelectDatabase: (databaseId: string) => void
+ searchPath: string
+ selectedDatabaseId: string
+}
+
+type SeparateQueryControlsProps = {
+ fullWidth?: boolean
+ onSelectDatabase: (databaseId: string) => void
+ selectedDatabaseId: string
+}
+
+function SeparateQueryControls({
+ fullWidth = false,
+ onSelectDatabase,
+ selectedDatabaseId,
+}: SeparateQueryControlsProps) {
+ const controlClassName = fullWidth ? 'w-full [&>span]:w-full' : undefined
+
+ return (
+ <>
+ {IS_PLATFORM && (
+
+ )}
+
+ span]:w-full' : undefined}
+ />
+ >
+ )
+}
+
+function QueryContextDropdown({
+ currentRole,
+ onSelectDatabase,
+ searchPath,
+ selectedDatabaseId,
+}: QueryContextDropdownProps) {
+ const { ref } = useParams()
+ const isPrimaryConnection = selectedDatabaseId.length === 0 || selectedDatabaseId === ref
+ const selectedConnectionLabel = isPrimaryConnection ? 'Primary' : 'Replica'
+ const activeContextLabels = [
+ !isPrimaryConnection ? selectedConnectionLabel : undefined,
+ searchPath !== DEFAULT_SQL_EDITOR_SEARCH_PATH ? searchPath : undefined,
+ currentRole !== 'postgres' ? currentRole : undefined,
+ ].filter((label): label is string => label !== undefined)
+ const contextSummary =
+ activeContextLabels.length === 0
+ ? undefined
+ : activeContextLabels.length === 1
+ ? activeContextLabels[0]
+ : `${activeContextLabels.length} changes`
+
+ return (
+
+
+
+
+ Query
+ {contextSummary !== undefined && (
+
+ {contextSummary}
+
+ )}
+
+
+
+
+
+
+
Query context
+
+ These settings apply together when this query runs.
+
+
+
+ {IS_PLATFORM && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx
index fcce7d7d335..7d7e9961625 100644
--- a/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx
@@ -1,10 +1,12 @@
-import { LOCAL_STORAGE_KEYS, useParams } from 'common'
+import { useParams } from 'common'
import { ChevronDown } from 'lucide-react'
+import { useState } from 'react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
@@ -12,59 +14,111 @@ import {
DropdownMenuTrigger,
} from 'ui'
+import { useSchemasQuery } from '@/data/database/schemas-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
type SearchPathOption = {
value: string
label: string
- description: string
+ description?: string
+}
+
+type SqlEditorSearchPathSelectorProps = {
+ className?: string
+ align?: 'start' | 'end'
}
const SEARCH_PATH_OPTIONS: SearchPathOption[] = [
{
value: 'public',
label: 'public',
- description: 'Default Postgres tables',
},
{
value: 'public_warehouse',
label: 'public_warehouse',
- description: 'Linked Warehouse tables',
+ description: 'Warehouse linked schema',
},
{
value: 'auth',
label: 'auth',
- description: 'Auth schema',
},
{
value: 'storage',
label: 'storage',
- description: 'Storage schema',
- },
- {
- value: 'custom',
- label: 'Custom',
- description: 'Multiple schemas, e.g. "$user", public',
},
]
-const DEFAULT_SEARCH_PATH = 'public'
+export const DEFAULT_SQL_EDITOR_SEARCH_PATH = 'public'
+const COMMON_SCHEMA_NAMES = ['public', 'auth', 'storage']
+export const getSqlEditorSearchPathStorageKey = (ref: string) => `sql-editor-search-path-${ref}`
-function getSearchPathOption(value: string) {
+function getSchemaDescription(schemaName: string) {
+ if (schemaName.endsWith('_warehouse')) return 'Warehouse linked schema'
+
+ return undefined
+}
+
+function getSchemaSortPriority(schemaName: string) {
+ const commonIndex = COMMON_SCHEMA_NAMES.indexOf(schemaName)
+ if (commonIndex !== -1) return commonIndex
+ if (schemaName.endsWith('_warehouse')) return COMMON_SCHEMA_NAMES.length
+
+ return COMMON_SCHEMA_NAMES.length + 1
+}
+
+function getSearchPathOption(value: string, options: SearchPathOption[]) {
return (
- SEARCH_PATH_OPTIONS.find((option) => option.value === value) ??
- SEARCH_PATH_OPTIONS.find((option) => option.value === 'custom')!
+ options.find((option) => option.value === value) ?? {
+ value,
+ label: value,
+ }
)
}
-export function SqlEditorSearchPathSelector() {
+export function SqlEditorSearchPathSelector({
+ className,
+ align = 'end',
+}: SqlEditorSearchPathSelectorProps) {
const { ref } = useParams()
+ const [isShowingMoreSchemas, setIsShowingMoreSchemas] = useState(false)
+ const { data: project } = useSelectedProjectQuery()
+ const { data: schemaData } = useSchemasQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
const [searchPath, setSearchPath] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.SQL_EDITOR_SEARCH_PATH(ref ?? 'unknown'),
- DEFAULT_SEARCH_PATH
+ getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
+ DEFAULT_SQL_EDITOR_SEARCH_PATH
)
- const selectedOption = getSearchPathOption(searchPath)
- const isWarehousePath = selectedOption.value.endsWith('_warehouse')
+ const schemaOptions =
+ schemaData !== undefined && schemaData.length > 0
+ ? schemaData
+ .map((schema) => ({
+ value: schema.name,
+ label: schema.name,
+ description: getSchemaDescription(schema.name),
+ }))
+ .sort((a, b) => {
+ const priority = getSchemaSortPriority(a.label) - getSchemaSortPriority(b.label)
+ return priority !== 0 ? priority : a.label.localeCompare(b.label)
+ })
+ : SEARCH_PATH_OPTIONS
+ const selectedOption = getSearchPathOption(searchPath, schemaOptions)
+ const standardSchemaOptions = schemaOptions.filter(
+ (option) => !option.value.endsWith('_warehouse')
+ )
+ const warehouseSchemaOptions = schemaOptions.filter((option) =>
+ option.value.endsWith('_warehouse')
+ )
+ const visibleStandardSchemaOptions = standardSchemaOptions.filter(
+ (option) =>
+ isShowingMoreSchemas ||
+ COMMON_SCHEMA_NAMES.includes(option.value) ||
+ option.value === selectedOption.value
+ )
+ const hiddenStandardSchemaCount =
+ standardSchemaOptions.length - visibleStandardSchemaOptions.length
return (
@@ -73,49 +127,83 @@ export function SqlEditorSearchPathSelector() {
type="button"
size="tiny"
variant="default"
- className="h-[26px] justify-start gap-0 pr-3"
+ className={cn('h-[26px] justify-start gap-0 pr-3', className)}
>
Search path
-
- {selectedOption.label}
-
+ {selectedOption.label}
-
+
Search path
- Unqualified table names resolve against this schema first. Custom represents a
- multi-schema path.
+ Choose where your query looks first for unqualified table names.{' '}
+
+ Learn more
+
- {SEARCH_PATH_OPTIONS.map((option) => (
-
-
-
0 && (
+ <>
+
+ {warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'}
+
+ {visibleStandardSchemaOptions.map((option) => (
+
+
+ {option.label}
+ {option.description !== undefined && (
+
+ {option.description}
+
+ )}
+
+
+ ))}
+ {hiddenStandardSchemaCount > 0 && (
+ {
+ event.preventDefault()
+ setIsShowingMoreSchemas(true)
+ }}
>
- {option.label}
-
-
- {option.description}
-
-
-
- ))}
+ Show {hiddenStandardSchemaCount} more schema
+ {hiddenStandardSchemaCount === 1 ? '' : 's'}
+
+ )}
+ >
+ )}
+ {warehouseSchemaOptions.length > 0 && (
+ <>
+
+
+ Warehouse schemas
+
+ {warehouseSchemaOptions.map((option) => (
+
+
+ {option.label}
+ {option.description !== undefined && (
+
+ {option.description}
+
+ )}
+
+
+ ))}
+ >
+ )}
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx
new file mode 100644
index 00000000000..b63d52322cd
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx
@@ -0,0 +1,114 @@
+import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { proxy, useSnapshot } from 'valtio'
+
+export type SqlWarehouseHealthPreset = 'healthy' | 'catching_up' | 'degraded' | 'error'
+export type SqlEditorQueryControlMode = 'compact' | 'separate'
+
+export type SqlWarehouseResultSource =
+ | { type: 'search_path'; schema: string }
+ | { type: 'query_reference' }
+
+const SQL_WAREHOUSE_SCHEMA_PATTERN = /\b[a-zA-Z_][\w$]*_warehouse\s*\./i
+
+const SQL_WAREHOUSE_HEALTH_LABELS: Record = {
+ healthy: '',
+ catching_up: 'Catching up',
+ degraded: 'Degraded',
+ error: 'Error',
+}
+
+const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record = {
+ healthy: 'Warehouse replication is caught up with Postgres.',
+ catching_up:
+ 'Warehouse replication is catching up. Recent writes may not appear in linked tables yet.',
+ degraded: 'Warehouse replication is degraded. Query results may be stale.',
+ error: 'Warehouse replication encountered an error. Check replication logs.',
+}
+
+const SQL_WAREHOUSE_HEALTH_CLASS_NAMES: Record = {
+ healthy: 'text-foreground-light hover:text-foreground',
+ catching_up: 'text-warning hover:text-warning-600',
+ degraded: 'text-destructive hover:text-destructive-600',
+ error: 'text-destructive hover:text-destructive-600',
+}
+
+export const sqlEditorWarehouseDemoStore = proxy<{
+ health: SqlWarehouseHealthPreset
+ queryControlMode: SqlEditorQueryControlMode
+}>({
+ health: 'healthy',
+ queryControlMode: 'separate',
+})
+
+export function setSqlEditorWarehouseHealth(health: SqlWarehouseHealthPreset) {
+ sqlEditorWarehouseDemoStore.health = health
+}
+
+export function setSqlEditorQueryControlMode(mode: SqlEditorQueryControlMode) {
+ sqlEditorWarehouseDemoStore.queryControlMode = mode
+}
+
+export function resolveSqlWarehouseResultSource(
+ sql: string,
+ searchPath: string
+): SqlWarehouseResultSource | undefined {
+ if (searchPath.endsWith('_warehouse')) {
+ return { type: 'search_path', schema: searchPath }
+ }
+
+ if (SQL_WAREHOUSE_SCHEMA_PATTERN.test(sql)) {
+ return { type: 'query_reference' }
+ }
+
+ return undefined
+}
+
+function getWarehouseResultSourceTooltip(source: SqlWarehouseResultSource) {
+ if (source.type === 'search_path') {
+ return `Results are served from Warehouse because the search path starts with ${source.schema}.`
+ }
+
+ return 'Results include a linked Warehouse table referenced directly in the SQL.'
+}
+
+export function SqlWarehouseResultStatus({ source }: { source: SqlWarehouseResultSource }) {
+ const snap = useSnapshot(sqlEditorWarehouseDemoStore)
+ const health = snap.health as SqlWarehouseHealthPreset
+ const shouldShowHealth = health !== 'healthy'
+
+ return (
+
+
+
+
+
+ Warehouse
+
+
+
+
+ {getWarehouseResultSourceTooltip(source)}
+
+
+ {shouldShowHealth && (
+ <>
+ ·
+
+
+
+
+ {SQL_WAREHOUSE_HEALTH_LABELS[health]}
+
+
+
+
+ {SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx
index 95a12f1bea2..448fec0c6ad 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx
@@ -1,63 +1,47 @@
-import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface SqlResultsSummaryProps {
rowCount: number
autoLimit?: number
}
-type ResultsHealth = 'complete' | 'limited'
-
-const RESULTS_HEALTH_LABEL: Record = {
- complete: 'Complete',
- limited: 'Limited',
-}
-
-const RESULTS_HEALTH_CLASS_NAME: Record = {
- complete: 'text-foreground-light hover:text-foreground',
- limited: 'text-warning hover:text-warning-600',
-}
-
function formatRowCount(rowCount: number) {
return `${rowCount.toLocaleString()} row${rowCount === 1 ? '' : 's'}`
}
export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) {
- const health: ResultsHealth = autoLimit !== undefined ? 'limited' : 'complete'
+ const hasReachedLimit = autoLimit !== undefined && rowCount >= autoLimit
return (
-
-
-
-
- {RESULTS_HEALTH_LABEL[health]}
-
-
-
-
- {health === 'limited' ? (
-
-
- Results are limited to preserve browser performance when a query returns many rows.
+ {hasReachedLimit && (
+ <>
+
+
+
+
+ Limited
+
-
- Change or remove this limit from the dropdown near Run.
-
-
- ) : (
- 'Query completed without applying the SQL Editor row limit.'
- )}
-
-
- ·
+
+
+
+
+ Results are limited to preserve browser performance when a query returns many
+ rows.
+
+
+ Change or remove this limit from the dropdown near Run.
+
+
+
+
+ ·
+ >
+ )}
{formatRowCount(rowCount)}
- {autoLimit !== undefined && (
+ {hasReachedLimit && (
(limit {autoLimit.toLocaleString()})
)}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx
index 141d27e6e72..722e5656909 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import { toast } from 'sonner'
import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
+import { SqlWarehouseResultStatus } from '../SqlEditorWarehouseDemo'
import { ChartConfig } from './ChartConfig'
import { SqlResultsSummary } from './SqlResultsSummary'
import { UtilityTabExplain } from './UtilityTabExplain'
@@ -151,7 +152,12 @@ export const UtilityPanel = ({
{showResultsSummary && (
-
+ <>
+ {result.warehouseResultSource && (
+
+ )}
+
+ >
)}
{result?.rows && (
diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx
index ae004c1a9c1..da76ea77bcc 100644
--- a/apps/studio/components/interfaces/UserDropdown.tsx
+++ b/apps/studio/components/interfaces/UserDropdown.tsx
@@ -19,6 +19,7 @@ import {
import { ButtonTooltip } from '../ui/ButtonTooltip'
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
+import { SqlEditorWarehouseDemoMenu } from './UserDropdown/SqlEditorWarehouseDemoMenu'
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
@@ -136,6 +137,7 @@ export function UserDropdown({
Changelog
+
>
diff --git a/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx
new file mode 100644
index 00000000000..ed1c7e53a4a
--- /dev/null
+++ b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx
@@ -0,0 +1,89 @@
+import { Database } from 'lucide-react'
+import { toast } from 'sonner'
+import {
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from 'ui'
+import { useSnapshot } from 'valtio'
+
+import {
+ setSqlEditorQueryControlMode,
+ setSqlEditorWarehouseHealth,
+ sqlEditorWarehouseDemoStore,
+ type SqlEditorQueryControlMode,
+ type SqlWarehouseHealthPreset,
+} from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
+
+const WAREHOUSE_HEALTH_PRESETS: { preset: SqlWarehouseHealthPreset; label: string }[] = [
+ { preset: 'healthy', label: 'Live' },
+ { preset: 'catching_up', label: 'Catching up' },
+ { preset: 'degraded', label: 'Degraded' },
+ { preset: 'error', label: 'Error' },
+]
+
+const QUERY_CONTROL_MODES: { mode: SqlEditorQueryControlMode; label: string }[] = [
+ { mode: 'compact', label: 'Compact Query dropdown' },
+ { mode: 'separate', label: 'Separate controls' },
+]
+
+export function SqlEditorWarehouseDemoMenu() {
+ const snap = useSnapshot(sqlEditorWarehouseDemoStore)
+
+ return (
+ <>
+
+
+
+
+ SQL Warehouse demo
+
+
+
+ Results health
+
+ {WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => (
+ {
+ setSqlEditorWarehouseHealth(preset)
+ toast.message(`SQL Warehouse demo: ${label}`)
+ }}
+ >
+ {label}
+
+ ))}
+
+
+ Query control
+
+ {
+ if (value === 'compact' || value === 'separate') {
+ setSqlEditorQueryControlMode(value)
+ toast.message(
+ `SQL Warehouse demo: ${
+ value === 'compact' ? 'Compact Query dropdown' : 'Separate controls'
+ }`
+ )
+ }
+ }}
+ >
+ {QUERY_CONTROL_MODES.map(({ mode, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx
index 01788bdbf0c..a810e324268 100644
--- a/apps/studio/components/ui/DatabaseSelector.tsx
+++ b/apps/studio/components/ui/DatabaseSelector.tsx
@@ -39,6 +39,7 @@ interface DatabaseSelectorProps {
className?: string
align?: 'start' | 'end'
isForm?: boolean
+ label?: string
}
export const DatabaseSelector = ({
@@ -50,6 +51,7 @@ export const DatabaseSelector = ({
align = 'end',
className,
isForm = false,
+ label = 'Source',
}: DatabaseSelectorProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
@@ -85,9 +87,7 @@ export const DatabaseSelector = ({
) : (
<>
- {isLoading || selectedDatabase?.identifier === projectRef
- ? 'Primary database'
- : 'Read replica'}
+ {isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'}
{isSuccess && selectedDatabase?.identifier !== projectRef && (
@@ -132,7 +132,7 @@ export const DatabaseSelector = ({
)}
>
-
Source
+
{label}
{isLoading ? : selectedDatabaseLabel}
@@ -222,7 +222,7 @@ export const DatabaseSelector = ({
{database.identifier === projectRef
- ? 'Primary database'
+ ? 'Primary'
: `Read replica (${region} - ${id})`}
{database.identifier === selectedDatabaseId &&
}
diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts
index 3718fac1f59..e8861d01686 100644
--- a/apps/studio/data/sql/execute-sql-mutation.ts
+++ b/apps/studio/data/sql/execute-sql-mutation.ts
@@ -11,6 +11,7 @@ import {
calculateSummary,
createNodeTree,
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
+import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
import { handleError as handleErrorFetchers, post } from '@/data/fetchers'
import { MB } from '@/lib/constants'
import { sqlEventParser } from '@/lib/sql-event-parser'
@@ -65,6 +66,7 @@ type ExecuteSqlVariables = {
type ExecuteSqlMutationVariables = ExecuteSqlVariables & {
autoLimit?: number
contextualInvalidation?: boolean
+ warehouseResultSource?: SqlWarehouseResultSource
}
/**
diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts
index 46768b2a979..7c5353d3d0d 100644
--- a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts
+++ b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts
@@ -22,6 +22,19 @@ describe('sqlEditorSessionState', () => {
expect(result?.error).toBeUndefined()
})
+ it('addResult stores Warehouse result metadata', () => {
+ sqlEditorSessionState.addResult('a', [{ x: 1 }], undefined, {
+ type: 'search_path',
+ schema: 'public_warehouse',
+ })
+
+ const result = sqlEditorSessionState.results['a']?.[0]
+ expect(result?.warehouseResultSource).toEqual({
+ type: 'search_path',
+ schema: 'public_warehouse',
+ })
+ })
+
it('addResultError stores the error with empty rows', () => {
sqlEditorSessionState.addResultError('a', { message: 'boom' }, 25)
diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts
index 8cb9618c4fa..46173d49d08 100644
--- a/apps/studio/state/sql-editor/sql-editor-session-state.ts
+++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts
@@ -1,6 +1,14 @@
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
+import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
+
+export interface SqlEditorQueryResult {
+ rows: any[]
+ error?: any
+ autoLimit?: number
+ warehouseResultSource?: SqlWarehouseResultSource
+}
/**
* Ephemeral, per-session SQL editor state that is NOT persisted: query results,
@@ -15,11 +23,7 @@ export const sqlEditorSessionState = proxy({
* a single query with multiple statements can return multiple results.
*/
results: {} as {
- [snippetId: string]: {
- rows: any[]
- error?: any
- autoLimit?: number
- }[]
+ [snippetId: string]: SqlEditorQueryResult[]
},
/** EXPLAIN results, if any, keyed by snippet id. */
@@ -39,17 +43,27 @@ export const sqlEditorSessionState = proxy({
setLimit: (value: number) => (sqlEditorSessionState.limit = value),
- addResult: (id: string, results: any[], autoLimit?: number) => {
+ addResult: (
+ id: string,
+ results: any[],
+ autoLimit?: number,
+ warehouseResultSource?: SqlWarehouseResultSource
+ ) => {
// Use ref() to prevent Valtio from creating proxies for each row object.
// This is critical for large result sets - without ref(), Valtio wraps every
// row and nested property in a Proxy, causing massive memory overhead.
// Alright to use ref() in this case as the data is meant to be read-only and we
// don't need to track changes to the underlying data
- sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit }]
+ sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit, warehouseResultSource }]
},
- addResultError: (id: string, error: any, autoLimit?: number) => {
- sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit }]
+ addResultError: (
+ id: string,
+ error: any,
+ autoLimit?: number,
+ warehouseResultSource?: SqlWarehouseResultSource
+ ) => {
+ sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit, warehouseResultSource }]
},
resetResult: (id: string) => {