sql-to-rest.tsx487 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import Editor, { useMonaco } from '@monaco-editor/react' |
| 4 | import { |
| 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' |
| 18 | import { ChevronUp, GitPullRequest } from 'lucide-react' |
| 19 | import type { editor } from 'monaco-editor' |
| 20 | import { useTheme } from 'next-themes' |
| 21 | import { |
| 22 | PropsWithChildren, |
| 23 | useCallback, |
| 24 | useEffect, |
| 25 | useLayoutEffect, |
| 26 | useMemo, |
| 27 | useState, |
| 28 | } from 'react' |
| 29 | import Markdown from 'react-markdown' |
| 30 | import { format } from 'sql-formatter' |
| 31 | import { Alert, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, Tabs } from 'ui' |
| 32 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 33 | |
| 34 | import { assumptions } from './assumptions' |
| 35 | import { BaseUrlDialog } from './base-url-dialog' |
| 36 | import { faqs } from './faqs' |
| 37 | import { transformRenderer } from './syntax-highlighter/transform-renderer' |
| 38 | import { ResultBundle } from './util' |
| 39 | |
| 40 | export interface SqlToRestProps { |
| 41 | defaultValue?: string |
| 42 | defaultBaseUrl?: string |
| 43 | } |
| 44 | |
| 45 | export 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 |
| 471 | function 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 | } |