database tests

This commit is contained in:
Saxon Fletcher committed 2025-07-23 13:06:58 +10:00
1 parent 9f1b5ae1cc
commit dfdca8670c
16 files changed
+1300 -8

No files matched your search

@@ -0,0 +1,193 @@
import dayjs from 'dayjs'
import { MoreVertical, Play, Trash } from 'lucide-react'
import { forwardRef, useImperativeHandle, useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
TableCell,
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DatabaseTest } from 'data/database-tests/database-tests-query'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useDatabaseTestDeleteMutation } from 'data/database-tests/database-test-delete-mutation'
export interface TestRowHandle {
runTest: () => Promise<{ error: any | null }>
setStatus: (status: 'queued' | 'passed' | 'failed' | undefined) => void
setIsRunning: (running: boolean) => void
}
interface TestRowProps {
test: DatabaseTest
prependQuery?: DatabaseTest | null
canRun?: boolean
onSelectTest: (test: DatabaseTest) => void
}
// Utility function to validate test query format
const isValidTestQuery = (query: string): boolean => {
const trimmedQuery = query.trim().toLowerCase()
return trimmedQuery.startsWith('begin;') && trimmedQuery.endsWith('rollback;')
}
const TestRow = forwardRef<TestRowHandle, TestRowProps>(
({ test, prependQuery, canRun = false, onSelectTest }, ref) => {
const project = useSelectedProject()
const [isRunning, setIsRunning] = useState(false)
const [status, setStatus] = useState<'queued' | 'passed' | 'failed' | undefined>()
const [lastRun, setLastRun] = useState<string | undefined>()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const { mutateAsync: runTestMutation } = useExecuteSqlMutation()
const { mutate: deleteTest, isLoading: isDeleting } = useDatabaseTestDeleteMutation({
onSuccess: () => {
setIsDeleteModalOpen(false)
},
})
const isTestQueryValid = isValidTestQuery(test.query)
const handleRunTest = async () => {
if (!isTestQueryValid) {
toast.error(
`Test "${test.name}" has invalid format. Must start with BEGIN; and end with ROLLBACK;`
)
return { error: new Error('Invalid test format') }
}
setIsRunning(true)
setStatus(undefined)
try {
// Determine the SQL to run
let sqlToRun = test.query
// If a setup test exists, prepend it to regular tests
if (prependQuery && canRun) {
sqlToRun = `${prependQuery.query}\n\n${test.query}`
}
const res = await runTestMutation({
projectRef: project?.ref,
connectionString: project?.connectionString,
sql: sqlToRun,
})
const { result } = res
console.log('result', res)
const hasFailedTests = result.some((r: any) => r.finish?.includes('failed'))
if (hasFailedTests) {
toast.error(`Test "${test.name}" failed.`)
setStatus('failed')
} else {
toast.success(`Test "${test.name}" passed!`)
setStatus('passed')
}
setLastRun(new Date().toISOString())
return { error: null }
} catch (error: any) {
toast.error(`Failed to run test "${test.name}": ${error.message}`)
setStatus('failed')
setLastRun(new Date().toISOString())
return { error }
} finally {
setIsRunning(false)
}
}
useImperativeHandle(ref, () => ({
runTest: handleRunTest,
setStatus,
setIsRunning,
}))
const onDelete = () => {
if (!project) return
deleteTest({ projectRef: project.ref, id: test.id })
}
const getStatusBadge = () => {
if (isRunning) {
return <Badge variant="outline">Running</Badge>
}
if (status === 'queued') {
return <Badge variant="outline">Queued</Badge>
}
if (status === 'passed') {
return <Badge variant="success">Passed</Badge>
}
if (status === 'failed') {
return <Badge variant="destructive">Failed</Badge>
}
return <Badge variant="outline">Not run</Badge>
}
return (
<>
<TableRow key={test.id}>
<TableCell>
<p className="text-sm cursor-pointer" onClick={() => onSelectTest(test)}>
{test.name}
</p>
</TableCell>
<TableCell>{getStatusBadge()}</TableCell>
<TableCell>{lastRun ? dayjs(lastRun).fromNow() : 'Never'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{canRun && (
<ButtonTooltip
type="default"
icon={<Play />}
loading={isRunning}
disabled={isRunning || status === 'queued' || !isTestQueryValid}
onClick={handleRunTest}
tooltip={{
content: {
side: 'bottom',
text: !isTestQueryValid
? 'Test query must start with BEGIN; and end with ROLLBACK;'
: undefined,
},
}}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setIsDeleteModalOpen(true)}>
<Trash size={14} />
<span className="ml-2">Remove test</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
<ConfirmationModal
visible={isDeleteModalOpen}
title="Delete test"
confirmLabel="Delete test"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={onDelete}
loading={isDeleting}
>
<p className="text-sm text-foreground-light">
Are you sure you want to delete the test "{test.name}"? This action cannot be undone.
</p>
</ConfirmationModal>
</>
)
}
)
TestRow.displayName = 'TestRow'
export default TestRow
@@ -0,0 +1,311 @@
import { partition } from 'lodash'
import { useRouter } from 'next/router'
import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Search, MoreVertical, Trash } from 'lucide-react'
import {
Alert,
Button,
Card,
CardHeader,
CardTitle,
Input,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
TableCell,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseTestsQuery } from 'data/database-tests/database-tests-query'
import { DatabaseTest } from 'data/database-tests/database-tests-query'
import { useDatabaseTestDeleteMutation } from 'data/database-tests/database-test-delete-mutation'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { toast } from 'sonner'
import TestRow, { TestRowHandle } from './TestRow'
// Utility function to validate test query format
const isValidTestQuery = (query: string): boolean => {
const trimmedQuery = query.trim().toLowerCase()
return trimmedQuery.startsWith('begin;') && trimmedQuery.endsWith('rollback;')
}
// Simple component for setup test rows - just name and actions
interface SetupTestRowProps {
test: DatabaseTest
onSelectTest: (test: DatabaseTest) => void
}
const SetupTestRow = ({ test, onSelectTest }: SetupTestRowProps) => {
const project = useSelectedProject()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const { mutate: deleteTest, isLoading: isDeleting } = useDatabaseTestDeleteMutation({
onSuccess: () => {
setIsDeleteModalOpen(false)
},
})
const onDelete = () => {
if (!project) return
deleteTest({ projectRef: project.ref, id: test.id })
}
return (
<div className="w-full flex justify-between items-center p-4">
<p className="text-sm cursor-pointer w-full" onClick={() => onSelectTest(test)}>
{test.name}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setIsDeleteModalOpen(true)}>
<Trash size={14} />
<span className="ml-2">Remove test</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
visible={isDeleteModalOpen}
title="Delete test"
confirmLabel="Delete test"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={onDelete}
loading={isDeleting}
>
<p className="text-sm text-foreground-light">
Are you sure you want to delete the test "{test.name}"? This action cannot be undone.
</p>
</ConfirmationModal>
</div>
)
}
export interface TestsListHandle {
runAllTests: () => void
}
interface DatabaseTestsListProps {
onSelectTest: (test: DatabaseTest) => void
}
const DatabaseTestsList = forwardRef<TestsListHandle, DatabaseTestsListProps>(
({ onSelectTest }, ref) => {
const router = useRouter()
const [search, setSearch] = useState('')
const testRowRefs = useRef<{ [key: string]: TestRowHandle | null }>({})
const {
data: tests,
isLoading,
isSuccess,
isError,
error,
} = useDatabaseTestsQuery({
projectRef: router.query.ref as string,
})
// Split tests into setup and regular groups
const { setupTests, regularTests, combinedSetupQuery } = useMemo(() => {
if (!tests) return { setupTests: [], regularTests: [], combinedSetupQuery: null }
const setupTests = tests.filter((test) => test.name.startsWith('000'))
const regularTests = tests.filter((test) => !test.name.startsWith('000'))
// Sort setup tests alphabetically to ensure consistent order
const sortedSetupTests = setupTests.sort((a, b) => a.name.localeCompare(b.name))
// Combine all setup test queries into a single query
const combinedSetupQuery =
sortedSetupTests.length > 0
? ({
id: 'combined-setup',
name: 'Combined Setup Tests',
query: sortedSetupTests.map((test) => test.query).join('\n\n'),
} as DatabaseTest)
: null
return {
setupTests: sortedSetupTests,
regularTests,
combinedSetupQuery,
}
}, [tests])
// Filter both groups based on search
const filteredSetupTests = useMemo(() => {
if (search.length === 0) return setupTests
return setupTests.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
}, [setupTests, search])
const filteredRegularTests = useMemo(() => {
if (search.length === 0) return regularTests
return regularTests.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
}, [regularTests, search])
const runAllTests = async () => {
const allTests = [...filteredSetupTests, ...filteredRegularTests]
if (allTests.length === 0) {
toast.error('No tests to run.')
return
}
// Filter out invalid tests
const validTests = allTests.filter((test) => isValidTestQuery(test.query))
const invalidTests = allTests.filter((test) => !isValidTestQuery(test.query))
if (invalidTests.length > 0) {
toast.error(
`${invalidTests.length} test(s) skipped - must start with BEGIN; and end with ROLLBACK;`
)
}
if (validTests.length === 0) {
toast.error('No valid tests to run.')
return
}
// Always run setup test first, then regular tests alphabetically
const validSetupTests = validTests.filter((test) => test.name.startsWith('000'))
const validRegularTests = validTests.filter((test) => !test.name.startsWith('000'))
const sortedRegularTests = [...validRegularTests].sort((a, b) => a.name.localeCompare(b.name))
const sortedTests = [...validSetupTests, ...sortedRegularTests]
// Set all tests to queued status initially
sortedTests.forEach((test) => {
testRowRefs.current[test.id]?.setStatus('queued')
})
let passedCount = 0
let failedCount = 0
// Run tests sequentially
for (const test of sortedTests) {
const testRef = testRowRefs.current[test.id]
if (testRef) {
// Clear queued status and run the test
testRef.setStatus(undefined)
const result = await testRef.runTest()
if (result?.error) {
failedCount++
} else {
passedCount++
}
}
}
// Show final results
if (failedCount > 0) {
toast.error(`${failedCount} out of ${validTests.length} tests failed.`)
} else {
toast.success(`All ${validTests.length} tests passed!`)
}
}
useImperativeHandle(ref, () => ({
runAllTests,
}))
// Pass combined setup query to child components for prepending
const setupTest = combinedSetupQuery
const renderTestTable = (tests: DatabaseTest[], isSetupSection = false) => {
if (isSetupSection) {
// Simplified view for setup tests - just name and actions
return (
<div className="w-full">
{tests.length === 0 ? (
<p>
No setup tests found. Create tests with names starting with "000" to set up test
environment.
</p>
) : (
tests.map((test) => (
<SetupTestRow key={test.id} test={test} onSelectTest={onSelectTest} />
))
)}
</div>
)
}
// Regular tests view with full TestRow component
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Run</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tests.map((test) => (
<TestRow
ref={(el) => (testRowRefs.current[test.id] = el)}
key={test.id}
test={test}
prependQuery={setupTest}
canRun={true}
onSelectTest={onSelectTest}
/>
))}
</TableBody>
</Table>
)
}
return (
<div className="flex flex-col h-full space-y-4">
{isLoading && <ShimmeringLoader />}
{isError && (
<div className="p-4">
<Alert variant="danger" title="Failed to fetch database tests">
{(error as any)?.message}
</Alert>
</div>
)}
{isSuccess && (
<>
{filteredSetupTests.length === 0 &&
filteredRegularTests.length === 0 &&
setupTests.length === 0 && (
<div className="p-4 text-center text-sm text-foreground-light">No tests found.</div>
)}
{/* Setup Tests Group - Always show */}
<Card>
<CardHeader className="px-4">
<CardTitle className="text-sm font-medium">Before Tests</CardTitle>
</CardHeader>
{renderTestTable(filteredSetupTests, true)}
</Card>
{/* Regular Tests Group */}
{filteredRegularTests.length > 0 && (
<Card>
<CardHeader className="px-4">
<CardTitle className="text-sm font-medium">Tests</CardTitle>
</CardHeader>
{renderTestTable(filteredRegularTests, false)}
</Card>
)}
</>
)}
</div>
)
}
)
DatabaseTestsList.displayName = 'DatabaseTestsList'
export default DatabaseTestsList
@@ -52,6 +52,13 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
route: `/project/${ref}/database/functions`,
defaultHidden: true,
},
{
id: 'nav-database-tests',
name: 'Tests',
value: 'Database: Tests',
route: `/project/${ref}/database/tests`,
defaultHidden: true,
},
{
id: 'nav-database-extensions',
name: 'Extensions',
@@ -39,6 +39,12 @@ export const generateDatabaseMenu = (
url: `/project/${ref}/database/triggers`,
items: [],
},
{
name: 'Tests',
key: 'tests',
url: `/project/${ref}/database/tests`,
items: [],
},
{
name: 'Enumerated Types',
key: 'types',
@@ -101,8 +101,8 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => {
}
}
if (editorPanel.onSave) {
editorPanel.onSave(currentValue)
if (editorPanel.onRun) {
editorPanel.onRun(currentValue)
}
executeSql({
@@ -371,6 +371,11 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => {
)}
<div className="bg-surface-100 flex items-center gap-2 justify-end px-5 py-4 w-full border-t shrink-0">
{editorPanel.onSave && (
<Button type="default" onClick={() => editorPanel.onSave(currentValue)}>
Save
</Button>
)}
<SqlRunButton isDisabled={isExecuting} isExecuting={isExecuting} onClick={onExecuteSql} />
</div>
</div>
@@ -1,4 +1,4 @@
import { Code, Play } from 'lucide-react'
import { Code, Play, Edit, MoreVertical } 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'
@@ -7,20 +7,40 @@ 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 { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import useNewQuery from 'components/interfaces/SQLEditor/hooks'
import { usePrimaryDatabase } from 'data/read-replicas/replicas-query'
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useDatabaseTestCreateMutation } from 'data/database-tests/database-test-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import dayjs from 'dayjs'
import { Parameter, parseParameters } from 'lib/sql-parameters'
import { Dashboards } from 'types'
import { ChartContainer, ChartTooltipContent, cn, CodeBlock, SQL_ICON } from 'ui'
import {
ChartContainer,
ChartTooltipContent,
cn,
CodeBlock,
SQL_ICON,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} 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'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useAppStateSnapshot } from 'state/app-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
@@ -122,7 +142,17 @@ export const QueryBlock = ({
onDragStart,
onResults,
}: QueryBlockProps) => {
const router = useRouter()
const { ref } = useParams()
const { newQuery } = useNewQuery()
const sqlEditorSnap = useSqlEditorV2StateSnapshot()
const { setEditorPanel } = useAppStateSnapshot()
const snap = useAiAssistantStateSnapshot()
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const isInSQLEditor = router.pathname.includes('/sql')
const isInNewSnippet = router.pathname.endsWith('/sql')
const [chartSettings, setChartSettings] = useState<ChartConfig>(chartConfig)
const { xKey, yKey, view = 'table' } = chartSettings
@@ -159,6 +189,15 @@ export const QueryBlock = ({
const postgresConnectionString = primaryDatabase?.connectionString
const readOnlyConnectionString = primaryDatabase?.connection_string_read_only
const { mutate: createTest, isLoading: isCreatingTest } = useDatabaseTestCreateMutation({
onSuccess: () => {
toast.success('Test created successfully')
},
onError: (error) => {
toast.error(`Failed to create test: ${error.message}`)
},
})
const chartData = chartSettings.cumulative
? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartSettings)
: formattedQueryResult
@@ -211,6 +250,139 @@ export const QueryBlock = ({
}
}
const handleSaveAsSnippet = () => {
if (!sql) return
if (id !== undefined) {
// Navigate to existing snippet
router.push(`/project/${ref}/sql/${id}`)
return
}
if (!isInSQLEditor || isInNewSnippet) {
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: sql,
})
snap.closeAssistant()
} else {
if (sql) newQuery(sql, label)
}
sendEvent({
action: 'assistant_edit_in_sql_editor_clicked',
properties: {
isInSQLEditor,
isInNewSnippet,
},
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
} else {
// Show dropdown options for SQL editor context
sqlEditorSnap.setDiffContent(sql, DiffType.Addition)
}
}
const handleSaveAsTest = () => {
if (!sql) return
createTest({
projectRef: ref!,
connectionString: postgresConnectionString,
query: sql,
})
}
const renderQueryActionsDropdown = () => {
if (id !== undefined) {
// If this is an existing snippet, just show the edit button
return (
<ButtonTooltip
asChild
type="text"
size="tiny"
className="w-7 h-7"
icon={<Edit size={14} strokeWidth={1.5} />}
tooltip={{
content: { side: 'bottom', text: 'Edit in SQL Editor' },
}}
>
<Link href={`/project/${ref}/sql/${id}`} />
</ButtonTooltip>
)
}
if (!isInSQLEditor || isInNewSnippet) {
// Show dropdown for save options
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
type="text"
size="tiny"
className="w-7 h-7"
icon={<MoreVertical size={14} strokeWidth={1.5} />}
tooltip={{
content: { side: 'bottom', text: 'Save options' },
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleSaveAsSnippet} disabled={!sql}>
<Edit size={14} className="mr-2" />
Save as snippet
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSaveAsTest} disabled={!sql || isCreatingTest}>
<Code size={14} className="mr-2" />
Save as test
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
} else {
// In SQL editor, show the original dropdown behavior
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
type="text"
size="tiny"
disabled={!sql}
className="w-7 h-7"
icon={<Edit size={14} strokeWidth={1.5} />}
tooltip={
!!sql
? { content: { side: 'bottom', text: 'Edit in SQL Editor' } }
: { content: { side: 'bottom', text: undefined } }
}
/>
</DropdownMenuTrigger>
{!!sql && (
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => sqlEditorSnap.setDiffContent(sql, DiffType.Addition)}
>
Insert code
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => sqlEditorSnap.setDiffContent(sql, DiffType.Modification)}
>
Replace code
</DropdownMenuItem>
<DropdownMenuItem onClick={() => newQuery(sql, label)}>
Create new snippet
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSaveAsTest} disabled={isCreatingTest}>
<Code size={14} className="mr-2" />
Save as test
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
}
useEffect(() => {
setChartSettings(chartConfig)
}, [chartConfig])
@@ -295,7 +467,7 @@ export const QueryBlock = ({
</>
)}
<EditQueryButton id={id} title={label} sql={sql} />
{renderQueryActionsDropdown()}
{(showRunButtonIfNotReadOnly || !readOnlyError) && (
<ButtonTooltip
@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { uuidv4 } from 'lib/helpers'
import { databaseTestsKeys } from './database-tests-key'
import { DatabaseTest } from './database-tests-query'
type DatabaseTestCreateVariables = {
projectRef: string
connectionString?: string
query: string
}
export const useDatabaseTestCreateMutation = ({
onSuccess,
}: {
onSuccess?: () => void
} = {}) => {
const queryClient = useQueryClient()
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
return useMutation({
mutationFn: async ({ projectRef, query }: DatabaseTestCreateVariables) => {
// AI title generation
const { title } = await generateSqlTitle({ sql: query })
// This is a mock implementation.
// In a real scenario, you would save the test to a file or a database table.
const key = databaseTestsKeys.list(projectRef)
const currentTests = queryClient.getQueryData<DatabaseTest[]>(key) ?? []
const newTest: DatabaseTest = {
id: uuidv4(),
name: title,
query,
}
queryClient.setQueryData(key, [...currentTests, newTest])
return newTest
},
onSuccess: (newTest) => {
toast.success(`Test "${newTest.name}" created.`)
onSuccess?.()
},
onError: (error: any) => {
toast.error(`Failed to create test: ${error.message}`)
},
})
}
@@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { databaseTestsKeys } from './database-tests-key'
import { DatabaseTest } from './database-tests-query'
type DatabaseTestDeleteVariables = {
projectRef: string
id: string
}
export const useDatabaseTestDeleteMutation = ({
onSuccess,
}: {
onSuccess?: () => void
} = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ projectRef, id }: DatabaseTestDeleteVariables) => {
// This is a mock implementation.
// In a real scenario, you would delete the test file or remove it from a database table.
const key = databaseTestsKeys.list(projectRef)
const currentTests = queryClient.getQueryData<DatabaseTest[]>(key) ?? []
const updatedTests = currentTests.filter((test) => test.id !== id)
queryClient.setQueryData(key, updatedTests)
return id
},
onSuccess: (id) => {
toast.success(`Test successfully deleted.`)
onSuccess?.()
},
onError: (error: any) => {
toast.error(`Failed to delete test: ${error.message}`)
},
})
}
@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { databaseTestsKeys } from './database-tests-key'
import { DatabaseTest } from './database-tests-query'
type DatabaseTestUpdateVariables = {
projectRef: string
id: string
query: string
name?: string
}
export const useDatabaseTestUpdateMutation = ({
onSuccess,
}: {
onSuccess?: () => void
} = {}) => {
const queryClient = useQueryClient()
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
return useMutation({
mutationFn: async ({ projectRef, id, query, name }: DatabaseTestUpdateVariables) => {
// Generate AI title if name is not provided
const finalName = name ?? (await generateSqlTitle({ sql: query })).title
// This is a mock implementation.
// In a real scenario, you would update the test in a file or a database table.
const key = databaseTestsKeys.list(projectRef)
const currentTests = queryClient.getQueryData<DatabaseTest[]>(key) ?? []
const updatedTests = currentTests.map((test) =>
test.id === id ? { ...test, name: finalName, query } : test
)
queryClient.setQueryData(key, updatedTests)
const updatedTest = updatedTests.find((test) => test.id === id)
return updatedTest!
},
onSuccess: (updatedTest) => {
toast.success(`Test "${updatedTest.name}" updated.`)
onSuccess?.()
},
onError: (error: any) => {
toast.error(`Failed to update test: ${error.message}`)
},
})
}
@@ -0,0 +1,3 @@
export const databaseTestsKeys = {
list: (projectRef: string | undefined) => [projectRef, 'database-tests'] as const,
}
@@ -0,0 +1,137 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { databaseTestsKeys } from './database-tests-key'
export type DatabaseTest = {
id: string
name: string
query: string
}
export type DatabaseTestsVariables = {
projectRef?: string
connectionString?: string
}
// This is a placeholder. Replace with your actual data fetching logic.
const MOCK_TESTS: DatabaseTest[] = [
{
id: '0',
name: '000-setup-tests-hooks',
query: `
-- install tests utilities
-- install pgtap extension for testing
create extension if not exists pgtap with schema extensions;
/*
---------------------
---- install dbdev ----
----------------------
Requires:
- pg_tle: https://github.com/aws/pg_tle
- pgsql-http: https://github.com/pramsey/pgsql-http
*/
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
drop extension if exists "supabase-dbdev";
select pgtle.uninstall_extension_if_exists('supabase-dbdev');
select
pgtle.install_extension(
'supabase-dbdev',
resp.contents ->> 'version',
'PostgreSQL package manager',
resp.contents ->> 'sql'
)
from http(
(
'GET',
'https://api.database.dev/rest/v1/'
|| 'package_versions?select=sql,version'
|| '&package_name=eq.supabase-dbdev'
|| '&order=version.desc'
|| '&limit=1',
array[
('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header
],
null,
null
)
) x,
lateral (
select
((row_to_json(x) -> 'content') #>> '{}')::json -> 0
) resp(contents);
create extension "supabase-dbdev";
select dbdev.install('supabase-dbdev');
drop extension if exists "supabase-dbdev";
create extension "supabase-dbdev";
-- Install test helpers
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
`,
},
{
id: '1',
name: '01_test_users_table',
query: `
BEGIN;
SELECT plan(1);
SELECT has_table('users');
SELECT * FROM finish();
ROLLBACK;
`.trim(),
},
{
id: '2',
name: '02_test_rls_policy',
query: `
BEGIN;
SELECT plan(1);
SELECT policies_are('public', 'profiles', ARRAY['Profiles are public']);
SELECT * FROM finish();
ROLLBACK;
`.trim(),
},
{
id: '3',
name: '03_test_rls_public',
query: `
begin;
select plan(1);
-- Verify RLS is enabled on all tables in the public schema
select tests.rls_enabled('public');
select * from finish();
rollback;
`,
},
]
export async function getDatabaseTests(
{ projectRef, connectionString }: DatabaseTestsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
// In a real implementation, you would fetch this from a database or file system.
// For now, we return mock data.
// The timeout is to simulate network latency.
await new Promise((resolve) => setTimeout(resolve, 500))
return MOCK_TESTS
}
export type DatabaseTestsData = Awaited<ReturnType<typeof getDatabaseTests>>
export type DatabaseTestsError = unknown
export const useDatabaseTestsQuery = <TData = DatabaseTestsData>(
{ projectRef, connectionString }: DatabaseTestsVariables,
{ enabled = true, ...options }: UseQueryOptions<DatabaseTestsData, DatabaseTestsError, TData> = {}
) =>
useQuery<DatabaseTestsData, DatabaseTestsError, TData>(
databaseTestsKeys.list(projectRef),
({ signal }) => getDatabaseTests({ projectRef, connectionString }, signal),
{
staleTime: 0,
refetchOnWindowFocus: false,
refetchOnMount: true,
...options,
}
)
+3 -1
View File
@@ -27,6 +27,7 @@ export const toolSetValidationSchema = z.record(
'get_advisors',
// Local tools
'get_policies',
'display_query',
'display_edge_function',
'rename_chat',
@@ -89,11 +90,12 @@ export const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
rename_chat: TOOL_CATEGORIES.UI,
search_docs: TOOL_CATEGORIES.UI,
// Schema tools - MCP
// Schema tools - MCP and local
list_tables: TOOL_CATEGORIES.SCHEMA,
list_extensions: TOOL_CATEGORIES.SCHEMA,
list_edge_functions: TOOL_CATEGORIES.SCHEMA,
list_branches: TOOL_CATEGORIES.SCHEMA,
get_policies: TOOL_CATEGORIES.SCHEMA,
// Log tools - MCP and local
get_advisors: TOOL_CATEGORIES.LOG,
+129
View File
@@ -8,6 +8,7 @@ import { IS_PLATFORM } from 'common'
import { getOrganizations } from 'data/organizations/organizations-query'
import { getProjects } from 'data/projects/projects-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { getDatabasePolicies } from 'data/database-policies/database-policies-query'
import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { getModel } from 'lib/ai/model'
import apiWrapper from 'lib/api/apiWrapper'
@@ -170,6 +171,56 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
return { status: 'Chat request sent to client' }
},
}),
get_policies: tool({
description: 'Get existing RLS policies for one or more schemas.',
parameters: z.object({
schemas: z.array(z.string()).describe('The schema names to get the policies for.'),
}),
execute: async ({ schemas }) => {
const includeSchemaMetadata = aiOptInLevel !== 'disabled'
if (!includeSchemaMetadata) {
return 'You have disabled AI features that can read your schema. Please re-enable them to use this tool.'
}
const headers = {
'Content-Type': 'application/json',
...(authorization && { Authorization: authorization }),
}
try {
const data = await getDatabasePolicies(
{
projectRef,
connectionString,
schema: schemas?.join(','),
},
undefined,
headers
)
if (data.length === 0) {
return `No policies found for the specified schemas: ${schemas.join(', ')}.`
}
const formattedPolicies = data
.map(
(policy) => `
- Policy Name: "${policy.name}"
Table: ${policy.schema}.${policy.table}
Action: ${policy.action}
Roles: ${policy.roles.join(', ')}
Command: ${policy.command}
Definition: ${policy.definition}
${policy.check ? `Check: ${policy.check}` : ''}`
)
.join('\n')
return `Here are the existing policies for schemas ${schemas.join(', ')}:\n${formattedPolicies}`
} catch (error: any) {
return `Failed to fetch policies: ${error.message}`
}
},
}),
}
// Get a list of all schemas to add to context
@@ -753,6 +804,84 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
CREATE INDEX idx_products_tenant ON products(tenant_id);
\`\`\`
# Database testing with pgTAP
- You can also help the user write tests for their database.
- Supabase uses pgTAP to run database tests.
- Assume that the pgTAP extension is already installed, as well as the \`database.dev\` and \`basejump-supabase_test_helpers\` extensions.
- Tests you create can be saved for running periodically
- Always get schema information using \`list_tables\`
- If writing tests for RLS policies, first get RLS information using \`list_policies\`
## Writing tests
- Always wrap tests in a transaction (\`BEGIN;\` and \`ROLLBACK;\`) to isolate them from other tests.
- Use \`SELECT plan(N);\` where N is the number of tests you are running.
- Use test helpers to simplify the tests:
- \`tests.create_supabase_user('user_id')\`: Creates a test user.
- \`tests.authenticate_as('user_id')\`: Switches the context to the created user.
- \`tests.rls_enabled('schema_name')\`: Verifies that RLS is enabled on all tables in a schema.
- Here is an example of a test:
\`\`\`sql
begin;
-- Assuming 000-setup-tests-hooks.sql file is present to use tests helpers
select plan(4);
-- Set up test data
-- Create test supabase users
select tests.create_supabase_user('user1@test.com');
select tests.create_supabase_user('user2@test.com');
-- Create test data
insert into public.todos (task, user_id) values
('User 1 Task 1', tests.get_supabase_uid('user1@test.com')),
('User 1 Task 2', tests.get_supabase_uid('user1@test.com')),
('User 2 Task 1', tests.get_supabase_uid('user2@test.com'));
-- Test as User 1
select tests.authenticate_as('user1@test.com');
-- Test 1: User 1 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
);
-- Test 2: User 1 can create their own todo
select lives_ok(
$$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$,
'User 1 can create their own todo'
);
-- Test as User 2
select tests.authenticate_as('user2@test.com');
-- Test 3: User 2 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
);
-- Test 4: User 2 cannot modify User 1's todo
SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
);
select * from finish();
rollback;
\`\`\`
For checking row updates, first return the updated row and then check if the result is not equal or equal to the expected result.
e.g. SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
SELECT results_eq(
$$
UPDATE public.messages
SET message = 'Hacked by user 1'
WHERE user_id = tests.get_supabase_uid('user1@test.com')
RETURNING 1
$$,
$$ VALUES(1) $$,
'User 1 can update their own messages'
);
);
# Edge Functions
- Use the \`display_edge_function\` tool to generate complete, high-quality Edge Functions in TypeScript for the Deno runtime.
- **Dependencies**:
@@ -0,0 +1,193 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useRef, useState } from 'react'
import { Alert, Button, AiIconAnimation } from 'ui'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DatabaseTest } from 'data/database-tests/database-tests-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { Play } from 'lucide-react'
import TestsList, { TestsListHandle } from 'components/interfaces/Database/Tests/TestsList'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useAppStateSnapshot } from 'state/app-state'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import type { NextPageWithLayout } from 'types'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useDatabaseTestCreateMutation } from 'data/database-tests/database-test-create-mutation'
import { useDatabaseTestUpdateMutation } from 'data/database-tests/database-test-update-mutation'
const DatabaseTestsPage: NextPageWithLayout = () => {
const { setEditorPanel } = useAppStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const project = useSelectedProject()
const [showRunAllTestsModal, setShowRunAllTestsModal] = useState(false)
const testsListRef = useRef<TestsListHandle>(null)
const canReadTests = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables')
const canCreateTests = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isPermissionsLoaded = usePermissionsLoaded()
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgtap = extensions?.find((ext) => ext.name === 'pgtap')
const isPgtapInstalled = pgtap !== undefined && pgtap.installed_version !== null
const { mutate: createTest, isLoading: isCreatingTest } = useDatabaseTestCreateMutation({
onSuccess: () => {
setEditorPanel({ open: false })
},
})
const { mutate: updateTest, isLoading: isUpdatingTest } = useDatabaseTestUpdateMutation({
onSuccess: () => {
setEditorPanel({ open: false })
},
})
const onSelectTest = (test: DatabaseTest) => {
setEditorPanel({
open: true,
initialValue: test.query,
label: `Edit test: ${test.name}`,
saveLabel: 'Save test',
onSave: (query) => {
updateTest({
projectRef: project?.ref!,
id: test.id,
query,
})
},
})
}
const createNewTest = () => {
setEditorPanel({
open: true,
initialValue: `
BEGIN;
SELECT plan(1);
-- Your test here
SELECT * FROM finish();
ROLLBACK;
`.trim(),
label: 'Create new test',
saveLabel: 'Save test',
onSave: (query) => {
createTest({
projectRef: project?.ref!,
connectionString: project?.connectionString,
query,
})
},
})
}
const onRunAllTests = () => {
setShowRunAllTestsModal(true)
}
const primaryActions = (
<div className="flex items-center gap-x-2">
<Button
type="primary"
onClick={createNewTest}
loading={isCreatingTest}
disabled={!canCreateTests}
>
Create new test
</Button>
<ButtonTooltip
type="default"
disabled={!canCreateTests}
className="px-1 pointer-events-auto"
icon={<AiIconAnimation size={16} />}
onClick={() =>
aiSnap.newChat({
name: 'Create a new test',
open: true,
initialInput: `Write a pgTAP test to...`,
})
}
tooltip={{
content: {
side: 'bottom',
text: !canCreateTests
? 'You need additional permissions to create tests'
: 'Create with Supabase Assistant',
},
}}
/>
</div>
)
const secondaryActions = (
<Button type="default" icon={<Play />} onClick={onRunAllTests}>
Run all tests
</Button>
)
return (
<PageLayout
title="Database Tests"
subtitle="Run pgTAP tests against your database"
primaryActions={primaryActions}
secondaryActions={secondaryActions}
>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12 space-y-4">
{!isLoadingExtensions && !isPgtapInstalled && (
<Alert
withIcon
variant="info"
title="pgTAP is not enabled"
actions={
<Button asChild type="default">
<Link href={`/project/${project?.ref}/database/extensions?filter=pgtap`}>
Enable pgTAP
</Link>
</Button>
}
>
The pgTAP extension is required to run database tests. Please enable it from the
database extensions page.
</Alert>
)}
<TestsList ref={testsListRef} onSelectTest={onSelectTest} />
</div>
</ScaffoldSection>
</ScaffoldContainer>
<ConfirmationModal
visible={showRunAllTestsModal}
title="Run all tests"
confirmLabel="Run all tests"
onCancel={() => setShowRunAllTestsModal(false)}
onConfirm={() => {
testsListRef.current?.runAllTests()
setShowRunAllTestsModal(false)
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to run all the tests? This may take a while.
</p>
</ConfirmationModal>
</PageLayout>
)
}
DatabaseTestsPage.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Database">{page}</DatabaseLayout>
</DefaultLayout>
)
export default DatabaseTestsPage
+1
View File
@@ -15,6 +15,7 @@ type EditorPanelType = {
label?: string
saveLabel?: string
onSave?: (value: string) => void
onRun?: (value: string) => void
functionName?: string
templates?: Template[]
initialPrompt?: string
@@ -64,7 +64,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-foreground-muted [&:has([role=checkbox])]:pr-0',
'h-12 px-4 text-left align-middle font-medium text-foreground-light text-xs font-mono uppercase [&:has([role=checkbox])]:pr-0',
className
)}
{...props}