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 && ( + <> +
+

Query context

+ +
+ + + )} + + 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 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) => {