SqlEditor.Commands.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import type { PGColumn } from '@supabase/pg-meta'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import { AlertTriangle, Code, Loader2, Table2 } from 'lucide-react'
  5. import { useRouter } from 'next/navigation'
  6. import { useEffect, useMemo, useRef } from 'react'
  7. import { cn, CommandEmpty, CommandGroup, CommandItem, CommandList } from 'ui'
  8. import { CodeBlock } from 'ui-patterns/CodeBlock'
  9. import type { CommandOptions } from 'ui-patterns/CommandMenu'
  10. import {
  11. Breadcrumb,
  12. CommandHeader,
  13. CommandMenuInput,
  14. CommandWrapper,
  15. escapeAttributeSelector,
  16. generateCommandClassNames,
  17. PageType,
  18. useCommandFilterState,
  19. useCommandMenuOpen,
  20. useRegisterCommands,
  21. useRegisterPage,
  22. useSetCommandMenuSize,
  23. useSetPage,
  24. } from 'ui-patterns/CommandMenu'
  25. import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
  26. import { orderCommandSectionsByPriority } from '@/components/interfaces/App/CommandMenu/ordering'
  27. import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query'
  28. import { usePrefetchTables, useTablesQuery, type TablesData } from '@/data/tables/tables-query'
  29. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  30. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  31. import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
  32. import { useProfile } from '@/lib/profile'
  33. export function useSqlEditorGotoCommands(options?: CommandOptions) {
  34. let { ref } = useParams()
  35. ref ||= '_'
  36. useRegisterCommands(
  37. COMMAND_MENU_SECTIONS.NAVIGATE,
  38. [
  39. {
  40. id: 'nav-sql-editor',
  41. name: 'SQL Editor',
  42. route: `/project/${ref}/sql`,
  43. defaultHidden: true,
  44. },
  45. ],
  46. { ...options, deps: [ref] }
  47. )
  48. }
  49. const SNIPPET_PAGE_NAME = 'Snippets'
  50. export function useSnippetCommands() {
  51. const { data: project } = useSelectedProjectQuery()
  52. const setPage = useSetPage()
  53. useRegisterPage(
  54. SNIPPET_PAGE_NAME,
  55. {
  56. type: PageType.Component,
  57. component: () => <RunSnippetPage />,
  58. },
  59. { enabled: !!project }
  60. )
  61. useRegisterCommands(
  62. COMMAND_MENU_SECTIONS.SQL,
  63. [
  64. {
  65. id: 'run-snippet',
  66. name: 'Run snippet...',
  67. icon: () => <Code />,
  68. action: () => setPage(SNIPPET_PAGE_NAME),
  69. },
  70. ],
  71. {
  72. enabled: !!project,
  73. orderSection: orderCommandSectionsByPriority,
  74. sectionMeta: { priority: 3 },
  75. }
  76. )
  77. }
  78. function RunSnippetPage() {
  79. const { ref } = useParams()
  80. const {
  81. data: snippetPages,
  82. isPending: isLoading,
  83. isError,
  84. isSuccess,
  85. } = useSqlSnippetsQuery({
  86. projectRef: ref,
  87. })
  88. const snippets = snippetPages?.pages.flatMap((page) => page.contents)
  89. const { profile } = useProfile()
  90. const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
  91. PermissionAction.CREATE,
  92. 'user_content',
  93. {
  94. resource: { type: 'sql', owner_id: profile?.id },
  95. subject: { id: profile?.id },
  96. }
  97. )
  98. useSetCommandMenuSize('xlarge')
  99. return (
  100. <CommandWrapper>
  101. <CommandHeader>
  102. <Breadcrumb />
  103. <CommandMenuInput autoFocus />
  104. </CommandHeader>
  105. {isLoading && <LoadingState />}
  106. {isError && <ErrorState />}
  107. {isSuccess && (!snippets || snippets.length === 0) && (
  108. <EmptyState projectRef={ref} canCreateNew={canCreateSQLSnippet} />
  109. )}
  110. {isSuccess && !!snippets && snippets.length > 0 && (
  111. <SnippetSelector projectRef={ref} canCreateNew={canCreateSQLSnippet} snippets={snippets} />
  112. )}
  113. </CommandWrapper>
  114. )
  115. }
  116. function LoadingState() {
  117. return (
  118. <div className="p-6">
  119. <p className="text-center">
  120. <Loader2 className="inline-block mr-2 animate-spin" />
  121. Loading...
  122. </p>
  123. </div>
  124. )
  125. }
  126. function ErrorState() {
  127. return (
  128. <div className="p-6">
  129. <p className="text-center">
  130. <AlertTriangle className="inline-block mr-2" />
  131. Couldn&apos;t load snippets
  132. </p>
  133. </div>
  134. )
  135. }
  136. function EmptyState({
  137. projectRef,
  138. canCreateNew,
  139. }: {
  140. projectRef: string | undefined
  141. canCreateNew: boolean
  142. }) {
  143. const router = useRouter()
  144. return (
  145. <div className="p-6">
  146. <p className="mb-2 text-center">No snippets found.</p>
  147. <CommandList className="py-2">
  148. <CommandGroup>
  149. <CommandItem
  150. id="create-snippet"
  151. className={generateCommandClassNames(false)}
  152. onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
  153. >
  154. {canCreateNew ? 'Create new snippet' : 'Run new SQL'}
  155. </CommandItem>
  156. </CommandGroup>
  157. </CommandList>
  158. </div>
  159. )
  160. }
  161. function SnippetSelector({
  162. projectRef,
  163. snippets,
  164. canCreateNew,
  165. }: {
  166. projectRef: string | undefined
  167. snippets: Array<SqlSnippet> | undefined
  168. canCreateNew: boolean
  169. }) {
  170. const router = useRouter()
  171. const selectedValue = useCommandFilterState((state) => state.value)
  172. const selectedSnippet = snippets?.find((snippet) => snippetValue(snippet) === selectedValue)
  173. const isSQLSnippet = selectedSnippet?.type === 'sql'
  174. return (
  175. <div className="w-full grow min-h-0 grid gap-4 md:grid-cols-2">
  176. <CommandList
  177. className={cn(
  178. 'h-full! min-h-0 max-h-[unset] py-2 overflow-hidden',
  179. '*:[[cmdk-list-sizer]]:h-full *:[[cmdk-list-sizer]]:flex *:[[cmdk-list-sizer]]:flex-col'
  180. )}
  181. >
  182. {!!snippets && snippets.length > 0 && (
  183. <CommandGroup className="grow min-h-0 overflow-auto">
  184. {snippets.map((snippet) => (
  185. <CommandItem
  186. key={snippet.id}
  187. id={`${snippet.id}-${snippet.name}`}
  188. className={generateCommandClassNames(false)}
  189. value={snippetValue(snippet)}
  190. onSelect={() => void router.push(`/project/${projectRef ?? '_'}/sql/${snippet.id}`)}
  191. >
  192. {snippet.name}
  193. </CommandItem>
  194. ))}
  195. </CommandGroup>
  196. )}
  197. {canCreateNew && (
  198. <div className="min-h-fit grow-0">
  199. <hr className="mt-4 mb-2 mx-2" />
  200. <CommandGroup forceMount={true}>
  201. <CommandItem
  202. id="create-snippet"
  203. className={generateCommandClassNames(false)}
  204. onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
  205. forceMount={true}
  206. >
  207. Create new snippet
  208. </CommandItem>
  209. </CommandGroup>
  210. </div>
  211. )}
  212. </CommandList>
  213. <CodeBlock
  214. language="sql"
  215. value={isSQLSnippet ? selectedSnippet?.content?.unchecked_sql : ''}
  216. wrapperClassName="hidden md:block"
  217. className="w-full h-full border-0 [&>code]:overflow-scroll [&>code]:block [&>code]:w-full [&>code]:h-full"
  218. hideCopy
  219. />
  220. </div>
  221. )
  222. }
  223. function snippetValue(snippet: SqlSnippet) {
  224. if (snippet.type !== 'sql') return ''
  225. return escapeAttributeSelector(
  226. `${snippet.id}-${snippet.name}-${snippet?.content?.unchecked_sql.slice(0, 30)}`
  227. ).toLowerCase()
  228. }
  229. const QUERY_TABLE_PAGE_NAME = 'Query a table'
  230. export function useQueryTableCommands(options?: CommandOptions) {
  231. const { data: project } = useSelectedProjectQuery()
  232. const setPage = useSetPage()
  233. const commandMenuOpen = useCommandMenuOpen()
  234. const commandMenuPreviouslyOpen = useRef(commandMenuOpen)
  235. const commandMenuJustOpened = commandMenuOpen && !commandMenuPreviouslyOpen.current
  236. commandMenuPreviouslyOpen.current = commandMenuOpen
  237. const prefetchTables = usePrefetchTables({
  238. projectRef: project?.ref,
  239. connectionString: project?.connectionString,
  240. })
  241. useEffect(() => {
  242. if (project && commandMenuJustOpened) {
  243. prefetchTables(undefined, true)
  244. }
  245. }, [project, prefetchTables, commandMenuJustOpened])
  246. useRegisterPage(
  247. QUERY_TABLE_PAGE_NAME,
  248. {
  249. type: PageType.Component,
  250. component: TableSelector,
  251. },
  252. { enabled: !!project }
  253. )
  254. useRegisterCommands(
  255. COMMAND_MENU_SECTIONS.SQL,
  256. [
  257. {
  258. id: 'query-table',
  259. name: 'Query a table...',
  260. icon: () => <Table2 />,
  261. action: () => setPage(QUERY_TABLE_PAGE_NAME),
  262. },
  263. ],
  264. { ...options, enabled: (options?.enabled ?? true) && !!project }
  265. )
  266. }
  267. function TableSelector() {
  268. const router = useRouter()
  269. const { data: project } = useSelectedProjectQuery()
  270. const { data: protectedSchemas } = useProtectedSchemas()
  271. const {
  272. data: tablesData,
  273. isPending: isLoading,
  274. isError,
  275. isSuccess,
  276. } = useTablesQuery({
  277. projectRef: project?.ref,
  278. connectionString: project?.connectionString,
  279. includeColumns: true,
  280. })
  281. const tables = useMemo(() => {
  282. return tablesData?.filter((table) => !protectedSchemas.find((s) => s.name === table.schema))
  283. }, [tablesData, protectedSchemas])
  284. return (
  285. <CommandWrapper>
  286. <CommandHeader>
  287. <Breadcrumb />
  288. <CommandMenuInput autoFocus />
  289. </CommandHeader>
  290. <CommandList>
  291. {isLoading && <LoadingState />}
  292. {isError && <ErrorState />}
  293. {isSuccess && (
  294. <>
  295. <CommandEmpty />
  296. <CommandGroup>
  297. {tables?.map((table) => (
  298. <CommandItem
  299. key={table.id}
  300. className={generateCommandClassNames(false)}
  301. value={escapeAttributeSelector(`${table.schema}.${table.name}`)}
  302. onSelect={() => {
  303. router.push(
  304. `/project/${project?.ref ?? '_'}/sql/new?content=${encodeURIComponent(generateSelectStatement(table))}`
  305. )
  306. }}
  307. >
  308. {`${table.schema}.${table.name}`}
  309. </CommandItem>
  310. ))}
  311. </CommandGroup>
  312. </>
  313. )}
  314. </CommandList>
  315. </CommandWrapper>
  316. )
  317. }
  318. function generateSelectStatement(table: TablesData[number] & { columns?: Array<PGColumn> }) {
  319. return `
  320. select ${
  321. !table.columns
  322. ? '*'
  323. : `
  324. ${table.columns.map((column) => `\t${column.name}`).join(',\n')}`
  325. }
  326. from ${formatTableIdentifier(table)}
  327. -- where
  328. -- order by
  329. -- limit
  330. ;
  331. `.trim()
  332. }
  333. // Not a perfectly spec-compliant regex , since Postgres also allows non-Latin
  334. // letters and letters with diacritical marks, but quoting them defensively
  335. // is easier than writing the regex. ¯\_(ツ)_/¯
  336. const VALID_UNQUOTED_IDENTIFIER_REGEX = /^[a-z_][a-z0-9_$]*$/
  337. function formatTableIdentifier(table: TablesData[number]) {
  338. const schema = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.schema)
  339. ? table.schema
  340. : `"${table.schema}"`
  341. const tableName = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.name)
  342. ? table.name
  343. : `"${table.name}"`
  344. return `${schema}.${tableName}`
  345. }