mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
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.
467 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|