mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 15:49:09 +03:00
feat(db-playground): schema visualizer
This commit is contained in:
1 parent
a58fcbfcfa
commit
cf94fc85c0
10 files changed
+1660
-245
No files matched your search
@@ -55,7 +55,6 @@ export async function POST(req: Request) {
|
||||
description: codeBlock`
|
||||
Generates a chart using Chart.js for a given SQL query.
|
||||
- Label both axises
|
||||
- Line charts should default to \`cubicInterpolationMode: 'monotone'\`
|
||||
- Plugins are not available
|
||||
|
||||
Call \`executeSql\` first.
|
||||
@@ -68,11 +67,6 @@ export async function POST(req: Request) {
|
||||
),
|
||||
}),
|
||||
},
|
||||
appendSqlToMigration: {
|
||||
description:
|
||||
'Appends schema changes to a SQL migration file. Always call this after modifying database structure.',
|
||||
parameters: z.object({ sql: z.string() }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import './globals.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { cn } from 'ui'
|
||||
import Providers from '~/components/providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
@@ -19,7 +20,9 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn(inter.className, 'light')}>{children}</body>
|
||||
<body className={cn(inter.className, 'light')}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'chartjs-adapter-date-fns'
|
||||
|
||||
import { PGlite } from '@electric-sql/pglite'
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { nanoid } from 'ai'
|
||||
import { useChat } from 'ai/react'
|
||||
import Chart from 'chart.js/auto'
|
||||
@@ -20,13 +21,14 @@ import remarkMath from 'remark-math'
|
||||
import { format } from 'sql-formatter'
|
||||
import { AiIconAnimation, markdownComponents } from 'ui'
|
||||
import { Button } from 'ui/src/components/shadcn/ui/button'
|
||||
import SchemaGraph from '~/components/schema/graph'
|
||||
import { Report } from './api/chat/route'
|
||||
|
||||
const loadFramerFeatures = () => import('./framer-features').then((res) => res.default)
|
||||
|
||||
// React's double-rendering in dev mode causes pglite errors
|
||||
// Temp: storing single instance in module scope
|
||||
let db: PGlite = new PGlite('idb://local')
|
||||
export let db: PGlite = new PGlite('idb://local')
|
||||
|
||||
function useReportSuggestions(db: PGlite) {
|
||||
const [schema, setSchema] = useState<any[]>()
|
||||
@@ -87,10 +89,12 @@ export default function Page() {
|
||||
const [isEditorVisible, setIsEditorVisible] = useState(false)
|
||||
const { reports } = useReportSuggestions(db)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { messages, input, setInput, handleInputChange, handleSubmit, append, stop, isLoading } =
|
||||
useChat({
|
||||
api: 'api/chat',
|
||||
maxToolRoundtrips: 5,
|
||||
maxToolRoundtrips: 10,
|
||||
async onToolCall({ toolCall }) {
|
||||
console.log('tool call', toolCall)
|
||||
switch (toolCall.toolName) {
|
||||
@@ -112,6 +116,22 @@ export default function Page() {
|
||||
const { sql } = toolCall.args as any
|
||||
console.log(sql)
|
||||
const results = await db.exec(sql)
|
||||
|
||||
setSql((s) => {
|
||||
const newSql = (s + '\n' + sql).trim()
|
||||
return format(newSql, {
|
||||
language: 'postgresql',
|
||||
keywordCase: 'lower',
|
||||
identifierCase: 'lower',
|
||||
dataTypeCase: 'lower',
|
||||
functionCase: 'lower',
|
||||
})
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['tables'],
|
||||
})
|
||||
|
||||
console.log(results)
|
||||
return results
|
||||
} catch (err) {
|
||||
@@ -147,24 +167,6 @@ export default function Page() {
|
||||
canvas.remove()
|
||||
}
|
||||
}
|
||||
case 'appendSqlToMigration': {
|
||||
const { sql } = toolCall.args as any
|
||||
setSql((s) => {
|
||||
const newSql = (s + '\n' + sql).trim()
|
||||
return format(newSql, {
|
||||
language: 'postgresql',
|
||||
keywordCase: 'lower',
|
||||
identifierCase: 'lower',
|
||||
dataTypeCase: 'lower',
|
||||
functionCase: 'lower',
|
||||
})
|
||||
})
|
||||
setIsEditorVisible(true)
|
||||
return {
|
||||
success: true,
|
||||
message: 'SQL has successfully been appended to the migration file.',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -186,7 +188,10 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<LazyMotion features={loadFramerFeatures}>
|
||||
<div className="w-full h-full flex p-6">
|
||||
<div className="w-full h-full flex p-6 gap-8">
|
||||
<div className="h-full w-[40rem] py-4 rounded-md bg-[#1e1e1e]">
|
||||
<SchemaGraph schema="public" />
|
||||
</div>
|
||||
{isEditorVisible && (
|
||||
<div className="h-full w-[50rem] py-4 rounded-md bg-[#1e1e1e]">
|
||||
<Editor
|
||||
@@ -506,13 +511,14 @@ export default function Page() {
|
||||
</m.div>
|
||||
)}
|
||||
<input
|
||||
className="flex-grow border-none focus-visible:ring-0 text-base bg-inherit placeholder:text-neutral-400"
|
||||
id="input"
|
||||
name="prompt"
|
||||
autoComplete="off"
|
||||
className="flex-grow border-none focus-visible:ring-0 text-base bg-inherit placeholder:text-neutral-400"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Message Supabase AI"
|
||||
autoFocus
|
||||
id="input"
|
||||
/>
|
||||
<Button
|
||||
className="rounded-full w-8 h-8 p-1.5 text-white bg-neutral-800"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export default function Providers({ children }: PropsWithChildren) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { uniqBy } from 'lodash'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Edge,
|
||||
Node,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { IconLoader } from 'ui'
|
||||
import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH, TableNode } from 'ui-patterns/SchemaTableNode'
|
||||
import { useTablesQuery } from '~/data/tables/tables-query'
|
||||
import SchemaGraphLegend from './legend'
|
||||
|
||||
type TableNodeData = {
|
||||
name: string
|
||||
isForeign: boolean
|
||||
columns: {
|
||||
id: string
|
||||
isPrimary: boolean
|
||||
isNullable: boolean
|
||||
isUnique: boolean
|
||||
isUpdateable: boolean
|
||||
isIdentity: boolean
|
||||
name: string
|
||||
format: string
|
||||
}[]
|
||||
}
|
||||
|
||||
async function getGraphDataFromTables(tables: PostgresTable[]): Promise<{
|
||||
nodes: Node<TableNodeData>[]
|
||||
edges: Edge[]
|
||||
}> {
|
||||
if (!tables.length) {
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
|
||||
const nodes = tables.map((table) => {
|
||||
const columns = (table.columns || []).map((column) => {
|
||||
return {
|
||||
id: column.id,
|
||||
isPrimary: table.primary_keys.some((pk) => pk.name === column.name),
|
||||
name: column.name,
|
||||
format: column.format,
|
||||
isNullable: column.is_nullable,
|
||||
isUnique: column.is_unique,
|
||||
isUpdateable: column.is_updatable,
|
||||
isIdentity: column.is_identity,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: `${table.id}`,
|
||||
type: 'table',
|
||||
data: {
|
||||
name: table.name,
|
||||
isForeign: false,
|
||||
columns,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
})
|
||||
|
||||
const edges: Edge[] = []
|
||||
const currentSchema = tables[0].schema
|
||||
const uniqueRelationships = uniqBy(
|
||||
tables.flatMap((t) => t.relationships),
|
||||
'id'
|
||||
)
|
||||
|
||||
for (const rel of uniqueRelationships) {
|
||||
// TODO: Support [external->this] relationship?
|
||||
if (rel.source_schema !== currentSchema) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create additional [this->foreign] node that we can point to on the graph.
|
||||
if (rel.target_table_schema !== currentSchema) {
|
||||
nodes.push({
|
||||
id: rel.constraint_name,
|
||||
type: 'table',
|
||||
data: {
|
||||
name: `${rel.target_table_schema}.${rel.target_table_name}.${rel.target_column_name}`,
|
||||
isForeign: true,
|
||||
columns: [],
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
const [source, sourceHandle] = findTablesHandleIds(
|
||||
tables,
|
||||
rel.source_table_name,
|
||||
rel.source_column_name
|
||||
)
|
||||
|
||||
if (source) {
|
||||
edges.push({
|
||||
id: String(rel.id),
|
||||
source,
|
||||
sourceHandle,
|
||||
target: rel.constraint_name,
|
||||
targetHandle: rel.constraint_name,
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const [source, sourceHandle] = findTablesHandleIds(
|
||||
tables,
|
||||
rel.source_table_name,
|
||||
rel.source_column_name
|
||||
)
|
||||
const [target, targetHandle] = findTablesHandleIds(
|
||||
tables,
|
||||
rel.target_table_name,
|
||||
rel.target_column_name
|
||||
)
|
||||
|
||||
// We do not support [external->this] flow currently.
|
||||
if (source && target) {
|
||||
edges.push({
|
||||
id: String(rel.id),
|
||||
source,
|
||||
sourceHandle,
|
||||
target,
|
||||
targetHandle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return getLayoutedElements(nodes, edges)
|
||||
}
|
||||
|
||||
function findTablesHandleIds(
|
||||
tables: PostgresTable[],
|
||||
table_name: string,
|
||||
column_name: string
|
||||
): [string?, string?] {
|
||||
for (const table of tables) {
|
||||
if (table_name !== table.name) continue
|
||||
|
||||
for (const column of table.columns || []) {
|
||||
if (column_name !== column.name) continue
|
||||
|
||||
return [String(table.id), column.id]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({
|
||||
rankdir: 'LR',
|
||||
align: 'UR',
|
||||
nodesep: 25,
|
||||
ranksep: 50,
|
||||
})
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: TABLE_NODE_WIDTH / 2,
|
||||
height: (TABLE_NODE_ROW_HEIGHT / 2) * (node.data.columns.length + 1), // columns + header
|
||||
})
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
node.targetPosition = Position.Left
|
||||
node.sourcePosition = Position.Right
|
||||
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||
// so it matches the React Flow node anchor point (top left).
|
||||
node.position = {
|
||||
x: nodeWithPosition.x - nodeWithPosition.width / 2,
|
||||
y: nodeWithPosition.y - nodeWithPosition.height / 2,
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const TablesGraph = ({ tables }: { tables: PostgresTable[] }) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const nodeTypes = useMemo(
|
||||
() => ({
|
||||
table: TableNode,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
getGraphDataFromTables(tables).then(({ nodes, edges }) => {
|
||||
reactFlowInstance.setNodes(nodes)
|
||||
reactFlowInstance.setEdges(edges)
|
||||
setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
|
||||
})
|
||||
}, [tables, resolvedTheme])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
defaultNodes={[]}
|
||||
defaultEdges={[]}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
deletable: false,
|
||||
style: {
|
||||
stroke: 'hsl(var(--border-stronger))',
|
||||
strokeWidth: 0.5,
|
||||
},
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
minZoom={0.8}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onInit={(flow) => {
|
||||
// Call `fitView` imperatively in order to set padding
|
||||
flow.fitView({
|
||||
padding: 0.4,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Background
|
||||
gap={16}
|
||||
className="[&>*]:stroke-foreground-muted opacity-[25%]"
|
||||
variant={BackgroundVariant.Dots}
|
||||
color={'inherit'}
|
||||
/>
|
||||
<SchemaGraphLegend />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SchemaGraph = ({ schema }: { schema: string }) => {
|
||||
const {
|
||||
data: tables,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useTablesQuery({ schemas: [schema], includeColumns: true })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center space-x-2">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm text-foreground-light">Loading table...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="px-6 py-4 text-foreground-light">
|
||||
<p>Error connecting to API</p>
|
||||
<p>{`${error?.message ?? 'Unknown error'}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<TablesGraph tables={tables!} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaGraph
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DiamondIcon, Fingerprint, Hash, Key } from 'lucide-react'
|
||||
|
||||
const SchemaGraphLegend = () => {
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 border-t flex justify-center px-1 py-2 shadow-md bg-surface-100 w-full z-10">
|
||||
<ul className="flex flex-wrap items-center justify-center gap-4">
|
||||
<li className="flex items-center text-xs font-mono gap-1">
|
||||
<Key size={15} strokeWidth={1.5} className="flex-shrink-0 text-light" />
|
||||
Primary key
|
||||
</li>
|
||||
<li className="flex items-center text-xs font-mono gap-1">
|
||||
<Hash size={15} strokeWidth={1.5} className="flex-shrink-0 text-light" />
|
||||
Identity
|
||||
</li>
|
||||
<li className="flex items-center text-xs font-mono gap-1">
|
||||
<Fingerprint size={15} strokeWidth={1.5} className="flex-shrink-0 text-light" />
|
||||
Unique
|
||||
</li>
|
||||
<li className="flex items-center text-xs font-mono gap-1">
|
||||
<DiamondIcon size={15} strokeWidth={1.5} className="flex-shrink-0 text-light" />
|
||||
Nullable
|
||||
</li>
|
||||
<li className="flex items-center text-xs font-mono gap-1">
|
||||
<DiamondIcon
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
fill="currentColor"
|
||||
className="flex-shrink-0 text-light"
|
||||
/>
|
||||
Non-Nullable
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaGraphLegend
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { db } from '~/app/page'
|
||||
import { PostgresMetaErr, getTables } from '~/lib/pg-meta'
|
||||
|
||||
export type TablesVariables = {
|
||||
schemas?: string[]
|
||||
includeColumns?: boolean
|
||||
}
|
||||
export type TablesData = PostgresTable[]
|
||||
export type TablesError = PostgresMetaErr['error']
|
||||
|
||||
export async function getTablesForQuery({ schemas, includeColumns = false }: TablesVariables) {
|
||||
const { data, error } = await getTables(db, { includedSchemas: schemas, includeColumns })
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const useTablesQuery = <TData = TablesData>(
|
||||
{ schemas, includeColumns }: TablesVariables,
|
||||
options: Omit<UseQueryOptions<TablesData, TablesError, TData>, 'queryKey' | 'queryFn'> = {}
|
||||
) =>
|
||||
useQuery<TablesData, TablesError, TData>({
|
||||
...options,
|
||||
queryKey: getTablesQueryKey({ schemas, includeColumns }),
|
||||
queryFn: () => getTablesForQuery({ schemas, includeColumns }),
|
||||
})
|
||||
|
||||
export const getTablesQueryKey = ({ schemas, includeColumns }: TablesVariables) => [
|
||||
'tables',
|
||||
{ schemas, includeColumns },
|
||||
]
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* This contains cherry-picked `@supabase/postgres-meta` functions (like listing tables)
|
||||
* that can be used browser-side with PGlite.
|
||||
*
|
||||
* https://github.com/supabase/postgres-meta
|
||||
*
|
||||
* TODO: Upstream ability to use `@supabase/postgres-meta` in the browser and remove this.
|
||||
*/
|
||||
|
||||
import { PGlite } from '@electric-sql/pglite'
|
||||
import { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
interface NoticeOrError {
|
||||
message: string | undefined
|
||||
severity: string | undefined
|
||||
code: string | undefined
|
||||
detail: string | undefined
|
||||
hint: string | undefined
|
||||
position: string | undefined
|
||||
internalPosition: string | undefined
|
||||
internalQuery: string | undefined
|
||||
where: string | undefined
|
||||
schema: string | undefined
|
||||
table: string | undefined
|
||||
column: string | undefined
|
||||
dataType: string | undefined
|
||||
constraint: string | undefined
|
||||
file: string | undefined
|
||||
line: string | undefined
|
||||
routine: string | undefined
|
||||
}
|
||||
export declare type MessageName =
|
||||
| 'parseComplete'
|
||||
| 'bindComplete'
|
||||
| 'closeComplete'
|
||||
| 'noData'
|
||||
| 'portalSuspended'
|
||||
| 'replicationStart'
|
||||
| 'emptyQuery'
|
||||
| 'copyDone'
|
||||
| 'copyData'
|
||||
| 'rowDescription'
|
||||
| 'parameterDescription'
|
||||
| 'parameterStatus'
|
||||
| 'backendKeyData'
|
||||
| 'notification'
|
||||
| 'readyForQuery'
|
||||
| 'commandComplete'
|
||||
| 'dataRow'
|
||||
| 'copyInResponse'
|
||||
| 'copyOutResponse'
|
||||
| 'authenticationOk'
|
||||
| 'authenticationMD5Password'
|
||||
| 'authenticationCleartextPassword'
|
||||
| 'authenticationSASL'
|
||||
| 'authenticationSASLContinue'
|
||||
| 'authenticationSASLFinal'
|
||||
| 'error'
|
||||
| 'notice'
|
||||
export declare class DatabaseError extends Error implements NoticeOrError {
|
||||
readonly length: number
|
||||
readonly name: MessageName
|
||||
severity: string | undefined
|
||||
code: string | undefined
|
||||
detail: string | undefined
|
||||
hint: string | undefined
|
||||
position: string | undefined
|
||||
internalPosition: string | undefined
|
||||
internalQuery: string | undefined
|
||||
where: string | undefined
|
||||
schema: string | undefined
|
||||
table: string | undefined
|
||||
column: string | undefined
|
||||
dataType: string | undefined
|
||||
constraint: string | undefined
|
||||
file: string | undefined
|
||||
line: string | undefined
|
||||
routine: string | undefined
|
||||
constructor(message: string, length: number, name: MessageName)
|
||||
}
|
||||
|
||||
export interface PostgresMetaOk<T> {
|
||||
data: T
|
||||
error: null
|
||||
}
|
||||
|
||||
export interface PostgresMetaErr {
|
||||
data: null
|
||||
error: Partial<DatabaseError> & { message: string; formattedError?: string }
|
||||
}
|
||||
|
||||
export type PostgresMetaResult<T> = PostgresMetaOk<T> | PostgresMetaErr
|
||||
|
||||
const tablesSql = `
|
||||
SELECT
|
||||
c.oid :: int8 AS id,
|
||||
nc.nspname AS schema,
|
||||
c.relname AS name,
|
||||
c.relrowsecurity AS rls_enabled,
|
||||
c.relforcerowsecurity AS rls_forced,
|
||||
CASE
|
||||
WHEN c.relreplident = 'd' THEN 'DEFAULT'
|
||||
WHEN c.relreplident = 'i' THEN 'INDEX'
|
||||
WHEN c.relreplident = 'f' THEN 'FULL'
|
||||
ELSE 'NOTHING'
|
||||
END AS replica_identity,
|
||||
pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) :: int8 AS bytes,
|
||||
pg_size_pretty(
|
||||
pg_total_relation_size(format('%I.%I', nc.nspname, c.relname))
|
||||
) AS size,
|
||||
pg_stat_get_live_tuples(c.oid) AS live_rows_estimate,
|
||||
pg_stat_get_dead_tuples(c.oid) AS dead_rows_estimate,
|
||||
obj_description(c.oid) AS comment,
|
||||
coalesce(pk.primary_keys, '[]') as primary_keys,
|
||||
coalesce(
|
||||
jsonb_agg(relationships) filter (where relationships is not null),
|
||||
'[]'
|
||||
) as relationships
|
||||
FROM
|
||||
pg_namespace nc
|
||||
JOIN pg_class c ON nc.oid = c.relnamespace
|
||||
left join (
|
||||
select
|
||||
table_id,
|
||||
jsonb_agg(_pk.*) as primary_keys
|
||||
from (
|
||||
select
|
||||
n.nspname as schema,
|
||||
c.relname as table_name,
|
||||
a.attname as name,
|
||||
c.oid :: int8 as table_id
|
||||
from
|
||||
pg_index i,
|
||||
pg_class c,
|
||||
pg_attribute a,
|
||||
pg_namespace n
|
||||
where
|
||||
i.indrelid = c.oid
|
||||
and c.relnamespace = n.oid
|
||||
and a.attrelid = c.oid
|
||||
and a.attnum = any (i.indkey)
|
||||
and i.indisprimary
|
||||
) as _pk
|
||||
group by table_id
|
||||
) as pk
|
||||
on pk.table_id = c.oid
|
||||
left join (
|
||||
select
|
||||
c.oid :: int8 as id,
|
||||
c.conname as constraint_name,
|
||||
nsa.nspname as source_schema,
|
||||
csa.relname as source_table_name,
|
||||
sa.attname as source_column_name,
|
||||
nta.nspname as target_table_schema,
|
||||
cta.relname as target_table_name,
|
||||
ta.attname as target_column_name
|
||||
from
|
||||
pg_constraint c
|
||||
join (
|
||||
pg_attribute sa
|
||||
join pg_class csa on sa.attrelid = csa.oid
|
||||
join pg_namespace nsa on csa.relnamespace = nsa.oid
|
||||
) on sa.attrelid = c.conrelid and sa.attnum = any (c.conkey)
|
||||
join (
|
||||
pg_attribute ta
|
||||
join pg_class cta on ta.attrelid = cta.oid
|
||||
join pg_namespace nta on cta.relnamespace = nta.oid
|
||||
) on ta.attrelid = c.confrelid and ta.attnum = any (c.confkey)
|
||||
where
|
||||
c.contype = 'f'
|
||||
) as relationships
|
||||
on (relationships.source_schema = nc.nspname and relationships.source_table_name = c.relname)
|
||||
or (relationships.target_table_schema = nc.nspname and relationships.target_table_name = c.relname)
|
||||
WHERE
|
||||
c.relkind IN ('r', 'p')
|
||||
AND NOT pg_is_other_temp_schema(nc.oid)
|
||||
AND (
|
||||
pg_has_role(c.relowner, 'USAGE')
|
||||
OR has_table_privilege(
|
||||
c.oid,
|
||||
'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'
|
||||
)
|
||||
OR has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
|
||||
)
|
||||
group by
|
||||
c.oid,
|
||||
c.relname,
|
||||
c.relrowsecurity,
|
||||
c.relforcerowsecurity,
|
||||
c.relreplident,
|
||||
nc.nspname,
|
||||
pk.primary_keys
|
||||
`
|
||||
|
||||
const columnsSql = `
|
||||
-- Adapted from information_schema.columns
|
||||
|
||||
SELECT
|
||||
c.oid :: int8 AS table_id,
|
||||
nc.nspname AS schema,
|
||||
c.relname AS table,
|
||||
(c.oid || '.' || a.attnum) AS id,
|
||||
a.attnum AS ordinal_position,
|
||||
a.attname AS name,
|
||||
CASE
|
||||
WHEN a.atthasdef THEN pg_get_expr(ad.adbin, ad.adrelid)
|
||||
ELSE NULL
|
||||
END AS default_value,
|
||||
CASE
|
||||
WHEN t.typtype = 'd' THEN CASE
|
||||
WHEN bt.typelem <> 0 :: oid
|
||||
AND bt.typlen = -1 THEN 'ARRAY'
|
||||
WHEN nbt.nspname = 'pg_catalog' THEN format_type(t.typbasetype, NULL)
|
||||
ELSE 'USER-DEFINED'
|
||||
END
|
||||
ELSE CASE
|
||||
WHEN t.typelem <> 0 :: oid
|
||||
AND t.typlen = -1 THEN 'ARRAY'
|
||||
WHEN nt.nspname = 'pg_catalog' THEN format_type(a.atttypid, NULL)
|
||||
ELSE 'USER-DEFINED'
|
||||
END
|
||||
END AS data_type,
|
||||
COALESCE(bt.typname, t.typname) AS format,
|
||||
a.attidentity IN ('a', 'd') AS is_identity,
|
||||
CASE
|
||||
a.attidentity
|
||||
WHEN 'a' THEN 'ALWAYS'
|
||||
WHEN 'd' THEN 'BY DEFAULT'
|
||||
ELSE NULL
|
||||
END AS identity_generation,
|
||||
a.attgenerated IN ('s') AS is_generated,
|
||||
NOT (
|
||||
a.attnotnull
|
||||
OR t.typtype = 'd' AND t.typnotnull
|
||||
) AS is_nullable,
|
||||
(
|
||||
c.relkind IN ('r', 'p')
|
||||
OR c.relkind IN ('v', 'f') AND pg_column_is_updatable(c.oid, a.attnum, FALSE)
|
||||
) AS is_updatable,
|
||||
uniques.table_id IS NOT NULL AS is_unique,
|
||||
check_constraints.definition AS "check",
|
||||
array_to_json(
|
||||
array(
|
||||
SELECT
|
||||
enumlabel
|
||||
FROM
|
||||
pg_catalog.pg_enum enums
|
||||
WHERE
|
||||
enums.enumtypid = coalesce(bt.oid, t.oid)
|
||||
OR enums.enumtypid = coalesce(bt.typelem, t.typelem)
|
||||
ORDER BY
|
||||
enums.enumsortorder
|
||||
)
|
||||
) AS enums,
|
||||
col_description(c.oid, a.attnum) AS comment
|
||||
FROM
|
||||
pg_attribute a
|
||||
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid
|
||||
AND a.attnum = ad.adnum
|
||||
JOIN (
|
||||
pg_class c
|
||||
JOIN pg_namespace nc ON c.relnamespace = nc.oid
|
||||
) ON a.attrelid = c.oid
|
||||
JOIN (
|
||||
pg_type t
|
||||
JOIN pg_namespace nt ON t.typnamespace = nt.oid
|
||||
) ON a.atttypid = t.oid
|
||||
LEFT JOIN (
|
||||
pg_type bt
|
||||
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid
|
||||
) ON t.typtype = 'd'
|
||||
AND t.typbasetype = bt.oid
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (table_id, ordinal_position)
|
||||
conrelid AS table_id,
|
||||
conkey[1] AS ordinal_position
|
||||
FROM pg_catalog.pg_constraint
|
||||
WHERE contype = 'u' AND cardinality(conkey) = 1
|
||||
) AS uniques ON uniques.table_id = c.oid AND uniques.ordinal_position = a.attnum
|
||||
LEFT JOIN (
|
||||
-- We only select the first column check
|
||||
SELECT DISTINCT ON (table_id, ordinal_position)
|
||||
conrelid AS table_id,
|
||||
conkey[1] AS ordinal_position,
|
||||
substring(
|
||||
pg_get_constraintdef(pg_constraint.oid, true),
|
||||
8,
|
||||
length(pg_get_constraintdef(pg_constraint.oid, true)) - 8
|
||||
) AS "definition"
|
||||
FROM pg_constraint
|
||||
WHERE contype = 'c' AND cardinality(conkey) = 1
|
||||
ORDER BY table_id, ordinal_position, oid asc
|
||||
) AS check_constraints ON check_constraints.table_id = c.oid AND check_constraints.ordinal_position = a.attnum
|
||||
WHERE
|
||||
NOT pg_is_other_temp_schema(nc.oid)
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND (c.relkind IN ('r', 'v', 'm', 'f', 'p'))
|
||||
AND (
|
||||
pg_has_role(c.relowner, 'USAGE')
|
||||
OR has_column_privilege(
|
||||
c.oid,
|
||||
a.attnum,
|
||||
'SELECT, INSERT, UPDATE, REFERENCES'
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
const coalesceRowsToArray = (source: string, filter: string) => {
|
||||
return `
|
||||
COALESCE(
|
||||
(
|
||||
SELECT
|
||||
array_agg(row_to_json(${source})) FILTER (WHERE ${filter})
|
||||
FROM
|
||||
${source}
|
||||
),
|
||||
'{}'
|
||||
) AS ${source}`
|
||||
}
|
||||
|
||||
function arrayToList<T>(useSpace: boolean, array: T[], formatter: (value: T) => string) {
|
||||
let sql = ''
|
||||
|
||||
sql += useSpace ? ' (' : '('
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
sql += (i === 0 ? '' : ', ') + formatter(array[i])
|
||||
}
|
||||
sql += ')'
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
date = date.replace('T', ' ')
|
||||
date = date.replace('Z', '+00')
|
||||
return date
|
||||
}
|
||||
|
||||
function quoteLiteral(value: any): string {
|
||||
let literal = null
|
||||
let explicitCast = null
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return 'NULL'
|
||||
} else if (value === false) {
|
||||
return "'f'"
|
||||
} else if (value === true) {
|
||||
return "'t'"
|
||||
} else if (value instanceof Date) {
|
||||
return "'" + formatDate(value.toISOString()) + "'"
|
||||
} else if (value instanceof Buffer) {
|
||||
return "E'\\\\x" + value.toString('hex') + "'"
|
||||
} else if (Array.isArray(value) === true) {
|
||||
const temp = []
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (Array.isArray(value[i]) === true) {
|
||||
temp.push(arrayToList(i !== 0, value[i], quoteLiteral))
|
||||
} else {
|
||||
temp.push(quoteLiteral(value[i]))
|
||||
}
|
||||
}
|
||||
return temp.toString()
|
||||
} else if (value === Object(value)) {
|
||||
explicitCast = 'jsonb'
|
||||
literal = JSON.stringify(value)
|
||||
} else {
|
||||
literal = value.toString().slice(0) // create copy
|
||||
}
|
||||
|
||||
let hasBackslash = false
|
||||
let quoted = "'"
|
||||
|
||||
for (let i = 0; i < literal.length; i++) {
|
||||
const c = literal[i]
|
||||
if (c === "'") {
|
||||
quoted += c + c
|
||||
} else if (c === '\\') {
|
||||
quoted += c + c
|
||||
hasBackslash = true
|
||||
} else {
|
||||
quoted += c
|
||||
}
|
||||
}
|
||||
|
||||
quoted += "'"
|
||||
|
||||
if (hasBackslash === true) {
|
||||
quoted = 'E' + quoted
|
||||
}
|
||||
|
||||
if (explicitCast) {
|
||||
quoted += '::' + explicitCast
|
||||
}
|
||||
|
||||
return quoted
|
||||
}
|
||||
|
||||
export const filterByList = (include?: string[], exclude?: string[], defaultExclude?: string[]) => {
|
||||
if (defaultExclude) {
|
||||
exclude = defaultExclude.concat(exclude ?? [])
|
||||
}
|
||||
if (include?.length) {
|
||||
return `IN (${include.map(quoteLiteral).join(',')})`
|
||||
} else if (exclude?.length) {
|
||||
return `NOT IN (${exclude.map(quoteLiteral).join(',')})`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const generateEnrichedTablesSql = ({ includeColumns }: { includeColumns: boolean }) => `
|
||||
with tables as (${tablesSql})
|
||||
${includeColumns ? `, columns as (${columnsSql})` : ''}
|
||||
select
|
||||
*
|
||||
${includeColumns ? `, ${coalesceRowsToArray('columns', 'columns.table_id = tables.id')}` : ''}
|
||||
from tables`
|
||||
|
||||
const DEFAULT_SYSTEM_SCHEMAS = ['information_schema', 'pg_catalog', 'pg_toast']
|
||||
|
||||
type GetTablesOptions = {
|
||||
includedSchemas?: string[]
|
||||
excludedSchemas?: string[]
|
||||
includeSystemSchemas?: boolean
|
||||
includeColumns?: boolean
|
||||
}
|
||||
|
||||
export async function getTables(
|
||||
db: PGlite,
|
||||
options?: GetTablesOptions
|
||||
): Promise<PostgresMetaResult<PostgresTable[]>> {
|
||||
const {
|
||||
includedSchemas,
|
||||
excludedSchemas,
|
||||
includeSystemSchemas = false,
|
||||
includeColumns = true,
|
||||
} = options ?? {}
|
||||
|
||||
let sql = generateEnrichedTablesSql({ includeColumns })
|
||||
|
||||
const filter = filterByList(
|
||||
includedSchemas,
|
||||
excludedSchemas,
|
||||
!includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined
|
||||
)
|
||||
|
||||
if (filter) {
|
||||
sql += ` where schema ${filter}`
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.query<PostgresTable>(sql)
|
||||
return { data: res.rows, error: null }
|
||||
} catch (error: any) {
|
||||
if (error instanceof Error) {
|
||||
const databaseError: DatabaseError = Object.assign(
|
||||
new DatabaseError(error.message, 0, 'error'),
|
||||
error
|
||||
)
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
...databaseError,
|
||||
// error.message is non-enumerable
|
||||
message: error.message,
|
||||
formattedError: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { data: null, error: { message: error.message } }
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^0.0.21",
|
||||
"@dagrejs/dagre": "^1.1.2",
|
||||
"@electric-sql/pglite": "^0.1.5",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@supabase/postgres-meta": "^0.81.2",
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"ai": "^3.1.27",
|
||||
"ai-sandbox": "*",
|
||||
"chart.js": "^4.4.3",
|
||||
@@ -27,6 +30,7 @@
|
||||
"react-dom": "^18",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-markdown": "^9.0.1",
|
||||
"reactflow": "^11.11.3",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
Generated
+775
-215
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user