Files
supabase/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
T
Charis 41bfd35aba fix: write queries in ai assistant (#37938)
Some read-only errors from running SQL in the AI Assistant aren't being
properly detected, so the action item to review and confirm running the
SQL isn't showing up. Add test against another variant of read-only
error message that can show up.
2025-08-15 11:15:34 +07:00

467 lines
17 KiB
TypeScript

import dayjs from 'dayjs'
import { Code, Play } from 'lucide-react'
import { DragEvent, ReactNode, useEffect, useMemo, useState } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts'
import { toast } from 'sonner'
import { useParams } from 'common'
import { ReportBlockContainer } from 'components/interfaces/Reports/ReportBlock/ReportBlockContainer'
import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
import { usePrimaryDatabase } from 'data/read-replicas/replicas-query'
import { type QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { type Parameter, parseParameters } from 'lib/sql-parameters'
import type { Dashboards } from 'types'
import { ChartContainer, ChartTooltipContent, cn, CodeBlock, SQL_ICON } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { ButtonTooltip } from '../ButtonTooltip'
import { CHART_COLORS } from '../Charts/Charts.constants'
import SqlWarningAdmonition from '../SqlWarningAdmonition'
import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
import { ParametersPopover } from './ParametersPopover'
import { getCumulativeResults } from './QueryBlock.utils'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
cumulative: false,
xKey: '',
yKey: '',
showLabels: false,
showGrid: false,
view: 'table',
}
interface QueryBlockProps {
/** Applicable if SQL is a snippet that's already saved (Used in Reports) */
id?: string
/** Title of the QueryBlock */
label: string
/** SQL query to render/run in the QueryBlock */
sql?: string
/** Configuration of the output chart based on the query result */
chartConfig?: ChartConfig
/** Not implemented yet: Will be the next part of ReportsV2 */
parameterValues?: Record<string, string>
/** Any other actions specific to the parent to be rendered in the header */
actions?: ReactNode
/** Toggle visiblity of SQL query on render */
showSql?: boolean
/** Indicate if SQL query can be rendered as a chart */
isChart?: boolean
/** For Assistant as QueryBlock is rendered while streaming response */
isLoading?: boolean
/** Override to prevent running the SQL query provided */
runQuery?: boolean
/** Prevent updating of columns for X and Y axes in the chart view */
lockColumns?: boolean
/** Max height set to render results / charts (Defaults to 250) */
maxHeight?: number
/** Whether query block is draggable */
draggable?: boolean
/** Tooltip when hovering over the header of the block (Used in Assistant Panel) */
tooltip?: ReactNode
/** Optional: Any initial results to render as part of the query*/
results?: any[]
/** Opt to show run button if query is not read only */
showRunButtonIfNotReadOnly?: boolean
/** Not implemented yet: Will be the next part of ReportsV2 */
onSetParameter?: (params: Parameter[]) => void
/** Optional callback the SQL query is run */
onRunQuery?: (queryType: 'select' | 'mutation') => void
/** Optional callback on drag start */
onDragStart?: (e: DragEvent<Element>) => void
/** Optional: callback when the results are returned from running the SQL query*/
onResults?: (results: any[]) => void
// [Joshen] Params below are currently only used by ReportsV2 (Might revisit to see how to improve these)
/** Optional height set to render the SQL query (Used in Reports) */
queryHeight?: number
/** UI to render if there's a read-only error while running the query */
readOnlyErrorPlaceholder?: ReactNode
/** UI to render if there's no query results (Used in Reports) */
noResultPlaceholder?: ReactNode
/** To trigger a refresh of the query */
isRefreshing?: boolean
/** Optional callback whenever a chart configuration is updated (Used in Reports) */
onUpdateChartConfig?: ({
chart,
chartConfig,
}: {
chart?: Partial<Dashboards.Chart>
chartConfig: Partial<ChartConfig>
}) => void
}
// [Joshen ReportsV2] JFYI we may adjust this in subsequent PRs when we implement this into Reports V2
// First iteration here is just to make this work with the AI Assistant first
export const QueryBlock = ({
id,
label,
sql,
chartConfig = DEFAULT_CHART_CONFIG,
maxHeight = 250,
queryHeight,
parameterValues: extParameterValues,
actions,
showSql: _showSql = false,
isChart = false,
isLoading = false,
runQuery = false,
lockColumns = false,
draggable = false,
isRefreshing = false,
noResultPlaceholder = null,
readOnlyErrorPlaceholder = null,
showRunButtonIfNotReadOnly = false,
tooltip,
results,
onRunQuery,
onSetParameter,
onUpdateChartConfig,
onDragStart,
onResults,
}: QueryBlockProps) => {
const { ref } = useParams()
const [chartSettings, setChartSettings] = useState<ChartConfig>(chartConfig)
const { xKey, yKey, view = 'table' } = chartSettings
const [showSql, setShowSql] = useState(_showSql)
const [readOnlyError, setReadOnlyError] = useState(false)
const [queryError, setQueryError] = useState<QueryResponseError>()
const [queryResult, setQueryResult] = useState<any[] | undefined>(results)
const [focusDataIndex, setFocusDataIndex] = useState<number>()
const formattedQueryResult = useMemo(() => {
// Make sure Y axis values are numbers
return queryResult?.map((row) => {
return Object.fromEntries(
Object.entries(row).map(([key, value]) => {
if (key === yKey) return [key, Number(value)]
else return [key, value]
})
)
})
}, [queryResult, yKey])
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
const parameters = useMemo(() => {
if (!sql) return []
return parseParameters(sql)
}, [sql])
// [Joshen] This is for when we introduced the concept of parameters into our reports
// const combinedParameterValues = { ...extParameterValues, ...parameterValues }
const { database: primaryDatabase } = usePrimaryDatabase({ projectRef: ref })
const postgresConnectionString = primaryDatabase?.connectionString
const readOnlyConnectionString = primaryDatabase?.connection_string_read_only
const chartData = chartSettings.cumulative
? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartSettings)
: formattedQueryResult
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: (data) => {
onResults?.(data.result)
setQueryResult(data.result)
setReadOnlyError(false)
setQueryError(undefined)
},
onError: (error) => {
const readOnlyTransaction = /cannot execute .+ in a read-only transaction/.test(error.message)
const permissionDenied = error.message.includes('permission denied')
const notOwner = error.message.includes('must be owner')
if (readOnlyTransaction || permissionDenied || notOwner) {
setReadOnlyError(true)
if (showRunButtonIfNotReadOnly) setShowWarning('hasWriteOperation')
} else {
setQueryError(error)
}
},
})
const getDateFormat = (key: any) => {
const value = chartData?.[0]?.[key] || ''
if (typeof value === 'number') return 'number'
if (dayjs(value).isValid()) return 'date'
return 'string'
}
const xKeyDateFormat = getDateFormat(xKey)
const handleExecute = () => {
if (!sql || isLoading) return
if (readOnlyError) {
return setShowWarning('hasWriteOperation')
}
try {
// [Joshen] This is for when we introduced the concept of parameters into our reports
// const processedSql = processParameterizedSql(sql, combinedParameterValues)
execute({
projectRef: ref,
connectionString: readOnlyConnectionString,
sql,
})
} catch (error: any) {
toast.error(`Failed to execute query: ${error.message}`)
}
}
useEffect(() => {
setChartSettings(chartConfig)
}, [chartConfig])
// Run once on mount to parse parameters and notify parent
useEffect(() => {
if (!!sql && onSetParameter) {
const params = parseParameters(sql)
onSetParameter(params)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sql])
useEffect(() => {
if (!!sql && !isLoading && runQuery && !!readOnlyConnectionString && !readOnlyError) {
handleExecute()
}
}, [sql, isLoading, runQuery, readOnlyConnectionString])
useEffect(() => {
if (isRefreshing) handleExecute()
}, [isRefreshing])
return (
<ReportBlockContainer
draggable={draggable}
showDragHandle={draggable}
tooltip={tooltip}
loading={isExecuting}
onDragStart={(e: DragEvent<Element>) => onDragStart?.(e)}
icon={
<SQL_ICON
size={18}
strokeWidth={1.5}
className={cn(
'transition-colors fill-foreground-muted group-aria-selected:fill-foreground',
'w-5 h-5 shrink-0 grow-0 -ml-0.5'
)}
/>
}
label={label}
actions={
<>
<ButtonTooltip
type="text"
size="tiny"
className="w-7 h-7"
icon={<Code size={14} strokeWidth={1.5} />}
onClick={() => setShowSql(!showSql)}
tooltip={{
content: { side: 'bottom', text: showSql ? 'Hide query' : 'Show query' },
}}
/>
{queryResult && (
<>
{/* [Joshen ReportsV2] Won't see this just yet as this is intended for Reports V2 */}
{parameters.length > 0 && (
<ParametersPopover
parameters={parameters}
parameterValues={parameterValues}
onSubmit={setParameterValues}
/>
)}
{isChart && (
<BlockViewConfiguration
view={view}
isChart={isChart}
lockColumns={lockColumns}
chartConfig={chartSettings}
columns={Object.keys(queryResult[0] || {})}
changeView={(view) => {
if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { view } })
setChartSettings({ ...chartSettings, view })
}}
updateChartConfig={(config) => {
if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: config })
setChartSettings(config)
}}
/>
)}
</>
)}
<EditQueryButton id={id} title={label} sql={sql} />
{(showRunButtonIfNotReadOnly || !readOnlyError) && (
<ButtonTooltip
type="text"
size="tiny"
className="w-7 h-7"
icon={<Play size={14} strokeWidth={1.5} />}
loading={isExecuting || isLoading}
disabled={isLoading}
onClick={() => {
handleExecute()
if (!!sql) onRunQuery?.('select')
}}
tooltip={{
content: {
side: 'bottom',
className: 'max-w-56 text-center',
text: isExecuting ? (
<p>{`Query is running. You may cancel ongoing queries via the [SQL Editor](/project/${ref}/sql?viewOngoingQueries=true).`}</p>
) : (
'Run query'
),
},
}}
/>
)}
{actions}
</>
}
>
{!!showWarning && (
<SqlWarningAdmonition
warningType={showWarning}
className="border-b"
onCancel={() => setShowWarning(undefined)}
onConfirm={() => {
// [Joshen] This is for when we introduced the concept of parameters into our reports
// const processedSql = processParameterizedSql(sql!, combinedParameterValues)
if (sql) {
setShowWarning(undefined)
execute({
projectRef: ref,
connectionString: postgresConnectionString,
sql,
})
onRunQuery?.('mutation')
}
}}
disabled={!sql}
/>
)}
{isExecuting && queryResult === undefined && (
<div className="p-3 w-full">
<ShimmeringLoader />
</div>
)}
{showSql && (
<div
className={cn('shrink-0 w-full max-h-96 overflow-y-auto', {
'border-b': queryResult !== undefined,
})}
style={{ height: !!queryHeight ? `${queryHeight}px` : undefined }}
>
<CodeBlock
hideLineNumbers
wrapLines={false}
value={sql}
language="sql"
className={cn(
'max-w-none block !bg-transparent !py-3 !px-3.5 prose dark:prose-dark border-0 text-foreground !rounded-none w-full',
'[&>code]:m-0 [&>code>span]:text-foreground'
)}
/>
</div>
)}
{view === 'chart' && queryResult !== undefined ? (
<>
{(queryResult ?? []).length === 0 ? (
<div className="flex w-full h-full items-center justify-center py-3">
<p className="text-foreground-light text-xs">No results returned from query</p>
</div>
) : !xKey || !yKey ? (
<div className="flex w-full h-full items-center justify-center">
<p className="text-foreground-light text-xs">Select columns for the X and Y axes</p>
</div>
) : (
<div className="flex-1 w-full">
<ChartContainer
className="aspect-auto px-3 py-2"
style={{
height: maxHeight ? `${maxHeight}px` : undefined,
minHeight: maxHeight ? `${maxHeight}px` : undefined,
}}
>
<BarChart
accessibilityLayer
margin={{ left: -20, right: 0, top: 10 }}
data={chartData}
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
}}
onMouseLeave={() => setFocusDataIndex(undefined)}
>
<CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />
<XAxis
dataKey={xKey}
tickLine={{ stroke: CHART_COLORS.AXIS }}
axisLine={{ stroke: CHART_COLORS.AXIS }}
interval="preserveStartEnd"
tickMargin={4}
minTickGap={32}
tickFormatter={(value) =>
xKeyDateFormat === 'date' ? dayjs(value).format('MMM D YYYY HH:mm') : value
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={4} />
<Tooltip content={<ChartTooltipContent className="w-[150px]" />} />
<Bar radius={1} dataKey={yKey}>
{chartData?.map((_: any, index: number) => (
<Cell
key={`cell-${index}`}
className="transition-all duration-100"
fill="hsl(var(--chart-1))"
opacity={focusDataIndex === undefined || focusDataIndex === index ? 1 : 0.4}
enableBackground={12}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
</div>
)}
</>
) : (
<>
{!isExecuting && !!queryError ? (
<div
className={cn('flex-1 w-full overflow-auto relative border-t px-3.5 py-2')}
style={{ maxHeight: maxHeight ? `${maxHeight}px` : undefined }}
>
<span className="font-mono text-xs">ERROR: {queryError.message}</span>
</div>
) : queryResult ? (
<div
className={cn('flex-1 w-full overflow-auto relative')}
style={{ maxHeight: maxHeight ? `${maxHeight}px` : undefined }}
>
<Results rows={queryResult} />
</div>
) : !isExecuting ? (
readOnlyError ? (
readOnlyErrorPlaceholder
) : (
noResultPlaceholder
)
) : null}
</>
)}
</ReportBlockContainer>
)
}