SQLEditor.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099
  1. // @ts-nocheck
  2. import type { Monaco } from '@monaco-editor/react'
  3. import {
  4. acceptUntrustedSql,
  5. rawSql,
  6. safeSql,
  7. type SafeSqlFragment,
  8. type UntrustedSqlFragment,
  9. } from '@supabase/pg-meta'
  10. import { wrapWithRollback } from '@supabase/pg-meta/src/query'
  11. import { useQueryClient } from '@tanstack/react-query'
  12. import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
  13. import { ChevronUp, Loader2 } from 'lucide-react'
  14. import dynamic from 'next/dynamic'
  15. import { useRouter } from 'next/router'
  16. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  17. import { toast } from 'sonner'
  18. import {
  19. Button,
  20. cn,
  21. DropdownMenu,
  22. DropdownMenuContent,
  23. DropdownMenuRadioGroup,
  24. DropdownMenuRadioItem,
  25. DropdownMenuTrigger,
  26. ResizableHandle,
  27. ResizablePanel,
  28. ResizablePanelGroup,
  29. Tooltip,
  30. TooltipContent,
  31. TooltipTrigger,
  32. } from 'ui'
  33. import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
  34. import { RunQueryWarningModal } from './RunQueryWarningModal'
  35. import {
  36. generateSnippetTitle,
  37. ROWS_PER_PAGE_OPTIONS,
  38. sqlAiDisclaimerComment,
  39. untitledSnippetTitle,
  40. } from './SQLEditor.constants'
  41. import {
  42. DiffType,
  43. IStandaloneCodeEditor,
  44. IStandaloneDiffEditor,
  45. type PotentialIssues,
  46. } from './SQLEditor.types'
  47. import {
  48. appendEnableRLSStatements,
  49. checkAlterDatabaseConnection,
  50. checkDestructiveQuery,
  51. checkIfAppendLimitRequired,
  52. createSqlSnippetSkeletonV2,
  53. filterTablesCoveredByEnsureRLSTrigger,
  54. getCreateTablesMissingRLS,
  55. hasActiveEnsureRLSTrigger,
  56. isUpdateWithoutWhere,
  57. suffixWithLimit,
  58. } from './SQLEditor.utils'
  59. import { useAddDefinitions } from './useAddDefinitions'
  60. import { UtilityPanel } from './UtilityPanel/UtilityPanel'
  61. import {
  62. isExplainQuery,
  63. isExplainSql,
  64. splitSqlStatements,
  65. } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
  66. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  67. import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
  68. import { GridFooter } from '@/components/ui/GridFooter'
  69. import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
  70. import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
  71. import { constructHeaders, isValidConnString } from '@/data/fetchers'
  72. import { lintKeys } from '@/data/lint/keys'
  73. import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  74. import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
  75. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  76. import { isError } from '@/data/utils/error-check'
  77. import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
  78. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  79. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  80. import { generateUuid } from '@/lib/api/snippets.browser'
  81. import { BASE_PATH } from '@/lib/constants'
  82. import { formatSql } from '@/lib/formatSql'
  83. import { detectOS } from '@/lib/helpers'
  84. import { useProfile } from '@/lib/profile'
  85. import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
  86. import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  87. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  88. import {
  89. isRoleImpersonationEnabled,
  90. useGetImpersonatedRoleState,
  91. } from '@/state/role-impersonation-state'
  92. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  93. import { useShortcut } from '@/state/shortcuts/useShortcut'
  94. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  95. import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
  96. import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
  97. // Load the monaco editor client-side only (does not behave well server-side)
  98. const MonacoEditor = dynamic(() => import('./MonacoEditor'), { ssr: false })
  99. const DiffEditor = dynamic(
  100. () => import('../../ui/DiffEditor').then(({ DiffEditor }) => DiffEditor),
  101. { ssr: false }
  102. )
  103. export const SQLEditor = () => {
  104. const os = detectOS()
  105. const router = useRouter()
  106. const { ref, id: urlId } = useParams()
  107. const { profile } = useProfile()
  108. const { data: project } = useSelectedProjectQuery()
  109. const { data: org } = useSelectedOrganizationQuery()
  110. const queryClient = useQueryClient()
  111. const tabs = useTabsStateSnapshot()
  112. const aiSnap = useAiAssistantStateSnapshot()
  113. const { openSidebar } = useSidebarManagerSnapshot()
  114. const snapV2 = useSqlEditorV2StateSnapshot()
  115. const getImpersonatedRoleState = useGetImpersonatedRoleState()
  116. const databaseSelectorState = useDatabaseSelectorStateSnapshot()
  117. const { isHipaaProjectDisallowed } = useOrgAiOptInLevel()
  118. const showPrettyExplain = useFlag('ShowPrettyExplain')
  119. const {
  120. sourceSqlDiff,
  121. setSourceSqlDiff,
  122. selectedDiffType,
  123. setSelectedDiffType,
  124. setIsAcceptDiffLoading,
  125. isDiffOpen,
  126. defaultSqlDiff,
  127. closeDiff,
  128. } = useSqlEditorDiff()
  129. const { promptState, setPromptState, promptInput, setPromptInput, resetPrompt } =
  130. useSqlEditorPrompt()
  131. const editorRef = useRef<IStandaloneCodeEditor | null>(null)
  132. const monacoRef = useRef<Monaco | null>(null)
  133. const diffEditorRef = useRef<IStandaloneDiffEditor | null>(null)
  134. const scrollTopRef = useRef<number>(0)
  135. const shouldRefocusAfterRunRef = useRef(false)
  136. const [hasSelection, setHasSelection] = useState<boolean>(false)
  137. const [lineHighlights, setLineHighlights] = useState<string[]>([])
  138. const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
  139. const [potentialIssues, setPotentialIssues] = useState<PotentialIssues>()
  140. const [showWidget, setShowWidget] = useState(false)
  141. const [activeUtilityTab, setActiveUtilityTab] = useState<string>('results')
  142. const refocusEditor = useCallback(() => {
  143. requestAnimationFrame(() => {
  144. setTimeout(() => editorRef.current?.focus(), 0)
  145. })
  146. }, [])
  147. useShortcut(SHORTCUT_IDS.SQL_EDITOR_FOCUS_EDITOR, refocusEditor, {
  148. registerInCommandMenu: true,
  149. })
  150. const openNewSnippet = useCallback(() => {
  151. if (!ref) return
  152. // skip=true bypasses the "load last visited snippet" redirect on /sql/new.
  153. // Without it, the effect in pages/project/[ref]/sql/[id].tsx bounces back
  154. // to the previous snippet.
  155. router.push(`/project/${ref}/sql/new?skip=true`)
  156. }, [ref, router])
  157. useShortcut(SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET, openNewSnippet, {
  158. registerInCommandMenu: true,
  159. })
  160. const clearPendingRunRefocus = useCallback(() => {
  161. shouldRefocusAfterRunRef.current = false
  162. }, [])
  163. const refocusEditorAfterRunIfNeeded = useCallback(() => {
  164. if (!shouldRefocusAfterRunRef.current) return
  165. shouldRefocusAfterRunRef.current = false
  166. refocusEditor()
  167. }, [refocusEditor])
  168. // generate a new snippet title and an id to be used for new snippets. The dependency on urlId is to avoid a bug which
  169. // shows up when clicking on the SQL Editor while being in the SQL editor on a random snippet.
  170. const [generatedNewSnippetName, generatedId] = useMemo(() => {
  171. const name = generateSnippetTitle()
  172. return [name, generateUuid([`${name}.sql`])]
  173. }, [urlId])
  174. // the id is stable across renders - it depends either on the url or on the memoized generated id
  175. const id = !urlId || urlId === 'new' ? generatedId : urlId
  176. const limit = snapV2.limit
  177. const results = snapV2.results[id]?.[0]
  178. const snippetIsLoading = !(
  179. id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined
  180. )
  181. const isLoading = urlId === 'new' ? false : snippetIsLoading
  182. useAddDefinitions(id, monacoRef.current)
  183. const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery(
  184. {
  185. projectRef: ref,
  186. },
  187. { enabled: isValidConnString(project?.connectionString) }
  188. )
  189. const { data: eventTriggers } = useDatabaseEventTriggersQuery(
  190. {
  191. projectRef: project?.ref,
  192. connectionString: project?.connectionString,
  193. },
  194. { enabled: isValidConnString(project?.connectionString) }
  195. )
  196. /* React query mutations */
  197. const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
  198. const { mutate: sendEvent } = useSendEventMutation()
  199. const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({
  200. onSuccess(data, vars) {
  201. if (id) {
  202. snapV2.addResult(id, data.result, vars.autoLimit)
  203. if (showPrettyExplain && isExplainQuery(data.result)) {
  204. snapV2.addExplainResult(id, data.result)
  205. setActiveUtilityTab('explain')
  206. } else if (activeUtilityTab === 'explain') {
  207. // If on Explain tab but ran a non-EXPLAIN query, switch to Results tab
  208. setActiveUtilityTab('results')
  209. }
  210. }
  211. // revalidate lint query
  212. queryClient.invalidateQueries({ queryKey: lintKeys.lint(ref) })
  213. refocusEditorAfterRunIfNeeded()
  214. },
  215. onError(error: any, vars) {
  216. if (id) {
  217. if (error.position && monacoRef.current) {
  218. const editor = editorRef.current
  219. const monaco = monacoRef.current
  220. const startLineNumber = hasSelection ? (editor?.getSelection()?.startLineNumber ?? 0) : 0
  221. const formattedError = error.formattedError ?? ''
  222. const lineError = formattedError.slice(formattedError.indexOf('LINE'))
  223. const line =
  224. startLineNumber + Number(lineError.slice(0, lineError.indexOf(':')).split(' ')[1])
  225. if (!isNaN(line)) {
  226. const decorations = editor?.deltaDecorations(
  227. [],
  228. [
  229. {
  230. range: new monaco.Range(line, 1, line, 20),
  231. options: {
  232. isWholeLine: true,
  233. inlineClassName: 'bg-warning-400',
  234. },
  235. },
  236. ]
  237. )
  238. if (decorations) {
  239. editor?.revealLineInCenter(line)
  240. setLineHighlights(decorations)
  241. }
  242. }
  243. }
  244. snapV2.addResultError(id, error, vars.autoLimit)
  245. }
  246. refocusEditorAfterRunIfNeeded()
  247. },
  248. })
  249. const { mutate: executeExplain, isPending: isExplainExecuting } = useExecuteSqlMutation({
  250. onSuccess(data) {
  251. if (id) {
  252. snapV2.addExplainResult(id, data.result)
  253. setActiveUtilityTab('explain')
  254. }
  255. },
  256. onError(error) {
  257. if (id) {
  258. snapV2.addExplainResultError(id, error)
  259. setActiveUtilityTab('explain')
  260. }
  261. },
  262. })
  263. const setAiTitle = useCallback(
  264. async (id: string, sql: string) => {
  265. try {
  266. const { title: name } = await generateSqlTitle({ sql })
  267. snapV2.updateSnippet({ id, snippet: { name } })
  268. snapV2.addNeedsSaving(id)
  269. const tabId = createTabId('sql', { id })
  270. tabs.updateTab(tabId, { label: name })
  271. } catch (error) {
  272. // [Joshen] No error handler required as this happens in the background and not necessary to ping the user
  273. }
  274. },
  275. [generateSqlTitle, snapV2]
  276. )
  277. const prettifyQuery = useCallback(async () => {
  278. if (isDiffOpen) return
  279. // use the latest state
  280. const state = getSqlEditorV2StateSnapshot()
  281. const snippet = state.snippets[id]
  282. if (editorRef.current && project) {
  283. const editor = editorRef.current
  284. const selection = editor.getSelection()
  285. const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined
  286. const sql = snippet
  287. ? ((selectedValue || editorRef.current?.getValue()) ??
  288. snippet.snippet.content?.unchecked_sql)
  289. : selectedValue || editorRef.current?.getValue()
  290. const formattedSql = formatSql(sql)
  291. const editorModel = editorRef?.current?.getModel()
  292. if (editorRef.current && editorModel) {
  293. editorRef.current.executeEdits('apply-prettify-edit', [
  294. {
  295. text: formattedSql,
  296. range: editorModel.getFullModelRange(),
  297. },
  298. ])
  299. snapV2.setSql({ id, sql: formattedSql })
  300. }
  301. }
  302. }, [id, isDiffOpen, project, snapV2])
  303. useShortcut(SHORTCUT_IDS.SQL_EDITOR_FORMAT, prettifyQuery, {
  304. registerInCommandMenu: true,
  305. })
  306. const executeQuery = useCallback(
  307. async (force: boolean = false, sqlOverride?: SafeSqlFragment) => {
  308. if (isDiffOpen) {
  309. clearPendingRunRefocus()
  310. return
  311. }
  312. // use the latest state
  313. const state = getSqlEditorV2StateSnapshot()
  314. const snippet = state.snippets[id]
  315. if (editorRef.current === null || isExecuting || project === undefined) {
  316. clearPendingRunRefocus()
  317. return
  318. }
  319. const editor = editorRef.current
  320. const selection = editor.getSelection()
  321. const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined
  322. const editorSql = snippet
  323. ? ((selectedValue || editorRef.current?.getValue()) ??
  324. snippet.snippet.content?.unchecked_sql)
  325. : selectedValue || editorRef.current?.getValue()
  326. const sql = sqlOverride ?? editorSql
  327. const hasDestructiveOperations = checkDestructiveQuery(sql)
  328. const hasUpdateWithoutWhere = isUpdateWithoutWhere(sql)
  329. const hasAlterDatabasePreventConnection = checkAlterDatabaseConnection(sql)
  330. const createTablesMissingRLS = filterTablesCoveredByEnsureRLSTrigger(
  331. getCreateTablesMissingRLS(sql),
  332. hasActiveEnsureRLSTrigger(eventTriggers)
  333. )
  334. const queryHasIssues =
  335. !force &&
  336. (hasDestructiveOperations ||
  337. hasUpdateWithoutWhere ||
  338. hasAlterDatabasePreventConnection ||
  339. createTablesMissingRLS.length > 0)
  340. if (queryHasIssues) {
  341. setPotentialIssues({
  342. hasDestructiveOperations,
  343. hasUpdateWithoutWhere,
  344. hasAlterDatabasePreventConnection,
  345. createTablesMissingRLS,
  346. })
  347. return
  348. }
  349. if (
  350. !isHipaaProjectDisallowed &&
  351. snippet?.snippet.name.startsWith(untitledSnippetTitle) &&
  352. IS_PLATFORM
  353. ) {
  354. // Intentionally don't await title gen (lazy)
  355. setAiTitle(id, sql)
  356. }
  357. if (lineHighlights.length > 0) {
  358. editor?.deltaDecorations(lineHighlights, [])
  359. setLineHighlights([])
  360. }
  361. const impersonatedRoleState = getImpersonatedRoleState()
  362. const connectionString = databases?.find(
  363. (db) => db.identifier === databaseSelectorState.selectedDatabaseId
  364. )?.connectionString
  365. if (!isValidConnString(connectionString)) {
  366. clearPendingRunRefocus()
  367. return toast.error('Unable to run query: Connection string is missing')
  368. }
  369. const userSql = rawSql(sql)
  370. const { appendAutoLimit } = checkIfAppendLimitRequired(userSql, limit)
  371. const formattedSql = suffixWithLimit(userSql, limit)
  372. execute({
  373. projectRef: project.ref,
  374. connectionString: connectionString,
  375. sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState),
  376. autoLimit: appendAutoLimit ? limit : undefined,
  377. isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
  378. isStatementTimeoutDisabled: true,
  379. contextualInvalidation: true,
  380. handleError: (error) => {
  381. throw error
  382. },
  383. })
  384. sendEvent({
  385. action: 'sql_editor_query_run_button_clicked',
  386. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  387. })
  388. },
  389. // eslint-disable-next-line react-hooks/exhaustive-deps
  390. [
  391. clearPendingRunRefocus,
  392. isDiffOpen,
  393. id,
  394. isExecuting,
  395. project,
  396. isHipaaProjectDisallowed,
  397. execute,
  398. getImpersonatedRoleState,
  399. setAiTitle,
  400. databaseSelectorState.selectedDatabaseId,
  401. databases,
  402. eventTriggers,
  403. limit,
  404. ]
  405. )
  406. const executeQueryFromButton = useCallback(() => {
  407. shouldRefocusAfterRunRef.current = true
  408. refocusEditor()
  409. void executeQuery()
  410. }, [executeQuery, refocusEditor])
  411. const executeExplainQuery = useCallback(async () => {
  412. if (isDiffOpen) return
  413. // use the latest state
  414. const state = getSqlEditorV2StateSnapshot()
  415. const snippet = state.snippets[id]
  416. if (editorRef.current !== null && !isExplainExecuting && project !== undefined) {
  417. const editor = editorRef.current
  418. const selection = editor.getSelection()
  419. const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined
  420. const sql = snippet
  421. ? ((selectedValue || editorRef.current?.getValue()) ??
  422. snippet.snippet.content?.unchecked_sql)
  423. : selectedValue || editorRef.current?.getValue()
  424. // Check for multiple statements - EXPLAIN only works on a single statement
  425. const statements = splitSqlStatements(sql)
  426. if (statements.length > 1) {
  427. snapV2.addExplainResultError(id, {
  428. message:
  429. 'EXPLAIN only works on a single SQL statement. Please select just one query to analyze.',
  430. })
  431. setActiveUtilityTab('explain')
  432. return
  433. }
  434. if (lineHighlights.length > 0) {
  435. editor?.deltaDecorations(lineHighlights, [])
  436. setLineHighlights([])
  437. }
  438. const impersonatedRoleState = getImpersonatedRoleState()
  439. const connectionString = databases?.find(
  440. (db) => db.identifier === databaseSelectorState.selectedDatabaseId
  441. )?.connectionString
  442. if (!isValidConnString(connectionString)) {
  443. return toast.error('Unable to run query: Connection string is missing')
  444. }
  445. // Wrap the query with EXPLAIN ANALYZE only if it's not already an EXPLAIN query
  446. const userSql = rawSql(sql ?? '')
  447. const explainSql = isExplainSql(sql) ? userSql : safeSql`EXPLAIN ANALYZE ${userSql}`
  448. // Wrap EXPLAIN queries in a transaction with rollback to prevent data modifications
  449. // This ensures EXPLAIN ANALYZE INSERT/UPDATE/DELETE queries don't actually modify data
  450. const explainSqlWithTransaction = wrapWithRollback(
  451. wrapWithRoleImpersonation(explainSql, impersonatedRoleState)
  452. )
  453. executeExplain({
  454. projectRef: project.ref,
  455. connectionString: connectionString,
  456. sql: explainSqlWithTransaction,
  457. isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
  458. handleError: (error) => {
  459. throw error
  460. },
  461. })
  462. }
  463. }, [
  464. isDiffOpen,
  465. id,
  466. isExplainExecuting,
  467. project,
  468. executeExplain,
  469. getImpersonatedRoleState,
  470. databaseSelectorState.selectedDatabaseId,
  471. databases,
  472. lineHighlights,
  473. snapV2,
  474. ])
  475. useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, {
  476. registerInCommandMenu: true,
  477. })
  478. const handleNewQuery = useCallback(
  479. async (sql: string, name: string) => {
  480. if (!ref) return console.error('Project ref is required')
  481. if (!profile) return console.error('Profile is required')
  482. if (!project) return console.error('Project is required')
  483. try {
  484. const snippet = createSqlSnippetSkeletonV2({
  485. name,
  486. sql,
  487. owner_id: profile.id,
  488. project_id: project.id,
  489. })
  490. snapV2.addSnippet({ projectRef: ref, snippet })
  491. snapV2.addNeedsSaving(snippet.id!)
  492. router.push(`/project/${ref}/sql/${snippet.id}`)
  493. } catch (error: any) {
  494. toast.error(`Failed to create new query: ${error.message}`)
  495. }
  496. },
  497. // eslint-disable-next-line react-hooks/exhaustive-deps
  498. [profile?.id, project?.id, ref, router, snapV2]
  499. )
  500. const onMount = (editor: IStandaloneCodeEditor) => {
  501. const tabId = createTabId('sql', { id })
  502. const tabData = tabs.tabsMap[tabId]
  503. // [Joshen] Tiny timeout to give a bit of time for the content to load before scrolling
  504. setTimeout(() => {
  505. if (tabData?.metadata?.scrollTop) {
  506. editor.setScrollTop(tabData.metadata.scrollTop)
  507. }
  508. }, 20)
  509. editor.onDidScrollChange((e) => (scrollTopRef.current = e.scrollTop))
  510. }
  511. const buildDebugPrompt = useCallback(() => {
  512. const snippet = snapV2.snippets[id]
  513. const result = snapV2.results[id]?.[0]
  514. const sql = (snippet?.snippet.content?.unchecked_sql ?? '')
  515. .replace(sqlAiDisclaimerComment, '')
  516. .trim()
  517. const errorMessage = result?.error?.message ?? 'Unknown error'
  518. const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`
  519. return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\``
  520. }, [id, snapV2.results, snapV2.snippets])
  521. const onDebug = useCallback(async () => {
  522. try {
  523. const snippet = snapV2.snippets[id]
  524. const result = snapV2.results[id]?.[0]
  525. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  526. aiSnap.newChat({
  527. name: 'Debug SQL snippet',
  528. sqlSnippets: [
  529. (snippet.snippet.content?.unchecked_sql ?? '').replace(sqlAiDisclaimerComment, '').trim(),
  530. ],
  531. initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${result.error.message}`,
  532. })
  533. } catch (error: unknown) {
  534. // [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message
  535. // that's not relevant for the user - so we prettify it here by avoiding to return the
  536. // entire error body from the assistant
  537. if (isError(error)) {
  538. toast.error(
  539. `Sorry, the assistant failed to debug your query! Please try again with a different one.`
  540. )
  541. }
  542. }
  543. // eslint-disable-next-line react-hooks/exhaustive-deps
  544. }, [id, snapV2.results, snapV2.snippets])
  545. const acceptAiHandler = useCallback(async () => {
  546. try {
  547. setIsAcceptDiffLoading(true)
  548. // TODO: show error if undefined
  549. if (!sourceSqlDiff || !editorRef.current || !diffEditorRef.current) return
  550. const editorModel = editorRef.current.getModel()
  551. const diffModel = diffEditorRef.current.getModel()
  552. if (!editorModel || !diffModel) return
  553. const sql = diffModel.modified.getValue()
  554. if (selectedDiffType === DiffType.NewSnippet) {
  555. const { title } = await generateSqlTitle({ sql })
  556. await handleNewQuery(sql, title)
  557. } else {
  558. editorRef.current.executeEdits('apply-ai-edit', [
  559. {
  560. text: sql,
  561. range: editorModel.getFullModelRange(),
  562. },
  563. ])
  564. }
  565. sendEvent({
  566. action: 'assistant_sql_diff_handler_evaluated',
  567. properties: { handlerAccepted: true },
  568. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  569. })
  570. setSelectedDiffType(DiffType.Modification)
  571. resetPrompt()
  572. closeDiff()
  573. } finally {
  574. setIsAcceptDiffLoading(false)
  575. }
  576. // eslint-disable-next-line react-hooks/exhaustive-deps
  577. }, [sourceSqlDiff, selectedDiffType, handleNewQuery, generateSqlTitle, router, id, snapV2])
  578. const discardAiHandler = useCallback(() => {
  579. sendEvent({
  580. action: 'assistant_sql_diff_handler_evaluated',
  581. properties: { handlerAccepted: false },
  582. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  583. })
  584. resetPrompt()
  585. closeDiff()
  586. }, [closeDiff, resetPrompt, sendEvent])
  587. const [isCompletionLoading, setIsCompletionLoading] = useState<boolean>(false)
  588. const complete = useCallback(
  589. async (
  590. _prompt: string,
  591. options?: {
  592. headers?: Record<string, string>
  593. body?: { completionMetadata?: any }
  594. }
  595. ) => {
  596. try {
  597. setIsCompletionLoading(true)
  598. const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
  599. method: 'POST',
  600. headers: {
  601. 'Content-Type': 'application/json',
  602. ...(options?.headers ?? {}),
  603. },
  604. body: JSON.stringify({
  605. projectRef: project?.ref,
  606. connectionString: project?.connectionString,
  607. language: 'sql',
  608. orgSlug: org?.slug,
  609. ...(options?.body ?? {}),
  610. }),
  611. })
  612. if (!response.ok) {
  613. const errorText = await response.text()
  614. throw new Error(errorText || 'Failed to generate completion')
  615. }
  616. // API returns a JSON-encoded string
  617. const text: string = await response.json()
  618. const meta = options?.body?.completionMetadata ?? {}
  619. const beforeSelection: string = meta.textBeforeCursor ?? ''
  620. const afterSelection: string = meta.textAfterCursor ?? ''
  621. const selection: string = meta.selection ?? ''
  622. const original = beforeSelection + selection + afterSelection
  623. const modified = beforeSelection + text + afterSelection
  624. const formattedModified = formatSql(modified)
  625. setSourceSqlDiff({ original, modified: formattedModified })
  626. setSelectedDiffType(DiffType.Modification)
  627. setPromptState((prev) => ({ ...prev, isLoading: false }))
  628. setIsCompletionLoading(false)
  629. } catch (error: any) {
  630. toast.error(`Failed to generate SQL: ${error?.message ?? 'Unknown error'}`)
  631. setIsCompletionLoading(false)
  632. throw error
  633. }
  634. },
  635. [
  636. org?.slug,
  637. project?.connectionString,
  638. project?.ref,
  639. setPromptState,
  640. setSelectedDiffType,
  641. setSourceSqlDiff,
  642. ]
  643. )
  644. const handlePrompt = async (
  645. prompt: string,
  646. context: {
  647. beforeSelection: string
  648. selection: string
  649. afterSelection: string
  650. }
  651. ) => {
  652. try {
  653. setPromptState((prev) => ({
  654. ...prev,
  655. selection: context.selection,
  656. beforeSelection: context.beforeSelection,
  657. afterSelection: context.afterSelection,
  658. }))
  659. const headerData = await constructHeaders()
  660. const authorizationHeader = headerData.get('Authorization')
  661. await complete(prompt, {
  662. ...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } : undefined),
  663. body: {
  664. completionMetadata: {
  665. textBeforeCursor: context.beforeSelection,
  666. textAfterCursor: context.afterSelection,
  667. language: 'pgsql',
  668. prompt,
  669. selection: context.selection,
  670. },
  671. },
  672. })
  673. } catch (error) {
  674. setPromptState((prev) => ({ ...prev, isLoading: false }))
  675. }
  676. }
  677. /** All useEffects are at the bottom before returning the TSX */
  678. useEffect(() => {
  679. if (id) {
  680. closeDiff()
  681. setPromptState((prev) => ({ ...prev, isOpen: false }))
  682. }
  683. return () => {
  684. if (ref) {
  685. const tabId = createTabId('sql', { id })
  686. tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
  687. }
  688. }
  689. // eslint-disable-next-line react-hooks/exhaustive-deps
  690. }, [closeDiff, id])
  691. useEffect(() => {
  692. const handler = (e: KeyboardEvent) => {
  693. if (!isDiffOpen && !promptState.isOpen) return
  694. switch (e.key) {
  695. case 'Enter':
  696. if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) {
  697. acceptAiHandler()
  698. resetPrompt()
  699. }
  700. return
  701. case 'Escape':
  702. if (isDiffOpen) discardAiHandler()
  703. resetPrompt()
  704. editorRef.current?.focus()
  705. return
  706. }
  707. }
  708. window.addEventListener('keydown', handler)
  709. return () => window.removeEventListener('keydown', handler)
  710. }, [os, isDiffOpen, promptState.isOpen, acceptAiHandler, discardAiHandler, resetPrompt])
  711. useEffect(() => {
  712. if (isDiffOpen) {
  713. const diffEditor = diffEditorRef.current
  714. const model = diffEditor?.getModel()
  715. if (model && model.original && model.modified) {
  716. model.original.setValue(defaultSqlDiff.original)
  717. model.modified.setValue(defaultSqlDiff.modified)
  718. // scroll to the start line of the modification
  719. const modifiedEditor = diffEditor!.getModifiedEditor()
  720. const startLine = promptState.startLineNumber
  721. modifiedEditor.revealLineInCenter(startLine)
  722. }
  723. }
  724. // eslint-disable-next-line react-hooks/exhaustive-deps
  725. }, [selectedDiffType, sourceSqlDiff])
  726. useEffect(() => {
  727. if (isSuccessReadReplicas) {
  728. const primaryDatabase = databases.find((db) => db.identifier === ref)
  729. databaseSelectorState.setSelectedDatabaseId(primaryDatabase?.identifier)
  730. }
  731. // eslint-disable-next-line react-hooks/exhaustive-deps
  732. }, [isSuccessReadReplicas, databases, ref])
  733. useEffect(() => {
  734. if (snapV2.diffContent !== undefined) {
  735. const { diffType, sql }: { diffType: DiffType; sql: string } = snapV2.diffContent
  736. const editorModel = editorRef.current?.getModel()
  737. if (!editorModel) return
  738. const existingValue = editorRef.current?.getValue() ?? ''
  739. if (existingValue.length === 0) {
  740. // if the editor is empty, just copy over the code
  741. editorRef.current?.executeEdits('apply-ai-message', [
  742. {
  743. text: `${sql}`,
  744. range: editorModel.getFullModelRange(),
  745. },
  746. ])
  747. } else {
  748. const currentSql = editorRef.current?.getValue()
  749. const diff = { original: currentSql || '', modified: sql }
  750. setSourceSqlDiff(diff)
  751. setSelectedDiffType(diffType)
  752. }
  753. }
  754. // eslint-disable-next-line react-hooks/exhaustive-deps
  755. }, [snapV2.diffContent])
  756. // We want to check if the diff editor is mounted and if it is, we want to show the widget
  757. // We also want to cleanup the widget when the diff editor is closed
  758. useEffect(() => {
  759. if (!isDiffOpen) {
  760. setIsDiffEditorMounted(false)
  761. setShowWidget(false)
  762. } else if (diffEditorRef.current && isDiffEditorMounted) {
  763. setShowWidget(true)
  764. return () => setShowWidget(false)
  765. }
  766. }, [isDiffOpen, isDiffEditorMounted])
  767. return (
  768. <>
  769. <RunQueryWarningModal
  770. visible={!!potentialIssues}
  771. potentialIssues={potentialIssues}
  772. onCancel={() => {
  773. clearPendingRunRefocus()
  774. setPotentialIssues(undefined)
  775. refocusEditor()
  776. }}
  777. onConfirm={() => {
  778. shouldRefocusAfterRunRef.current = true
  779. setPotentialIssues(undefined)
  780. refocusEditor()
  781. void executeQuery(true)
  782. }}
  783. onConfirmWithRLS={() => {
  784. const tables = potentialIssues?.createTablesMissingRLS ?? []
  785. if (tables.length === 0) return
  786. const editor = editorRef.current
  787. const selection = editor?.getSelection()
  788. const selectedValue = selection
  789. ? editor?.getModel()?.getValueInRange(selection)
  790. : undefined
  791. const baseSql = selectedValue || editor?.getValue() || ''
  792. const rewrittenSql = appendEnableRLSStatements(baseSql, tables)
  793. shouldRefocusAfterRunRef.current = true
  794. setPotentialIssues(undefined)
  795. refocusEditor()
  796. void executeQuery(true, acceptUntrustedSql(rewrittenSql as UntrustedSqlFragment))
  797. }}
  798. />
  799. <div className="flex h-full">
  800. <ResizablePanelGroup
  801. className="relative"
  802. orientation="vertical"
  803. autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
  804. >
  805. <ResizablePanel defaultSize="50" maxSize="70">
  806. <div className="grow overflow-y-auto border-b h-full">
  807. {isLoading ? (
  808. <div className="flex h-full w-full items-center justify-center">
  809. <Loader2 className="animate-spin text-brand" />
  810. </div>
  811. ) : (
  812. <>
  813. {isDiffOpen && (
  814. <div className="w-full h-full">
  815. <DiffEditor
  816. language="pgsql"
  817. original={defaultSqlDiff.original}
  818. modified={defaultSqlDiff.modified}
  819. onMount={(editor) => {
  820. diffEditorRef.current = editor
  821. setIsDiffEditorMounted(true)
  822. }}
  823. />
  824. {showWidget && (
  825. <ResizableAIWidget
  826. editor={diffEditorRef.current!}
  827. id="ask-ai-diff"
  828. value={promptInput}
  829. onChange={setPromptInput}
  830. onSubmit={(prompt: string) => {
  831. handlePrompt(prompt, {
  832. beforeSelection: promptState.beforeSelection,
  833. selection: promptState.selection || defaultSqlDiff.modified,
  834. afterSelection: promptState.afterSelection,
  835. })
  836. }}
  837. onAccept={acceptAiHandler}
  838. onReject={discardAiHandler}
  839. onCancel={resetPrompt}
  840. isDiffVisible={true}
  841. isLoading={isCompletionLoading}
  842. startLineNumber={Math.max(0, promptState.startLineNumber)}
  843. endLineNumber={promptState.endLineNumber}
  844. />
  845. )}
  846. </div>
  847. )}
  848. <div key={id} className="w-full h-full relative">
  849. <MonacoEditor
  850. autoFocus
  851. placeholder={
  852. !promptState.isOpen && !editorRef.current?.getValue()
  853. ? 'Hit ' +
  854. (os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
  855. ' to generate query or just start typing'
  856. : ''
  857. }
  858. id={id}
  859. snippetName={
  860. urlId === 'new'
  861. ? generatedNewSnippetName
  862. : (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
  863. }
  864. className={cn(isDiffOpen && 'hidden')}
  865. editorRef={editorRef}
  866. monacoRef={monacoRef}
  867. executeQuery={executeQuery}
  868. executeExplainQuery={executeExplainQuery}
  869. prettifyQuery={prettifyQuery}
  870. onHasSelection={setHasSelection}
  871. onMount={onMount}
  872. onPrompt={({
  873. selection,
  874. beforeSelection,
  875. afterSelection,
  876. startLineNumber,
  877. endLineNumber,
  878. }) => {
  879. setPromptState((prev) => ({
  880. ...prev,
  881. isOpen: true,
  882. selection,
  883. beforeSelection,
  884. afterSelection,
  885. startLineNumber,
  886. endLineNumber,
  887. }))
  888. }}
  889. />
  890. {editorRef.current && promptState.isOpen && !isDiffOpen && (
  891. <ResizableAIWidget
  892. editor={editorRef.current}
  893. id="ask-ai"
  894. value={promptInput}
  895. onChange={setPromptInput}
  896. onSubmit={(prompt: string) => {
  897. handlePrompt(prompt, {
  898. beforeSelection: promptState.beforeSelection,
  899. selection: promptState.selection,
  900. afterSelection: promptState.afterSelection,
  901. })
  902. }}
  903. onCancel={resetPrompt}
  904. isDiffVisible={false}
  905. isLoading={isCompletionLoading}
  906. startLineNumber={Math.max(0, promptState.startLineNumber)}
  907. endLineNumber={promptState.endLineNumber}
  908. />
  909. )}
  910. </div>
  911. </>
  912. )}
  913. </div>
  914. </ResizablePanel>
  915. <ResizableHandle withHandle />
  916. <ResizablePanel defaultSize="50" maxSize="70">
  917. {isLoading ? (
  918. <div className="flex h-full w-full items-center justify-center">
  919. <Loader2 className="animate-spin text-brand" />
  920. </div>
  921. ) : (
  922. <UtilityPanel
  923. id={id}
  924. isExecuting={isExecuting}
  925. isExplainExecuting={isExplainExecuting}
  926. isDisabled={isDiffOpen}
  927. hasSelection={hasSelection}
  928. prettifyQuery={prettifyQuery}
  929. executeQuery={executeQueryFromButton}
  930. executeExplainQuery={executeExplainQuery}
  931. onDebug={onDebug}
  932. buildDebugPrompt={buildDebugPrompt}
  933. activeTab={activeUtilityTab}
  934. onActiveTabChange={setActiveUtilityTab}
  935. />
  936. )}
  937. </ResizablePanel>
  938. <div className="h-9">
  939. {results?.rows !== undefined && !isExecuting && (
  940. <GridFooter className="flex items-center justify-between gap-2">
  941. <Tooltip>
  942. <TooltipTrigger>
  943. <p className="text-xs">
  944. <span className="text-foreground">
  945. {results.rows.length} row{results.rows.length > 1 ? 's' : ''}
  946. </span>
  947. <span className="text-foreground-lighter ml-1">
  948. {results.autoLimit !== undefined &&
  949. ` (Limited to only ${results.autoLimit} rows)`}
  950. </span>
  951. </p>
  952. </TooltipTrigger>
  953. <TooltipContent className="max-w-xs">
  954. <p className="flex flex-col gap-y-1">
  955. <span>
  956. Results are automatically limited to preserve browser performance, in
  957. particular if your query returns an exceptionally large number of rows.
  958. </span>
  959. <span className="text-foreground-light">
  960. You may change or remove this limit from the dropdown on the right
  961. </span>
  962. </p>
  963. </TooltipContent>
  964. </Tooltip>
  965. {results.autoLimit !== undefined && (
  966. <DropdownMenu>
  967. <DropdownMenuTrigger asChild>
  968. <Button type="default" iconRight={<ChevronUp size={14} />}>
  969. Limit results to:{' '}
  970. {ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === snapV2.limit)?.label}
  971. </Button>
  972. </DropdownMenuTrigger>
  973. <DropdownMenuContent className="w-40" align="end">
  974. <DropdownMenuRadioGroup
  975. value={snapV2.limit.toString()}
  976. onValueChange={(val) => snapV2.setLimit(Number(val))}
  977. >
  978. {ROWS_PER_PAGE_OPTIONS.map((option) => (
  979. <DropdownMenuRadioItem key={option.label} value={option.value.toString()}>
  980. {option.label}
  981. </DropdownMenuRadioItem>
  982. ))}
  983. </DropdownMenuRadioGroup>
  984. </DropdownMenuContent>
  985. </DropdownMenu>
  986. )}
  987. </GridFooter>
  988. )}
  989. </div>
  990. </ResizablePanelGroup>
  991. </div>
  992. </>
  993. )
  994. }