mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 22:29:11 +03:00
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import Editor, { useMonaco } from '@monaco-editor/react'
|
|
import { ChevronUp, GitPullRequest } from 'lucide-react'
|
|
import type { editor } from 'monaco-editor'
|
|
import { useTheme } from 'next-themes'
|
|
import {
|
|
PropsWithChildren,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react'
|
|
import Markdown from 'react-markdown'
|
|
import { format } from 'sql-formatter'
|
|
import {
|
|
HttpRequest,
|
|
ParsingError,
|
|
RenderError,
|
|
Statement,
|
|
SupabaseJsQuery,
|
|
UnimplementedError,
|
|
UnsupportedError,
|
|
formatCurl,
|
|
formatHttp,
|
|
processSql,
|
|
renderHttp,
|
|
renderSupabaseJs,
|
|
} from '@supabase/sql-to-rest'
|
|
import { CodeBlock, Collapsible, Tabs, cn } from 'ui'
|
|
import { Alert } from 'ui/src/components/shadcn/ui/alert'
|
|
import { assumptions } from './assumptions'
|
|
import { BaseUrlDialog } from './base-url-dialog'
|
|
import { faqs } from './faqs'
|
|
import { transformRenderer } from './syntax-highlighter/transform-renderer'
|
|
import { ResultBundle } from './util'
|
|
|
|
export interface SqlToRestProps {
|
|
defaultValue?: string
|
|
defaultBaseUrl?: string
|
|
}
|
|
|
|
export default function SqlToRest({
|
|
defaultValue,
|
|
defaultBaseUrl = 'http://localhost:54321/rest/v1',
|
|
}: SqlToRestProps) {
|
|
const monaco = useMonaco()
|
|
const { resolvedTheme } = useTheme()
|
|
const isDark = resolvedTheme?.includes('dark') ?? true
|
|
|
|
useLayoutEffect(() => {
|
|
if (monaco) {
|
|
const lightMode = getTheme(false)
|
|
const darkMode = getTheme(true)
|
|
monaco.editor.defineTheme('supabase-light', lightMode)
|
|
monaco.editor.defineTheme('supabase-dark', darkMode)
|
|
}
|
|
}, [monaco])
|
|
|
|
const [sql, setSql] = useState(defaultValue ?? '')
|
|
const [statement, setStatement] = useState<Statement>()
|
|
const [currentLanguage, setCurrentLanguage] = useState('curl')
|
|
|
|
const [httpRequest, setHttpRequest] = useState<HttpRequest>()
|
|
const [jsQuery, setJsQuery] = useState<SupabaseJsQuery>()
|
|
|
|
const [parsingError, setParsingError] = useState<ParsingError>()
|
|
const [unimplementedError, setUnimplementedError] = useState<UnimplementedError>()
|
|
const [unsupportedError, setUnsupportedError] = useState<UnsupportedError>()
|
|
const [httpRenderError, setHttpRenderError] = useState<RenderError>()
|
|
const [supabaseJsRenderError, setSupabaseJsRenderError] = useState<RenderError>()
|
|
|
|
const [isBaseUrlDialogOpen, setIsBaseUrlDialogOpen] = useState(false)
|
|
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl)
|
|
|
|
const baseUrlObject = useMemo(() => {
|
|
try {
|
|
return new URL(baseUrl)
|
|
} catch (err) {
|
|
return undefined
|
|
}
|
|
}, [baseUrl])
|
|
|
|
const codeBlockRenderer = useMemo(
|
|
() =>
|
|
transformRenderer({
|
|
search: (text) => !!baseUrlObject && text.includes(baseUrlObject.host),
|
|
wrapper: ({ children }) => (
|
|
<span
|
|
className="cursor-pointer border-b border-dotted border-neutral-500"
|
|
onClick={() => setIsBaseUrlDialogOpen(true)}
|
|
>
|
|
{children}
|
|
</span>
|
|
),
|
|
}),
|
|
[baseUrlObject]
|
|
)
|
|
|
|
const rawHttp = useMemo(() => {
|
|
if (!httpRequest) {
|
|
return
|
|
}
|
|
return formatHttp(baseUrl, httpRequest)
|
|
}, [httpRequest, baseUrl])
|
|
|
|
const curlCommand = useMemo(() => {
|
|
if (!httpRequest) {
|
|
return
|
|
}
|
|
return formatCurl(baseUrl, httpRequest)
|
|
}, [httpRequest, baseUrl])
|
|
|
|
const jsCommand = useMemo(() => {
|
|
if (!jsQuery) {
|
|
return
|
|
}
|
|
const { code } = jsQuery
|
|
return code
|
|
}, [jsQuery])
|
|
|
|
const relevantFaqs = useMemo(() => {
|
|
if (!statement) {
|
|
return []
|
|
}
|
|
|
|
switch (currentLanguage) {
|
|
case 'http':
|
|
case 'curl': {
|
|
if (!httpRequest) {
|
|
return []
|
|
}
|
|
|
|
const result: ResultBundle = {
|
|
type: 'http',
|
|
language: currentLanguage,
|
|
statement,
|
|
...httpRequest,
|
|
}
|
|
|
|
return faqs.filter((faq) => faq.condition(result))
|
|
}
|
|
case 'js': {
|
|
if (!jsQuery) {
|
|
return []
|
|
}
|
|
|
|
const result: ResultBundle = {
|
|
type: 'supabase-js',
|
|
language: currentLanguage,
|
|
statement,
|
|
...jsQuery,
|
|
}
|
|
|
|
return faqs.filter((faq) => faq.condition(result))
|
|
}
|
|
default:
|
|
return []
|
|
}
|
|
}, [currentLanguage, statement, httpRequest, jsQuery])
|
|
|
|
const relevantAssumptions = useMemo(() => {
|
|
if (!statement) {
|
|
return []
|
|
}
|
|
|
|
switch (currentLanguage) {
|
|
case 'http':
|
|
case 'curl': {
|
|
if (!httpRequest) {
|
|
return []
|
|
}
|
|
|
|
const result: ResultBundle = {
|
|
type: 'http',
|
|
language: currentLanguage,
|
|
statement,
|
|
...httpRequest,
|
|
}
|
|
|
|
return assumptions.filter((a) => a.condition(result)).flatMap((a) => a.assumptions(result))
|
|
}
|
|
case 'js': {
|
|
if (!jsQuery) {
|
|
return []
|
|
}
|
|
|
|
const result: ResultBundle = {
|
|
type: 'supabase-js',
|
|
language: currentLanguage,
|
|
statement,
|
|
...jsQuery,
|
|
}
|
|
|
|
return assumptions.filter((a) => a.condition(result)).flatMap((a) => a.assumptions(result))
|
|
}
|
|
default:
|
|
return []
|
|
}
|
|
}, [currentLanguage, statement, httpRequest, jsQuery])
|
|
|
|
const process = useCallback(async (sql: string) => {
|
|
setSql(sql)
|
|
|
|
try {
|
|
const statement = await processSql(sql)
|
|
const httpRequest = await renderHttp(statement)
|
|
const jsQuery = await renderSupabaseJs(statement)
|
|
|
|
setParsingError(undefined)
|
|
setUnimplementedError(undefined)
|
|
setUnsupportedError(undefined)
|
|
setHttpRenderError(undefined)
|
|
setSupabaseJsRenderError(undefined)
|
|
|
|
setStatement(statement)
|
|
setHttpRequest(httpRequest)
|
|
setJsQuery(jsQuery)
|
|
} catch (error) {
|
|
setParsingError(undefined)
|
|
setUnimplementedError(undefined)
|
|
setUnsupportedError(undefined)
|
|
setHttpRenderError(undefined)
|
|
setSupabaseJsRenderError(undefined)
|
|
|
|
if (error instanceof ParsingError) {
|
|
setParsingError(error)
|
|
} else if (error instanceof UnimplementedError) {
|
|
setUnimplementedError(error)
|
|
} else if (error instanceof UnsupportedError) {
|
|
setUnsupportedError(error)
|
|
} else if (error instanceof RenderError) {
|
|
if (error.renderer === 'http') {
|
|
setHttpRenderError(error)
|
|
} else if (error.renderer === 'supabase-js') {
|
|
setSupabaseJsRenderError(error)
|
|
} else {
|
|
console.error(error)
|
|
}
|
|
} else {
|
|
console.error(error)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Process initial value only
|
|
useEffect(() => {
|
|
process(sql)
|
|
}, [process])
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4 mt-4">
|
|
<div className="flex flex-col gap-4">
|
|
<div className="font-medium">Enter SQL to translate</div>
|
|
<div
|
|
className={cn('h-96 py-4 border rounded-md', isDark ? 'bg-[#1f1f1f]' : 'bg-[#f0f0f0]')}
|
|
>
|
|
<Editor
|
|
language="pgsql"
|
|
theme={isDark ? 'supabase-dark' : 'supabase-light'}
|
|
value={sql}
|
|
options={{
|
|
tabSize: 2,
|
|
minimap: {
|
|
enabled: false,
|
|
},
|
|
fontSize: 13,
|
|
}}
|
|
onMount={async (editor, monaco) => {
|
|
// Register pgsql formatter
|
|
monaco.languages.registerDocumentFormattingEditProvider('pgsql', {
|
|
async provideDocumentFormattingEdits(model) {
|
|
const currentCode = editor.getValue()
|
|
const formattedCode = format(currentCode, {
|
|
language: 'postgresql',
|
|
keywordCase: 'lower',
|
|
})
|
|
return [
|
|
{
|
|
range: model.getFullModelRange(),
|
|
text: formattedCode,
|
|
},
|
|
]
|
|
},
|
|
})
|
|
|
|
// Format on cmd+s
|
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, async () => {
|
|
await editor.getAction('editor.action.formatDocument').run()
|
|
})
|
|
|
|
// Run format on the initial value
|
|
await editor.getAction('editor.action.formatDocument').run()
|
|
}}
|
|
onChange={async (sql) => {
|
|
if (!sql) {
|
|
return
|
|
}
|
|
await process(sql)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{parsingError && (
|
|
<Alert className="text-red-900">
|
|
{parsingError.message}. {parsingError.hint}
|
|
</Alert>
|
|
)}
|
|
{unsupportedError && (
|
|
<Alert className="text-red-900">
|
|
<div>
|
|
{unsupportedError.message}. {unsupportedError.hint}
|
|
</div>
|
|
<div className="prose text-sm mt-2">
|
|
PostgREST doesn't support this query. If you're sure the syntax is correct and are
|
|
unable to modify it, wrap it in a stored procedure and call it using the{' '}
|
|
<a href="https://postgrest.org/en/v12/references/api/stored_procedures.html#stored-procedures">
|
|
RPC
|
|
</a>{' '}
|
|
endpoint.
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
{unimplementedError && (
|
|
<Alert className="text-orange-1000">
|
|
{unimplementedError.message}.
|
|
<div className="mt-2 text-white flex gap-1 leading-6">
|
|
<GitPullRequest className="inline-block" width={16} />
|
|
<a
|
|
href="https://github.com/supabase-community/sql-to-rest/issues/new/choose"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
Create a PR
|
|
</a>
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
<BaseUrlDialog
|
|
defaultValue={baseUrl}
|
|
onChange={(value) => setBaseUrl(value)}
|
|
open={isBaseUrlDialogOpen}
|
|
onOpenChange={(open) => setIsBaseUrlDialogOpen(open)}
|
|
/>
|
|
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-4',
|
|
parsingError || unsupportedError || unimplementedError
|
|
? 'opacity-25 pointer-events-none'
|
|
: ''
|
|
)}
|
|
>
|
|
<div className="font-medium">Choose language to translate to</div>
|
|
<Tabs activeId={currentLanguage} onChange={(id: string) => setCurrentLanguage(id)}>
|
|
<Tabs.Panel id="curl" label="cURL" className="flex flex-col gap-4">
|
|
{httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
|
|
<CodeBlock
|
|
language="curl"
|
|
hideLineNumbers
|
|
className={cn(
|
|
'self-stretch overflow-y-hidden',
|
|
httpRenderError || !baseUrlObject ? 'opacity-25 pointer-events-none' : ''
|
|
)}
|
|
renderer={codeBlockRenderer}
|
|
>
|
|
{curlCommand}
|
|
</CodeBlock>
|
|
</Tabs.Panel>
|
|
<Tabs.Panel id="http" label="HTTP" className="flex flex-col gap-4">
|
|
{httpRenderError && <Alert className="text-red-900">{httpRenderError.message}</Alert>}
|
|
<CodeBlock
|
|
language="http"
|
|
hideLineNumbers
|
|
className={cn(
|
|
'self-stretch overflow-y-hidden',
|
|
httpRenderError ? 'opacity-25 pointer-events-none' : ''
|
|
)}
|
|
renderer={codeBlockRenderer}
|
|
>
|
|
{rawHttp}
|
|
</CodeBlock>
|
|
</Tabs.Panel>
|
|
<Tabs.Panel id="js" label="JavaScript" className="flex flex-col gap-4">
|
|
{supabaseJsRenderError && (
|
|
<Alert className="text-red-900">{supabaseJsRenderError.message}</Alert>
|
|
)}
|
|
<CodeBlock
|
|
language="js"
|
|
hideLineNumbers
|
|
className={cn(
|
|
'self-stretch overflow-y-hidden',
|
|
supabaseJsRenderError ? 'opacity-25 pointer-events-none' : ''
|
|
)}
|
|
renderer={codeBlockRenderer}
|
|
>
|
|
{jsCommand}
|
|
</CodeBlock>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-4',
|
|
((currentLanguage === 'http' || currentLanguage === 'curl') && httpRenderError) ||
|
|
(currentLanguage === 'js' && supabaseJsRenderError)
|
|
? 'opacity-25 pointer-events-none'
|
|
: ''
|
|
)}
|
|
>
|
|
{relevantAssumptions.length > 0 && (
|
|
<div>
|
|
<h3 className="my-1 text-base text-inherit">Assumptions</h3>
|
|
<ol className="my-0 text-foreground">
|
|
{relevantAssumptions.map((assumption) => (
|
|
<li>
|
|
<Markdown className="text-sm">{assumption}</Markdown>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
|
|
{relevantFaqs.length > 0 && (
|
|
<>
|
|
<h3 className="my-1 text-base text-inherit">FAQs</h3>
|
|
{relevantFaqs.map((faq) => (
|
|
<Collapsible
|
|
key={faq.id}
|
|
className="flex flex-col items-stretch justify-start bg-surface-100 rounded border border-default px-4"
|
|
>
|
|
<Collapsible.Trigger asChild>
|
|
<button type="button" className="flex justify-between items-center p-3">
|
|
<Markdown
|
|
className="text-sm text-left"
|
|
components={{
|
|
p: ({ children }: PropsWithChildren) => <p className="m-0">{children}</p>,
|
|
}}
|
|
>
|
|
{faq.question}
|
|
</Markdown>
|
|
<ChevronUp className="transition data-open-parent:rotate-0 data-closed-parent:rotate-180" />
|
|
</button>
|
|
</Collapsible.Trigger>
|
|
<Collapsible.Content>
|
|
<div className="text-foreground flex flex-col justify-start items-center px-3 pb-4">
|
|
<Markdown
|
|
className="text-sm"
|
|
components={{
|
|
code: (props: any) => <CodeBlock hideLineNumbers {...props} />,
|
|
}}
|
|
>
|
|
{faq.answer}
|
|
</Markdown>
|
|
</div>
|
|
</Collapsible.Content>
|
|
</Collapsible>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// TODO: this was copied from studio - find a way to share it between sites
|
|
function getTheme(isDarkMode: boolean): editor.IStandaloneThemeData {
|
|
return {
|
|
base: isDarkMode ? 'vs-dark' : 'vs', // can also be vs-dark or hc-black
|
|
inherit: true, // can also be false to completely replace the builtin rules
|
|
rules: [
|
|
{
|
|
token: '',
|
|
background: isDarkMode ? '1f1f1f' : 'f0f0f0',
|
|
foreground: isDarkMode ? 'd4d4d4' : '444444',
|
|
},
|
|
{ token: 'string.sql', foreground: '24b47e' },
|
|
{ token: 'comment', foreground: '666666' },
|
|
{ token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' },
|
|
],
|
|
colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' },
|
|
}
|
|
}
|