sql-editor-v2.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import { untrustedSql } from '@supabase/pg-meta'
  2. import { debounce, memoize } from 'lodash'
  3. import { useMemo } from 'react'
  4. import { toast } from 'sonner'
  5. import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
  6. import { devtools, proxyMap } from 'valtio/utils'
  7. import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
  8. import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types'
  9. import { upsertContent, UpsertContentPayload } from '@/data/content/content-upsert-mutation'
  10. import { contentKeys } from '@/data/content/keys'
  11. import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation'
  12. import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation'
  13. import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
  14. import { getQueryClient } from '@/data/query-client'
  15. import type { SqlSnippets } from '@/types'
  16. type StateSnippetFolder = {
  17. projectRef: string
  18. folder: SnippetFolder
  19. status?: 'editing' | 'saving' | 'idle'
  20. }
  21. type StateSnippet = {
  22. projectRef: string
  23. splitSizes: number[]
  24. snippet: SnippetWithContent
  25. }
  26. // [Joshen] API codegen is somehow missing the content property
  27. export interface SnippetWithContent extends Snippet {
  28. content?: SqlSnippets.Content
  29. isNotSavedInDatabaseYet?: boolean
  30. }
  31. const NEW_FOLDER_ID = 'new-folder'
  32. export const sqlEditorState = proxy({
  33. // ========================================================================
  34. // ## Data properties within the store
  35. // ========================================================================
  36. /**
  37. * Currently limitations include supporting up to one level of folders from root, and only private snippets
  38. */
  39. folders: {} as {
  40. [folderId: string]: StateSnippetFolder
  41. },
  42. /**
  43. * Private and shared snippets only, favorite snippets are derivatives of them by the `favorite` property
  44. */
  45. snippets: {} as {
  46. [snippetId: string]: StateSnippet
  47. },
  48. /**
  49. * Query results, if any, for a snippet. Set as an array per snippetId as we were previously experimenting
  50. * with having a Jupyter notebook like UI but it never took off. Nonetheless kept this data structure as
  51. * we'd also want to support returning multiple results from a single query (e.g From a query that contains
  52. * multiple select statements), and this will allow us to do quite easily.
  53. */
  54. results: {} as {
  55. [snippetId: string]: {
  56. rows: any[]
  57. error?: any
  58. autoLimit?: number
  59. }[]
  60. },
  61. /**
  62. * Explain results, if any, for a snippet
  63. */
  64. explainResults: {} as {
  65. [snippetId: string]: {
  66. rows: QueryPlanRow[]
  67. error?: { message: string; formattedError?: string }
  68. }
  69. },
  70. /**
  71. * Synchronous saving of folders and snippets (debounce behavior). Key is the snippet id, value is shouldInvalidate
  72. */
  73. needsSaving: proxyMap<string, boolean>([]),
  74. /**
  75. * Stores the state of each snippet
  76. */
  77. savingStates: {} as {
  78. [snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED'
  79. },
  80. /**
  81. * UI-imposed limit for the number of results a query can return (applied to the SQL query being run if applicable).
  82. * Acts as a safeguard to prevent accidentally taking down the database from a really large SELECT query.
  83. * Related to `autoLimit` in `results`. Refer to `checkIfAppendLimitRequired` and `suffixWithLimit` for usage.
  84. */
  85. limit: 100,
  86. /**
  87. * Used for error handling after optimistical rendering from renaming a folder
  88. */
  89. lastUpdatedFolderName: '',
  90. /**
  91. * For Assistant to render diffing into the editor
  92. */
  93. diffContent: undefined as undefined | { sql: string; diffType: DiffType },
  94. get allFolderNames() {
  95. return Object.values(sqlEditorState.folders).map((x) => x.folder.name)
  96. },
  97. // ========================================================================
  98. // ## Methods to interact the store with
  99. // ========================================================================
  100. setDiffContent: (sql: string, diffType: DiffType) =>
  101. (sqlEditorState.diffContent = { sql, diffType }),
  102. /**
  103. * Load snippet into SQL Editor Valtio store
  104. */
  105. addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: SnippetWithContent }) => {
  106. if (sqlEditorState.snippets[snippet.id]) return
  107. sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
  108. sqlEditorState.results[snippet.id] = []
  109. sqlEditorState.explainResults[snippet.id] = { rows: [] }
  110. sqlEditorState.savingStates[snippet.id] = 'IDLE'
  111. },
  112. /**
  113. * Update snippet data (e.g name, visibility, chart) and queue for sync saving
  114. */
  115. updateSnippet: ({
  116. id,
  117. snippet,
  118. skipSave = false,
  119. }: {
  120. id: string
  121. snippet: Partial<Snippet>
  122. skipSave?: boolean
  123. }) => {
  124. if (sqlEditorState.snippets[id]) {
  125. sqlEditorState.snippets[id].snippet = {
  126. ...sqlEditorState.snippets[id].snippet,
  127. ...snippet,
  128. }
  129. if (!skipSave) sqlEditorState.needsSaving.set(id, true)
  130. }
  131. },
  132. /**
  133. * Load snippet content into the snippet within the Valtio store.
  134. * Snippets fetched from the GET /content or /folders endpoints do not have the content loaded initially
  135. * to reduce the response size from the API. Hence content for each snippet has to be loaded on demand
  136. */
  137. setSnippet: (projectRef: string, snippet: SnippetWithContent) => {
  138. let storedSnippet = sqlEditorState.snippets[snippet.id]
  139. if (storedSnippet) {
  140. if (!storedSnippet.snippet.content) {
  141. storedSnippet.snippet.content = snippet.content
  142. }
  143. } else {
  144. sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
  145. }
  146. },
  147. /**
  148. * Update the snippet content of a snippet and queue for sync saving
  149. * Possibly can consolidate with `updateSnippet` to simplify
  150. */
  151. setSql: ({
  152. id,
  153. sql,
  154. shouldInvalidate = false,
  155. }: {
  156. id: string
  157. sql: string
  158. shouldInvalidate?: boolean
  159. }) => {
  160. let snippet = sqlEditorState.snippets[id]?.snippet
  161. if (snippet?.content) {
  162. snippet.content.unchecked_sql = untrustedSql(sql)
  163. sqlEditorState.needsSaving.set(id, shouldInvalidate)
  164. }
  165. },
  166. /**
  167. * Update snippet in Valtio store after renaming
  168. * Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
  169. * Refer to `RenameQueryModal.tsx` for more details
  170. */
  171. renameSnippet: ({
  172. id,
  173. name,
  174. description,
  175. }: {
  176. id: string
  177. name: string
  178. description?: string
  179. }) => {
  180. let snippet = sqlEditorState.snippets[id]?.snippet
  181. if (snippet) {
  182. snippet.name = name
  183. snippet.description = description
  184. }
  185. },
  186. /**
  187. * Remove snippet from the Valtio store, and optionally remove snippet from the sync saving queue
  188. */
  189. removeSnippet: (id: string, skipSave: boolean = false) => {
  190. const { [id]: snippet, ...otherSnippets } = sqlEditorState.snippets
  191. sqlEditorState.snippets = otherSnippets
  192. const { [id]: result, ...otherResults } = sqlEditorState.results
  193. sqlEditorState.results = otherResults
  194. const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults
  195. sqlEditorState.explainResults = otherExplainResults
  196. if (!skipSave) sqlEditorState.needsSaving.delete(id)
  197. },
  198. /**
  199. * Load folder into SQL Editor Valtio store
  200. */
  201. addFolder: ({ projectRef, folder }: { projectRef: string; folder: SnippetFolder }) => {
  202. if (sqlEditorState.folders[folder.id]) return
  203. sqlEditorState.folders[folder.id] = { projectRef, folder }
  204. },
  205. /**
  206. * Adds a new folder placeholder for the UI to render
  207. */
  208. addNewFolder: ({ projectRef }: { projectRef: string }) => {
  209. // [Joshen] Use this to identify new folders that have yet to be saved
  210. const id = NEW_FOLDER_ID
  211. sqlEditorState.folders[id] = {
  212. projectRef,
  213. status: 'editing',
  214. folder: {
  215. id,
  216. name: '',
  217. owner_id: -1,
  218. project_id: -1,
  219. parent_id: null,
  220. },
  221. }
  222. },
  223. editFolder: (id: string) => {
  224. sqlEditorState.folders[id].status = 'editing'
  225. },
  226. /**
  227. * For renaming a folder, queue for sync saving if pass all validations
  228. */
  229. saveFolder: ({ id, name }: { id: string; name: string }) => {
  230. let storeFolder = sqlEditorState.folders[id]
  231. const isNewFolder = id === 'new-folder'
  232. const hasChanges = storeFolder.folder.name !== name
  233. if (isNewFolder && sqlEditorState.allFolderNames.includes(name)) {
  234. sqlEditorState.removeFolder(id)
  235. return toast.error('Unable to create new folder: This folder name already exists')
  236. } else if (hasChanges && sqlEditorState.allFolderNames.includes(name)) {
  237. storeFolder.status = 'idle'
  238. return toast.error('Unable to update folder: This folder name already exists')
  239. }
  240. const originalFolderName = storeFolder.folder.name.slice()
  241. storeFolder.status = hasChanges ? 'saving' : 'idle'
  242. storeFolder.folder.id = id
  243. storeFolder.folder.name = name
  244. if (hasChanges) {
  245. sqlEditorState.lastUpdatedFolderName = originalFolderName
  246. sqlEditorState.needsSaving.set(id, true)
  247. }
  248. },
  249. /**
  250. * Remove folder from the Valtio store
  251. * Deleting a folder follows an async saving and hence doesnt require queuing for sync saving here
  252. * Refer to `SQLEditorNav` for more details (ConfirmationModal for deleting a folder)
  253. */
  254. removeFolder: (id: string) => {
  255. const { [id]: folder, ...otherFolders } = sqlEditorState.folders
  256. sqlEditorState.folders = otherFolders
  257. },
  258. /**
  259. * Set the value for the auto limit for SELECT based SQL queries
  260. */
  261. setLimit: (value: number) => (sqlEditorState.limit = value),
  262. addNeedsSaving: (id: string) => sqlEditorState.needsSaving.set(id, true),
  263. addFavorite: (id: string) => {
  264. const storeSnippet = sqlEditorState.snippets[id]
  265. if (storeSnippet) {
  266. storeSnippet.snippet.favorite = true
  267. sqlEditorState.needsSaving.set(id, true)
  268. }
  269. },
  270. removeFavorite: (id: string) => {
  271. const storeSnippet = sqlEditorState.snippets[id]
  272. if (storeSnippet.snippet) {
  273. storeSnippet.snippet.favorite = false
  274. sqlEditorState.needsSaving.set(id, true)
  275. }
  276. },
  277. addResult: (id: string, results: any[], autoLimit?: number) => {
  278. if (sqlEditorState.results[id]) {
  279. // Use ref() to prevent Valtio from creating proxies for each row object.
  280. // This is critical for large result sets - without ref(), Valtio wraps every
  281. // row and nested property in a Proxy, causing massive memory overhead.
  282. // Alright to use ref() in this case as the data is meant to be read-only and we
  283. // don't need to track changes to the underlying data
  284. sqlEditorState.results[id] = [{ rows: ref(results), autoLimit }]
  285. }
  286. },
  287. addResultError: (id: string, error: any, autoLimit?: number) => {
  288. if (sqlEditorState.results[id]) {
  289. sqlEditorState.results[id] = [{ rows: ref([]), error, autoLimit }]
  290. }
  291. },
  292. resetResult: (id: string) => {
  293. if (sqlEditorState.results[id]) {
  294. sqlEditorState.results[id] = []
  295. }
  296. },
  297. addExplainResult: (id: string, results: QueryPlanRow[]) => {
  298. // Use ref() to prevent Valtio from creating proxies for each row object
  299. sqlEditorState.explainResults[id] = { rows: ref(results) }
  300. },
  301. addExplainResultError: (id: string, error: { message: string; formattedError?: string }) => {
  302. sqlEditorState.explainResults[id] = { rows: ref([]), error }
  303. },
  304. resetExplainResult: (id: string) => {
  305. sqlEditorState.explainResults[id] = { rows: [] }
  306. },
  307. resetResults: (id: string) => {
  308. sqlEditorState.resetResult(id)
  309. sqlEditorState.resetExplainResult(id)
  310. },
  311. })
  312. // ========================================================================
  313. // ## Expose entry points into this Valtio store
  314. // ========================================================================
  315. export const getSqlEditorV2StateSnapshot = () => snapshot(sqlEditorState)
  316. export const useSqlEditorV2StateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
  317. useSnapshot(sqlEditorState, options)
  318. export const useSnippetFolders = (projectRef: string) => {
  319. const snapshot = useSqlEditorV2StateSnapshot()
  320. return useMemo(
  321. () =>
  322. Object.values(snapshot.folders)
  323. .filter((x) => x.projectRef === projectRef)
  324. .map((x) => x.folder)
  325. // folders don't have created_at or inserted_at, so we always sort by name
  326. .sort((a, b) => a.name.localeCompare(b.name)),
  327. [projectRef, snapshot.folders]
  328. )
  329. }
  330. /**
  331. * Get ALL snippets for a project
  332. */
  333. export const useSnippets = (projectRef: string) => {
  334. const snapshot = useSqlEditorV2StateSnapshot()
  335. return useMemo(
  336. () =>
  337. Object.values(snapshot.snippets)
  338. .filter((storeSnippet) => storeSnippet.projectRef === projectRef)
  339. .map((storeSnippet) => storeSnippet.snippet),
  340. [projectRef, snapshot.snippets]
  341. )
  342. }
  343. // ========================================================================
  344. // ## Below are all the asynchronous saving logic for the SQL Editor
  345. // ========================================================================
  346. async function upsertSnippet(
  347. id: string,
  348. projectRef: string,
  349. payload: UpsertContentPayload,
  350. shouldInvalidate = false
  351. ) {
  352. try {
  353. sqlEditorState.savingStates[id] = 'UPDATING'
  354. await upsertContent({ projectRef, payload })
  355. if (shouldInvalidate) {
  356. const queryClient = getQueryClient()
  357. await Promise.all([
  358. queryClient.invalidateQueries({ queryKey: contentKeys.count(projectRef, 'sql') }),
  359. queryClient.invalidateQueries({ queryKey: contentKeys.sqlSnippets(projectRef) }),
  360. queryClient.invalidateQueries({ queryKey: contentKeys.folders(projectRef) }),
  361. ])
  362. }
  363. let snippet = sqlEditorState.snippets[id]?.snippet
  364. if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
  365. snippet.isNotSavedInDatabaseYet = false
  366. }
  367. sqlEditorState.savingStates[id] = 'IDLE'
  368. } catch (error) {
  369. sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
  370. }
  371. }
  372. const memoizedUpsertSnippet = memoize((_id: string) => debounce(upsertSnippet, 1000))
  373. const debouncedUpdateSnippet = (
  374. id: string,
  375. projectRef: string,
  376. payload: UpsertContentPayload,
  377. shouldInvalidate = false
  378. ) => memoizedUpsertSnippet(id)(id, projectRef, payload, shouldInvalidate)
  379. async function upsertFolder(id: string, projectRef: string, name: string) {
  380. try {
  381. if (id === NEW_FOLDER_ID) {
  382. const res = await createSQLSnippetFolder({ projectRef, name })
  383. toast.success('Successfully created folder')
  384. sqlEditorState.removeFolder(NEW_FOLDER_ID)
  385. sqlEditorState.folders[res.id] = { projectRef, status: 'idle', folder: res }
  386. } else {
  387. await updateSQLSnippetFolder({ projectRef, id, name })
  388. toast.success('Successfully updated folder')
  389. sqlEditorState.folders[id].status = 'idle'
  390. }
  391. } catch (error: any) {
  392. toast.error(`Failed to save folder: ${error.message}`)
  393. if (error.message.includes('create')) {
  394. sqlEditorState.removeFolder(id)
  395. } else if (
  396. error.message.includes('update') &&
  397. sqlEditorState.lastUpdatedFolderName.length > 0
  398. ) {
  399. let storeFolder = sqlEditorState.folders[id]
  400. storeFolder.status = 'idle'
  401. storeFolder.folder.name = sqlEditorState.lastUpdatedFolderName
  402. }
  403. } finally {
  404. sqlEditorState.lastUpdatedFolderName = ''
  405. }
  406. }
  407. if (typeof window !== 'undefined') {
  408. devtools(sqlEditorState, {
  409. name: 'sqlEditorStateV2',
  410. // [Joshen] So that jest unit tests can ignore this
  411. enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined,
  412. })
  413. subscribe(sqlEditorState.needsSaving, () => {
  414. const state = getSqlEditorV2StateSnapshot()
  415. state.needsSaving.forEach((shouldInvalidate, id) => {
  416. const snippet = state.snippets[id]
  417. const folder = state.folders[id]
  418. if (snippet) {
  419. const {
  420. name,
  421. description,
  422. visibility,
  423. project_id,
  424. owner_id,
  425. folder_id,
  426. content,
  427. favorite,
  428. } = snippet.snippet
  429. if (visibility === 'project' && !!folder_id) {
  430. toast.error('Shared snippet cannot be within a folder')
  431. } else {
  432. debouncedUpdateSnippet(
  433. id,
  434. snippet.projectRef,
  435. {
  436. id,
  437. type: 'sql',
  438. name: name ?? 'Untitled',
  439. description: description ?? '',
  440. visibility: visibility ?? 'user',
  441. project_id: project_id ?? 0,
  442. owner_id: owner_id,
  443. folder_id: folder_id ?? undefined,
  444. favorite: favorite ?? false,
  445. content: {
  446. ...content!,
  447. content_id: id,
  448. },
  449. },
  450. shouldInvalidate
  451. )
  452. sqlEditorState.needsSaving.delete(id)
  453. }
  454. } else if (folder) {
  455. upsertFolder(id, folder.projectRef, folder.folder.name)
  456. sqlEditorState.needsSaving.delete(id)
  457. }
  458. })
  459. })
  460. }