index.tsx 22 KB

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