This commit is contained in:
Danny White committed 2026-07-03 17:27:56 -06:00
1 parent 3e73fca908
commit 0f37d29f63
13 files changed
+653 -146

No files matched your search

@@ -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
)}
>
<div className="flex items-center gap-1">
@@ -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,
]
)
@@ -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<HTMLDivElement>(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 (
<div className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100">
<div
ref={queryBarRef}
className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100 @container"
>
<div className="flex min-w-0 items-center">{IS_PLATFORM && <SavingIndicator id={id} />}</div>
<div className="flex items-center gap-x-2">
@@ -94,7 +151,23 @@ export function SqlEditorQueryBar({
aria-label="Query options"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuContent align="end" className="w-72">
{shouldShowCollapsedQueryControls && (
<>
<div className="space-y-1 px-1 py-1.5">
<p className="px-2 py-1 text-xs text-foreground-lighter">Query context</p>
<SeparateQueryControls
onSelectDatabase={onSelectDatabase}
selectedDatabaseId={lastSelectedDb}
fullWidth
/>
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Actions
</DropdownMenuLabel>
<DropdownMenuItem className="justify-between" onClick={prettifyQuery}>
<span className="flex items-center gap-x-2">
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
@@ -102,40 +175,39 @@ export function SqlEditorQueryBar({
</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</DropdownMenuItem>
{IS_PLATFORM && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={toggleFavorite}>
<Heart
size={14}
strokeWidth={2}
className={
isFavorite ? 'fill-brand stroke-none' : 'fill-none stroke-foreground-light'
}
/>
{isFavorite ? 'Remove from' : 'Add to'} favorites
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
<span className="flex items-center gap-x-2">
<Keyboard size={14} className="text-foreground-light" />
IntelliSense enabled
</span>
{intelliSenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Preferences
</DropdownMenuLabel>
{IS_PLATFORM && (
<DropdownMenuCheckboxItem checked={isFavorite} onCheckedChange={toggleFavorite}>
Favourite query
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={intelliSenseEnabled}
onCheckedChange={handleIntelliSenseChange}
>
IntelliSense
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{IS_PLATFORM && (
<DatabaseSelector
selectedDatabaseId={lastSelectedDb.length === 0 ? undefined : lastSelectedDb}
onSelectId={onSelectDatabase}
{isSeparateQueryControlMode ? (
<div className="hidden items-center gap-x-2 @4xl:flex">
<SeparateQueryControls
onSelectDatabase={onSelectDatabase}
selectedDatabaseId={lastSelectedDb}
/>
</div>
) : (
<QueryContextDropdown
currentRole={currentRole}
onSelectDatabase={onSelectDatabase}
searchPath={searchPath}
selectedDatabaseId={lastSelectedDb}
/>
)}
<SqlEditorSearchPathSelector />
<RoleImpersonationPopover serviceRoleLabel="postgres" header="Run SQL query as a role" />
<SqlEditorLimitSelector />
<SqlRunButton
hasSelection={hasSelection}
@@ -147,3 +219,110 @@ export function SqlEditorQueryBar({
</div>
)
}
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 && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
onSelectId={onSelectDatabase}
className={controlClassName}
align="end"
/>
)}
<SqlEditorSearchPathSelector className={controlClassName} align="end" />
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
align="end"
className={fullWidth ? 'w-full justify-start [&>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 (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button type="button" size="tiny" variant="default" className="h-[26px] justify-start pr-3">
<div className="flex min-w-0 items-center gap-1">
<span className="text-foreground-muted">Query</span>
{contextSummary !== undefined && (
<span className="hidden max-w-40 truncate text-foreground @2xl:inline">
{contextSummary}
</span>
)}
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end" side="bottom">
<div className="border-b px-4 py-3">
<p className="text-sm text-foreground">Query context</p>
<p className="text-xs leading-snug text-foreground-light">
These settings apply together when this query runs.
</p>
</div>
<div className="space-y-1 p-2">
{IS_PLATFORM && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
onSelectId={onSelectDatabase}
className="w-full [&>span]:w-full"
align="end"
label="Connection"
/>
)}
<SqlEditorSearchPathSelector className="w-full [&>span]:w-full" align="end" />
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
align="end"
className="w-full justify-start [&>span]:w-full"
/>
</div>
</PopoverContent>
</Popover>
)
}
@@ -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 (
<DropdownMenu>
@@ -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)}
>
<div className="flex min-w-0 items-center gap-1">
<span className="text-foreground-muted">Search path</span>
<span
className={cn(
'max-w-32 truncate font-mono',
isWarehousePath ? 'text-brand-link' : 'text-foreground'
)}
>
{selectedOption.label}
</span>
<span className="max-w-32 truncate text-foreground">{selectedOption.label}</span>
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-72" align="end">
<DropdownMenuContent className="w-72" align={align}>
<DropdownMenuLabel className="flex flex-col gap-1">
<span>Search path</span>
<span className="text-xs font-normal leading-snug text-foreground-light">
Unqualified table names resolve against this schema first. Custom represents a
multi-schema path.
Choose where your query looks first for unqualified table names.{' '}
<a
href="https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH"
target="_blank"
rel="noreferrer"
className="text-foreground underline decoration-foreground-lighter underline-offset-2 hover:decoration-foreground"
>
Learn more
</a>
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={selectedOption.value} onValueChange={setSearchPath}>
{SEARCH_PATH_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span
className={cn(
'truncate',
option.value.endsWith('_warehouse') ? 'text-brand-link' : undefined
)}
{standardSchemaOptions.length > 0 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
{warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'}
</DropdownMenuLabel>
{visibleStandardSchemaOptions.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description !== undefined && (
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
)}
</div>
</DropdownMenuRadioItem>
))}
{hiddenStandardSchemaCount > 0 && (
<DropdownMenuItem
className="text-foreground-light"
onSelect={(event) => {
event.preventDefault()
setIsShowingMoreSchemas(true)
}}
>
{option.label}
</span>
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
</div>
</DropdownMenuRadioItem>
))}
Show {hiddenStandardSchemaCount} more schema
{hiddenStandardSchemaCount === 1 ? '' : 's'}
</DropdownMenuItem>
)}
</>
)}
{warehouseSchemaOptions.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Warehouse schemas
</DropdownMenuLabel>
{warehouseSchemaOptions.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description !== undefined && (
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
)}
</div>
</DropdownMenuRadioItem>
))}
</>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -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<SqlWarehouseHealthPreset, string> = {
healthy: '',
catching_up: 'Catching up',
degraded: 'Degraded',
error: 'Error',
}
const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record<SqlWarehouseHealthPreset, string> = {
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<SqlWarehouseHealthPreset, string> = {
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 (
<span className="inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
<Tooltip>
<TooltipTrigger asChild>
<span className="group inline-flex cursor-default text-foreground-light transition-colors hover:text-foreground">
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
Warehouse
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{getWarehouseResultSourceTooltip(source)}
</TooltipContent>
</Tooltip>
{shouldShowHealth && (
<>
<span className="shrink-0 text-foreground-muted">·</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`group inline-flex cursor-default transition-colors ${SQL_WAREHOUSE_HEALTH_CLASS_NAMES[health]}`}
>
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
{SQL_WAREHOUSE_HEALTH_LABELS[health]}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]}
</TooltipContent>
</Tooltip>
</>
)}
</span>
)
}
@@ -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<ResultsHealth, string> = {
complete: 'Complete',
limited: 'Limited',
}
const RESULTS_HEALTH_CLASS_NAME: Record<ResultsHealth, string> = {
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 (
<p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-foreground-lighter">
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'group inline-flex cursor-default transition-colors',
RESULTS_HEALTH_CLASS_NAME[health]
)}
>
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
{RESULTS_HEALTH_LABEL[health]}
</span>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{health === 'limited' ? (
<div className="flex flex-col gap-y-1">
<span>
Results are limited to preserve browser performance when a query returns many rows.
{hasReachedLimit && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="group inline-flex cursor-default text-warning transition-colors hover:text-warning-600">
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
Limited
</span>
</span>
<span className="text-foreground-light">
Change or remove this limit from the dropdown near Run.
</span>
</div>
) : (
'Query completed without applying the SQL Editor row limit.'
)}
</TooltipContent>
</Tooltip>
<span className="shrink-0 text-foreground-muted">·</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="flex flex-col gap-y-1">
<span>
Results are limited to preserve browser performance when a query returns many
rows.
</span>
<span className="text-foreground-light">
Change or remove this limit from the dropdown near Run.
</span>
</div>
</TooltipContent>
</Tooltip>
<span className="shrink-0 text-foreground-muted">·</span>
</>
)}
<span className="truncate text-foreground">
{formatRowCount(rowCount)}
{autoLimit !== undefined && (
{hasReachedLimit && (
<span className="ml-1 text-foreground-lighter">(limit {autoLimit.toLocaleString()})</span>
)}
</span>
@@ -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 = ({
<div className="flex min-w-0 items-center gap-x-3">
{showResultsSummary && (
<SqlResultsSummary rowCount={result.rows.length} autoLimit={result.autoLimit} />
<>
{result.warehouseResultSource && (
<SqlWarehouseResultStatus source={result.warehouseResultSource} />
)}
<SqlResultsSummary rowCount={result.rows.length} autoLimit={result.autoLimit} />
</>
)}
{result?.rows && (
@@ -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
</Link>
</DropdownMenuItem>
<SqlEditorWarehouseDemoMenu />
<DropdownMenuSeparator />
</DropdownMenuGroup>
</>
@@ -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 (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex gap-2">
<Database size={14} strokeWidth={1.5} className="text-foreground-lighter" />
SQL Warehouse demo
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuLabel className="text-xs text-foreground-lighter">
Results health
</DropdownMenuLabel>
{WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => (
<DropdownMenuItem
key={preset}
className="cursor-pointer"
onClick={() => {
setSqlEditorWarehouseHealth(preset)
toast.message(`SQL Warehouse demo: ${label}`)
}}
>
{label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-foreground-lighter">
Query control
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={snap.queryControlMode}
onValueChange={(value) => {
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 }) => (
<DropdownMenuRadioItem key={mode} value={mode} className="cursor-pointer">
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)
}
@@ -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 = ({
) : (
<>
<span className="capitalize">
{isLoading || selectedDatabase?.identifier === projectRef
? 'Primary database'
: 'Read replica'}
{isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'}
</span>
{isSuccess && selectedDatabase?.identifier !== projectRef && (
<span>
@@ -132,7 +132,7 @@ export const DatabaseSelector = ({
)}
>
<div className="flex items-center gap-1">
<span className="text-foreground-muted">Source</span>
<span className="text-foreground-muted">{label}</span>
<span className="flex items-center gap-1">
{isLoading ? <Loader2 className="animate-spin" size={12} /> : selectedDatabaseLabel}
</span>
@@ -222,7 +222,7 @@ export const DatabaseSelector = ({
<div className="w-full flex items-center justify-between">
<p>
{database.identifier === projectRef
? 'Primary database'
? 'Primary'
: `Read replica (${region} - ${id})`}
</p>
{database.identifier === selectedDatabaseId && <Check size={16} />}
@@ -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
}
/**
@@ -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)
@@ -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) => {