mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
database tests
This commit is contained in:
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,
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user