sql-to-rest.tsx487 lines · main
1'use client'
2
3import Editor, { useMonaco } from '@monaco-editor/react'
4import {
5 formatCurl,
6 formatHttp,
7 HttpRequest,
8 ParsingError,
9 processSql,
10 RenderError,
11 renderHttp,
12 renderBrivenJs,
13 Statement,
14 BrivenJsQuery,
15 UnimplementedError,
16 UnsupportedError,
17} from '@supabase/sql-to-rest'
18import { ChevronUp, GitPullRequest } from 'lucide-react'
19import type { editor } from 'monaco-editor'
20import { useTheme } from 'next-themes'
21import {
22 PropsWithChildren,
23 useCallback,
24 useEffect,
25 useLayoutEffect,
26 useMemo,
27 useState,
28} from 'react'
29import Markdown from 'react-markdown'
30import { format } from 'sql-formatter'
31import { Alert, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, Tabs } from 'ui'
32import { CodeBlock } from 'ui-patterns/CodeBlock'
33
34import { assumptions } from './assumptions'
35import { BaseUrlDialog } from './base-url-dialog'
36import { faqs } from './faqs'
37import { transformRenderer } from './syntax-highlighter/transform-renderer'
38import { ResultBundle } from './util'
39
40export interface SqlToRestProps {
41 defaultValue?: string
42 defaultBaseUrl?: string
43}
44
45export default function SqlToRest({
46 defaultValue,
47 defaultBaseUrl = 'http://localhost:54321/rest/v1',
48}: SqlToRestProps) {
49 const monaco = useMonaco()
50 const { resolvedTheme } = useTheme()
51 const isDark = resolvedTheme?.includes('dark') ?? true
52
53 useLayoutEffect(() => {
54 if (monaco) {
55 const lightMode = getTheme(false)
56 const darkMode = getTheme(true)
57 monaco.editor.defineTheme('briven-light', lightMode)
58 monaco.editor.defineTheme('briven-dark', darkMode)
59 }
60 }, [monaco])
61
62 const [sql, setSql] = useState(defaultValue ?? '')
63 const [statement, setStatement] = useState<Statement>()
64 const [currentLanguage, setCurrentLanguage] = useState('curl')
65
66 const [httpRequest, setHttpRequest] = useState<HttpRequest>()
67 const [jsQuery, setJsQuery] = useState<BrivenJsQuery>()
68
69 const [parsingError, setParsingError] = useState<ParsingError>()
70 const [unimplementedError, setUnimplementedError] = useState<UnimplementedError>()
71 const [unsupportedError, setUnsupportedError] = useState<UnsupportedError>()
72 const [httpRenderError, setHttpRenderError] = useState<RenderError>()
73 const [brivenJsRenderError, setBrivenJsRenderError] = useState<RenderError>()
74
75 const [isBaseUrlDialogOpen, setIsBaseUrlDialogOpen] = useState(false)
76 const [baseUrl, setBaseUrl] = useState(defaultBaseUrl)
77
78 const baseUrlObject = useMemo(() => {
79 try {
80 return new URL(baseUrl)
81 } catch (err) {
82 return undefined
83 }
84 }, [baseUrl])
85
86 const codeBlockRenderer = useMemo(
87 () =>
88 transformRenderer({
89 search: (text) => !!baseUrlObject && text.includes(baseUrlObject.host),
90 wrapper: ({ children }) => (
91 <span
92 className="cursor-pointer border-b border-dotted border-neutral-500"
93 onClick={() => setIsBaseUrlDialogOpen(true)}
94 >
95 {children}
96 </span>
97 ),
98 }),
99 [baseUrlObject]
100 )
101
102 const rawHttp = useMemo(() => {
103 if (!httpRequest) {
104 return
105 }
106 return formatHttp(baseUrl, httpRequest)
107 }, [httpRequest, baseUrl])
108
109 const curlCommand = useMemo(() => {
110 if (!httpRequest) {
111 return
112 }
113 return formatCurl(baseUrl, httpRequest)
114 }, [httpRequest, baseUrl])
115
116 const jsCommand = useMemo(() => {
117 if (!jsQuery) {
118 return
119 }
120 const { code } = jsQuery
121 return code
122 }, [jsQuery])
123
124 const relevantFaqs = useMemo(() => {
125 if (!statement) {
126 return []
127 }
128
129 switch (currentLanguage) {
130 case 'http':
131 case 'curl': {
132 if (!httpRequest) {
133 return []
134 }
135
136 const result: ResultBundle = {
137 type: 'http',
138 language: currentLanguage,
139 statement,
140 ...httpRequest,
141 }
142
143 return faqs.filter((faq) => faq.condition(result))
144 }
145 case 'js': {
146 if (!jsQuery) {
147 return []
148 }
149
150 const result: ResultBundle = {
151 type: 'briven-js',
152 language: currentLanguage,
153 statement,
154 ...jsQuery,
155 }
156
157 return faqs.filter((faq) => faq.condition(result))
158 }
159 default:
160 return []
161 }
162 }, [currentLanguage, statement, httpRequest, jsQuery])
163
164 const relevantAssumptions = useMemo(() => {
165 if (!statement) {
166 return []
167 }
168
169 switch (currentLanguage) {
170 case 'http':
171 case 'curl': {
172 if (!httpRequest) {
173 return []
174 }
175
176 const result: ResultBundle = {
177 type: 'http',
178 language: currentLanguage,
179 statement,
180 ...httpRequest,
181 }
182
183 return assumptions.filter((a) => a.condition(result)).flatMap((a) => a.assumptions(result))
184 }
185 case 'js': {
186 if (!jsQuery) {
187 return []
188 }
189
190 const result: ResultBundle = {
191 type: 'briven-js',
192 language: currentLanguage,
193 statement,
194 ...jsQuery,
195 }
196
197 return assumptions.filter((a) => a.condition(result)).flatMap((a) => a.assumptions(result))
198 }
199 default:
200 return []
201 }
202 }, [currentLanguage, statement, httpRequest, jsQuery])
203
204 const process = useCallback(async (sql: string) => {
205 setSql(sql)
206
207 try {
208 const statement = await processSql(sql)
209 const httpRequest = await renderHttp(statement)
210 const jsQuery = await renderBrivenJs(statement)
211
212 setParsingError(undefined)
213 setUnimplementedError(undefined)
214 setUnsupportedError(undefined)
215 setHttpRenderError(undefined)
216 setBrivenJsRenderError(undefined)
217
218 setStatement(statement)
219 setHttpRequest(httpRequest)
220 setJsQuery(jsQuery)
221 } catch (error) {
222 setParsingError(undefined)
223 setUnimplementedError(undefined)
224 setUnsupportedError(undefined)
225 setHttpRenderError(undefined)
226 setBrivenJsRenderError(undefined)
227
228 if (error instanceof ParsingError) {
229 setParsingError(error)
230 } else if (error instanceof UnimplementedError) {
231 setUnimplementedError(error)
232 } else if (error instanceof UnsupportedError) {
233 setUnsupportedError(error)
234 } else if (error instanceof RenderError) {
235 if (error.renderer === 'http') {
236 setHttpRenderError(error)
237 } else if (error.renderer === 'briven-js') {
238 setBrivenJsRenderError(error)
239 } else {
240 console.error(error)
241 }
242 } else {
243 console.error(error)
244 }
245 }
246 }, [])
247
248 // Process initial value only
249 useEffect(() => {
250 process(sql)
251 }, [process])
252
253 return (
254 <div className="grid grid-cols-1 2xl:grid-cols-2 gap-4 mt-4">
255 <div className="flex flex-col gap-4">
256 <div className="font-medium">Enter SQL to translate</div>
257 <div
258 className={cn('h-96 py-4 border rounded-md', isDark ? 'bg-[#1f1f1f]' : 'bg-[#f0f0f0]')}
259 >
260 <Editor
261 language="pgsql"
262 theme={isDark ? 'briven-dark' : 'briven-light'}
263 value={sql}
264 options={{
265 tabSize: 2,
266 minimap: {
267 enabled: false,
268 },
269 fontSize: 13,
270 }}
271 onMount={async (editor, monaco) => {
272 // Register pgsql formatter
273 monaco.languages.registerDocumentFormattingEditProvider('pgsql', {
274 async provideDocumentFormattingEdits(model) {
275 const currentCode = editor.getValue()
276 const formattedCode = format(currentCode, {
277 language: 'postgresql',
278 keywordCase: 'lower',
279 })
280 return [
281 {
282 range: model.getFullModelRange(),
283 text: formattedCode,
284 },
285 ]
286 },
287 })
288
289 // Format on cmd+s
290 editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, async () => {
291 await editor.getAction('editor.action.formatDocument')?.run()
292 })
293
294 // Run format on the initial value
295 await editor.getAction('editor.action.formatDocument')?.run()
296 }}
297 onChange={async (sql) => {
298 if (!sql) {
299 return
300 }
301 await process(sql)
302 }}
303 />
304 </div>
305
306 {parsingError && (
307 <Alert className="text-red-900">
308 {parsingError.message}. {parsingError.hint}
309 </Alert>
310 )}
311 {unsupportedError && (
312 <Alert className="text-red-900">
313 <div>
314 {unsupportedError.message}. {unsupportedError.hint}
315 </div>
316 <div className="prose text-sm mt-2">
317 PostgREST doesn't support this query. If you're sure the syntax is correct and are
318 unable to modify it, wrap it in a database function and call it using the{' '}
319 <a href="https://postgrest.org/en/v12/references/api/stored_procedures.html#stored-procedures">
320 RPC
321 </a>{' '}
322 endpoint.
323 </div>
324 </Alert>
325 )}
326 {unimplementedError && (
327 <Alert className="text-orange-1000">
328 {unimplementedError.message}.
329 <div className="mt-2 text-white flex gap-1 leading-6">
330 <GitPullRequest className="inline-block" width={16} />
331 <a
332 href="https://github.com/supabase-community/sql-to-rest/issues/new/choose"
333 target="_blank"
334 rel="noopener noreferrer"
335 >
336 Create a PR
337 </a>
338 </div>
339 </Alert>
340 )}
341 </div>
342 <BaseUrlDialog
343 defaultValue={baseUrl}
344 onChange={(value) => setBaseUrl(value)}
345 open={isBaseUrlDialogOpen}
346 onOpenChange={(open) => setIsBaseUrlDialogOpen(open)}
347 />
348
349 <div
350 className={cn(
351 'flex flex-col gap-4',
352 parsingError || unsupportedError || unimplementedError
353 ? 'opacity-25 pointer-events-none'
354 : ''
355 )}
356 >
357 <div className="font-medium">Choose language to translate to</div>
358 <Tabs activeId={currentLanguage} onChange={(id: string) => setCurrentLanguage(id)}>
359 <Tabs.Panel id="curl" label="cURL" className="flex flex-col gap-4">
360 {httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
361 <CodeBlock
362 language="curl"
363 hideLineNumbers
364 className={cn(
365 'self-stretch overflow-y-hidden',
366 httpRenderError || !baseUrlObject ? 'opacity-25 pointer-events-none' : ''
367 )}
368 renderer={codeBlockRenderer}
369 >
370 {curlCommand}
371 </CodeBlock>
372 </Tabs.Panel>
373 <Tabs.Panel id="http" label="HTTP" className="flex flex-col gap-4">
374 {httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
375 <CodeBlock
376 language="http"
377 hideLineNumbers
378 className={cn(
379 'self-stretch overflow-y-hidden',
380 httpRenderError ? 'opacity-25 pointer-events-none' : ''
381 )}
382 renderer={codeBlockRenderer}
383 >
384 {rawHttp}
385 </CodeBlock>
386 </Tabs.Panel>
387 <Tabs.Panel id="js" label="JavaScript" className="flex flex-col gap-4">
388 {brivenJsRenderError && (
389 <Alert className="text-red-900">{brivenJsRenderError.message}</Alert>
390 )}
391 <CodeBlock
392 language="js"
393 hideLineNumbers
394 className={cn(
395 'self-stretch overflow-y-hidden',
396 brivenJsRenderError ? 'opacity-25 pointer-events-none' : ''
397 )}
398 renderer={codeBlockRenderer}
399 >
400 {jsCommand}
401 </CodeBlock>
402 </Tabs.Panel>
403 </Tabs>
404 <div
405 className={cn(
406 'flex flex-col gap-4',
407 ((currentLanguage === 'http' || currentLanguage === 'curl') && httpRenderError) ||
408 (currentLanguage === 'js' && brivenJsRenderError)
409 ? 'opacity-25 pointer-events-none'
410 : ''
411 )}
412 >
413 {relevantAssumptions.length > 0 && (
414 <div>
415 <h3 className="my-1 text-base text-inherit">Assumptions</h3>
416 <ol className="my-0 text-foreground">
417 {relevantAssumptions.map((assumption) => (
418 <li className="text-sm">
419 <Markdown>{assumption}</Markdown>
420 </li>
421 ))}
422 </ol>
423 </div>
424 )}
425
426 {relevantFaqs.length > 0 && (
427 <>
428 <h3 className="my-1 text-base text-inherit">FAQs</h3>
429 {relevantFaqs.map((faq) => (
430 <Collapsible
431 key={faq.id}
432 className="flex flex-col items-stretch justify-start bg-surface-100 rounded-sm border border-default px-4"
433 >
434 <CollapsibleTrigger asChild>
435 <button
436 type="button"
437 className="flex justify-between items-center p-3 text-sm text-left"
438 >
439 <Markdown
440 components={{
441 p: ({ children }: PropsWithChildren) => <p className="m-0">{children}</p>,
442 }}
443 >
444 {faq.question}
445 </Markdown>
446 <ChevronUp className="transition data-open-parent:rotate-0 data-closed-parent:rotate-180" />
447 </button>
448 </CollapsibleTrigger>
449 <CollapsibleContent>
450 <div className="text-foreground flex flex-col justify-start items-center px-3 pb-4 text-sm">
451 <Markdown
452 components={{
453 code: (props: any) => <CodeBlock hideLineNumbers {...props} />,
454 }}
455 >
456 {faq.answer}
457 </Markdown>
458 </div>
459 </CollapsibleContent>
460 </Collapsible>
461 ))}
462 </>
463 )}
464 </div>
465 </div>
466 </div>
467 )
468}
469
470// TODO: this was copied from studio - find a way to share it between sites
471function getTheme(isDarkMode: boolean): editor.IStandaloneThemeData {
472 return {
473 base: isDarkMode ? 'vs-dark' : 'vs', // can also be vs-dark or hc-black
474 inherit: true, // can also be false to completely replace the builtin rules
475 rules: [
476 {
477 token: '',
478 background: isDarkMode ? '1f1f1f' : 'f0f0f0',
479 foreground: isDarkMode ? 'd4d4d4' : '444444',
480 },
481 { token: 'string.sql', foreground: '24b47e' },
482 { token: 'comment', foreground: '666666' },
483 { token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' },
484 ],
485 colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' },
486 }
487}