index.tsx562 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta/src/pg-format'
3import { isEmpty, isNull, keyBy, mapValues, partition } from 'lodash'
4import { Plus, Trash } from 'lucide-react'
5import { useEffect, useMemo, useState } from 'react'
6import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 cn,
11 Form,
12 FormControl,
13 FormDescription,
14 FormField,
15 FormItem,
16 FormLabel,
17 FormMessage,
18 Input,
19 RadioGroupStacked,
20 RadioGroupStackedItem,
21 ScrollArea,
22 Select,
23 SelectContent,
24 SelectItem,
25 SelectTrigger,
26 SelectValue,
27 Separator,
28 Sheet,
29 SheetContent,
30 SheetFooter,
31 SheetSection,
32 Switch,
33} from 'ui'
34import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
35import z from 'zod'
36
37import { convertArgumentTypes, convertConfigParams } from '../Functions.utils'
38import { CreateFunctionConfigParamsSection } from './CreateFunctionConfigParamsSection'
39import { CreateFunctionHeader } from './CreateFunctionHeader'
40import { FunctionEditor } from './FunctionEditor'
41import { POSTGRES_DATA_TYPES } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants'
42import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
43import SchemaSelector from '@/components/ui/SchemaSelector'
44import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
45import { useDatabaseFunctionCreateMutation } from '@/data/database-functions/database-functions-create-mutation'
46import type { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query'
47import { useDatabaseFunctionUpdateMutation } from '@/data/database-functions/database-functions-update-mutation'
48import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
49import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
50import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
51
52const FORM_ID = 'create-function-sidepanel'
53
54interface CreateFunctionProps {
55 func?: SavedDatabaseFunction
56 isDuplicating?: boolean
57 visible: boolean
58 onClose: () => void
59}
60
61const FormSchema = z.object({
62 name: z.string().trim().min(1),
63 schema: z.string().trim().min(1),
64 args: z.array(z.object({ name: z.string().trim().min(1), type: z.string().trim() })),
65 behavior: z.enum(['IMMUTABLE', 'STABLE', 'VOLATILE']),
66 definition: z.string().trim().min(1),
67 language: z.string().trim(),
68 return_type: z.string().trim(),
69 security_definer: z.boolean(),
70 config_params: z
71 .array(z.object({ name: z.string().trim().min(1), value: z.string().trim().min(1) }))
72 .optional(),
73})
74
75export const CreateFunction = ({
76 func,
77 visible,
78 isDuplicating = false,
79 onClose,
80}: CreateFunctionProps) => {
81 const { data: project } = useSelectedProjectQuery()
82 const [advancedSettingsShown, setAdvancedSettingsShown] = useState(false)
83 const [focusedEditor, setFocusedEditor] = useState(false)
84
85 const isEditing = !isDuplicating && !!func?.id
86
87 const form = useForm<z.infer<typeof FormSchema>>({
88 resolver: zodResolver(FormSchema as any),
89 })
90 const language = form.watch('language')
91
92 const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
93 checkIsDirty: () => form.formState.isDirty,
94 onClose,
95 })
96
97 const { mutate: createDatabaseFunction, isPending: isCreating } =
98 useDatabaseFunctionCreateMutation()
99 const { mutate: updateDatabaseFunction, isPending: isUpdating } =
100 useDatabaseFunctionUpdateMutation()
101
102 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
103 if (!project) return console.error('Project is required')
104 // Submit click is the explicit user gesture that promotes form-entered SQL fragments
105 // (`args` items, `return_type`, and each `config_params` value) to executable.
106 const payload = {
107 ...data,
108 args: data.args.map((x) => acceptUntrustedSql(untrustedSql(`${x.name} ${x.type}`))),
109 return_type: acceptUntrustedSql(untrustedSql(data.return_type)),
110 config_params: mapValues(keyBy(data.config_params, 'name'), (item) =>
111 acceptUntrustedSql(untrustedSql(item.value))
112 ),
113 }
114
115 if (isEditing) {
116 updateDatabaseFunction(
117 {
118 func,
119 projectRef: project.ref,
120 connectionString: project.connectionString,
121 payload,
122 },
123 {
124 onSuccess: () => {
125 toast.success(`Successfully updated function ${data.name}`)
126 onClose()
127 },
128 }
129 )
130 } else {
131 createDatabaseFunction(
132 {
133 projectRef: project.ref,
134 connectionString: project.connectionString,
135 payload,
136 },
137 {
138 onSuccess: () => {
139 toast.success(`Successfully created function ${data.name}`)
140 onClose()
141 },
142 }
143 )
144 }
145 }
146
147 useEffect(() => {
148 if (visible) {
149 setFocusedEditor(false)
150 form.reset({
151 name: func?.name ?? '',
152 schema: func?.schema ?? 'public',
153 args: convertArgumentTypes(func?.argument_types || '').value,
154 behavior: func?.behavior ?? 'VOLATILE',
155 definition: func?.definition ?? '',
156 language: func?.language ?? 'plpgsql',
157 return_type: func?.return_type ?? 'void',
158 security_definer: func?.security_definer ?? false,
159 config_params: convertConfigParams(func?.config_params).value,
160 })
161 }
162 // eslint-disable-next-line react-hooks/exhaustive-deps
163 }, [visible, func?.id])
164
165 const { data: protectedSchemas } = useProtectedSchemas()
166
167 return (
168 <Sheet open={visible} onOpenChange={handleOpenChange}>
169 <SheetContent
170 showClose={false}
171 size={'default'}
172 className={'p-0 flex flex-row gap-0 min-w-screen! lg:min-w-[600px]!'}
173 >
174 <div className="flex flex-col grow w-full">
175 <CreateFunctionHeader selectedFunction={func?.name} isDuplicating={isDuplicating} />
176 <Separator />
177 <Form {...form}>
178 <form
179 id={FORM_ID}
180 className="grow overflow-auto"
181 onSubmit={form.handleSubmit(onSubmit)}
182 >
183 <SheetSection className={focusedEditor ? 'hidden' : ''}>
184 <FormField
185 control={form.control}
186 name="name"
187 render={({ field }) => (
188 <FormItemLayout
189 label="Name of function"
190 description="Name will also be used for the function name in postgres"
191 layout="horizontal"
192 >
193 <FormControl>
194 <Input {...field} placeholder="Name of function" />
195 </FormControl>
196 </FormItemLayout>
197 )}
198 />
199 </SheetSection>
200 <Separator className={focusedEditor ? 'hidden' : ''} />
201 <SheetSection className={focusedEditor ? 'hidden' : 'space-y-4'}>
202 <FormField
203 control={form.control}
204 name="schema"
205 render={({ field }) => (
206 <FormItemLayout
207 label="Schema"
208 description="Tables made in the table editor will be in 'public'"
209 layout="horizontal"
210 >
211 <FormControl>
212 <SchemaSelector
213 selectedSchemaName={field.value}
214 excludedSchemas={protectedSchemas?.map((s) => s.name)}
215 size="small"
216 onSelectSchema={(name) => field.onChange(name)}
217 />
218 </FormControl>
219 </FormItemLayout>
220 )}
221 />
222 {!isEditing && (
223 <FormField
224 control={form.control}
225 name="return_type"
226 render={({ field }) => (
227 <FormItemLayout label="Return type" layout="horizontal">
228 {/* Form selects don't need form controls, otherwise the CSS gets weird */}
229 <Select onValueChange={field.onChange} defaultValue={field.value}>
230 <SelectTrigger className="col-span-8">
231 <SelectValue />
232 </SelectTrigger>
233 <SelectContent>
234 <ScrollArea className="h-52">
235 {['void', 'record', 'trigger', 'integer', ...POSTGRES_DATA_TYPES].map(
236 (option) => (
237 <SelectItem value={option} key={option}>
238 {option}
239 </SelectItem>
240 )
241 )}
242 </ScrollArea>
243 </SelectContent>
244 </Select>
245 </FormItemLayout>
246 )}
247 />
248 )}
249 </SheetSection>
250 <Separator className={focusedEditor ? 'hidden' : ''} />
251 <SheetSection className={focusedEditor ? 'hidden' : ''}>
252 <FormFieldArgs readonly={isEditing} />
253 </SheetSection>
254 <Separator className={focusedEditor ? 'hidden' : ''} />
255 <SheetSection className={`${focusedEditor ? 'h-full' : ''} px-0!`}>
256 <FormField
257 control={form.control}
258 name="definition"
259 render={({ field }) => (
260 <FormItem className="space-y-4 flex flex-col h-full">
261 <div className="px-content">
262 <FormLabel className="text-base text-foreground">Definition</FormLabel>
263 <FormDescription className="text-sm text-foreground-light">
264 <p>
265 The language below should be written in <code>{language}</code>.
266 </p>
267 {!isEditing && <p>Change the language in the Advanced Settings below.</p>}
268 </FormDescription>
269 </div>
270 <div
271 className={cn(
272 'border border-default flex',
273 focusedEditor ? 'grow ' : 'h-72'
274 )}
275 >
276 <FunctionEditor
277 field={field}
278 language={language}
279 focused={focusedEditor}
280 setFocused={setFocusedEditor}
281 />
282 </div>
283
284 <FormMessage className="px-content" />
285 </FormItem>
286 )}
287 />
288 </SheetSection>
289 <Separator className={focusedEditor ? 'hidden' : ''} />
290 {isEditing ? (
291 <></>
292 ) : (
293 <>
294 <SheetSection className={focusedEditor ? 'hidden' : ''}>
295 <div className="space-y-8 rounded-sm bg-studio py-4 px-6 border border-overlay">
296 <FormItem className="flex flex-row items-center justify-between">
297 <div className="space-y-0.5">
298 <FormLabel className="text-base">Show advanced settings</FormLabel>
299 <FormDescription>
300 These are settings that might be familiar for Postgres developers
301 </FormDescription>
302 </div>
303 <FormControl>
304 <Switch
305 checked={advancedSettingsShown}
306 onCheckedChange={(checked) => setAdvancedSettingsShown(checked)}
307 />
308 </FormControl>
309 </FormItem>
310 </div>
311 </SheetSection>
312 {advancedSettingsShown && (
313 <>
314 <SheetSection className={focusedEditor ? 'hidden' : 'space-y-2 pt-0'}>
315 <FormFieldLanguage />
316 <FormField
317 control={form.control}
318 name="behavior"
319 render={({ field }) => (
320 <FormItemLayout label="Behavior" layout="horizontal">
321 {/* Form selects don't need form controls, otherwise the CSS gets weird */}
322 <Select defaultValue={field.value} onValueChange={field.onChange}>
323 <SelectTrigger className="col-span-8">
324 <SelectValue />
325 </SelectTrigger>
326 <SelectContent>
327 <SelectItem value="IMMUTABLE" key="IMMUTABLE">
328 immutable
329 </SelectItem>
330 <SelectItem value="STABLE" key="STABLE">
331 stable
332 </SelectItem>
333 <SelectItem value="VOLATILE" key="VOLATILE">
334 volatile
335 </SelectItem>
336 </SelectContent>
337 </Select>
338 </FormItemLayout>
339 )}
340 />
341 </SheetSection>
342 <Separator className={focusedEditor ? 'hidden' : ''} />
343 <SheetSection className={focusedEditor ? 'hidden' : ''}>
344 <CreateFunctionConfigParamsSection />
345 </SheetSection>
346 <Separator className={focusedEditor ? 'hidden' : ''} />
347 <SheetSection className={focusedEditor ? 'hidden' : ''}>
348 <h5 className="text-base text-foreground mb-4">Type of Security</h5>
349 <FormField
350 control={form.control}
351 name="security_definer"
352 render={({ field }) => (
353 <FormItem>
354 <FormControl className="col-span-8">
355 <RadioGroupStacked
356 onValueChange={(value) =>
357 field.onChange(value == 'SECURITY_DEFINER')
358 }
359 value={field.value ? 'SECURITY_DEFINER' : 'SECURITY_INVOKER'}
360 >
361 <RadioGroupStackedItem
362 value="SECURITY_INVOKER"
363 id="SECURITY_INVOKER"
364 label="SECURITY INVOKER"
365 description={
366 <>
367 Function is to be executed with the privileges of the user
368 that <span className="text-foreground">calls it</span>.
369 </>
370 }
371 />
372 <RadioGroupStackedItem
373 value="SECURITY_DEFINER"
374 id="SECURITY_DEFINER"
375 label="SECURITY DEFINER"
376 description={
377 <>
378 Function is to be executed with the privileges of the user
379 that <span className="text-foreground">created it</span>.
380 </>
381 }
382 />
383 </RadioGroupStacked>
384 </FormControl>
385 <FormMessage />
386 </FormItem>
387 )}
388 />
389 </SheetSection>
390 </>
391 )}
392 </>
393 )}
394 </form>
395 </Form>
396 <SheetFooter>
397 <Button disabled={isCreating || isUpdating} type="default" onClick={confirmOnClose}>
398 Cancel
399 </Button>
400 <Button
401 form={FORM_ID}
402 htmlType="submit"
403 disabled={isCreating || isUpdating}
404 loading={isCreating || isUpdating}
405 >
406 {isEditing ? 'Save' : 'Create'} function
407 </Button>
408 </SheetFooter>
409 </div>
410 <DiscardChangesConfirmationDialog {...modalProps} />
411 </SheetContent>
412 </Sheet>
413 )
414}
415
416interface FormFieldConfigParamsProps {
417 readonly?: boolean
418}
419
420const FormFieldArgs = ({ readonly }: FormFieldConfigParamsProps) => {
421 const { fields, append, remove } = useFieldArray<z.infer<typeof FormSchema>>({
422 name: 'args',
423 })
424
425 return (
426 <>
427 <div className="flex flex-col">
428 <h5 className="text-base text-foreground">Arguments</h5>
429 <p className="text-sm text-foreground-light">
430 Arguments can be referenced in the function body using either names or numbers.
431 </p>
432 </div>
433 <div className="space-y-2 pt-4">
434 {readonly && isEmpty(fields) && (
435 <span className="text-foreground-lighter">No argument for this function</span>
436 )}
437 {fields.map((field, index) => {
438 return (
439 <div className="flex flex-row space-x-1" key={field.id}>
440 <FormField
441 name={`args.${index}.name`}
442 render={({ field }) => (
443 <FormItem className="flex-1">
444 <FormControl>
445 <Input {...field} disabled={readonly} placeholder="argument_name" />
446 </FormControl>
447 <FormMessage />
448 </FormItem>
449 )}
450 />
451 <FormField
452 name={`args.${index}.type`}
453 render={({ field }) => (
454 <FormItem className="flex-1">
455 <FormControl>
456 {readonly ? (
457 <Input value={field.value} disabled readOnly className="h-auto" />
458 ) : (
459 <>
460 <Select
461 disabled={readonly}
462 onValueChange={field.onChange}
463 defaultValue={field.value}
464 >
465 <SelectTrigger className="h-[38px]">
466 <SelectValue />
467 </SelectTrigger>
468 <SelectContent>
469 <ScrollArea className="h-52">
470 {['integer', ...POSTGRES_DATA_TYPES].map((option) => (
471 <SelectItem value={option} key={option}>
472 {option}
473 </SelectItem>
474 ))}
475 </ScrollArea>
476 </SelectContent>
477 </Select>
478 </>
479 )}
480 </FormControl>
481 <FormMessage />
482 </FormItem>
483 )}
484 />
485
486 {!readonly && (
487 <Button
488 type="danger"
489 icon={<Trash size={12} />}
490 onClick={() => remove(index)}
491 className="h-[38px] w-[38px]"
492 />
493 )}
494 </div>
495 )
496 })}
497
498 {!readonly && (
499 <Button
500 type="default"
501 icon={<Plus size={12} />}
502 onClick={() => append({ name: '', type: 'integer' })}
503 disabled={readonly}
504 >
505 Add a new argument
506 </Button>
507 )}
508 </div>
509 </>
510 )
511}
512
513const ALL_ALLOWED_LANGUAGES = ['plpgsql', 'sql', 'plcoffee', 'plv8', 'plls']
514
515const FormFieldLanguage = () => {
516 const { data: project } = useSelectedProjectQuery()
517
518 const { data: enabledExtensions } = useDatabaseExtensionsQuery(
519 {
520 projectRef: project?.ref,
521 connectionString: project?.connectionString,
522 },
523 {
524 select(data) {
525 return partition(data, (ext) => !isNull(ext.installed_version))[0]
526 },
527 }
528 )
529
530 const allowedLanguages = useMemo(() => {
531 return ALL_ALLOWED_LANGUAGES.filter((lang) => {
532 if (lang.startsWith('pl')) {
533 return enabledExtensions?.find((ex) => ex.name === lang) !== undefined
534 }
535
536 return true
537 })
538 }, [enabledExtensions])
539
540 return (
541 <FormField
542 name="language"
543 render={({ field }) => (
544 <FormItemLayout label="Language" layout="horizontal">
545 {/* Form selects don't need form controls, otherwise the CSS gets weird */}
546 <Select onValueChange={field.onChange} defaultValue={field.value}>
547 <SelectTrigger className="col-span-8">
548 <SelectValue />
549 </SelectTrigger>
550 <SelectContent>
551 {allowedLanguages.map((option) => (
552 <SelectItem value={option} key={option}>
553 {option}
554 </SelectItem>
555 ))}
556 </SelectContent>
557 </Select>
558 </FormItemLayout>
559 )}
560 />
561 )
562}