mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
Extend the "Copy as Markdown" feature in the Schema Visualizer to include Custom Types/Enums and Row Level Security (RLS) Policies in the generated output. Closes https://github.com/orgs/supabase/discussions/46108 ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature — extend Copy as Markdown to include enums and RLS policies ## What is the current behavior? The "Copy as Markdown" function in the Schema Visualizer generates a SCHEMA.md that only includes tables, columns, and entity relationships. Custom Types/Enums and RLS Policies are missing, making the output an incomplete representation of the schema. Related: https://github.com/orgs/supabase/discussions/46108 ## What is the new behavior? The generated markdown now includes two additional sections: **Custom Types / Enums:** - Lists each enum type with its ordered permitted values - Filtered by the currently selected schema **RLS Policies:** - Grouped by table for self-contained readability - Includes policy name, command (SELECT/INSERT/UPDATE/DELETE/ALL), roles, action (PERMISSIVE/RESTRICTIVE), USING expression, and WITH CHECK expression **Example output:** ```markdown ## Custom Types / Enums ### `order_status` `pending` | `processing` | `shipped` | `delivered` ## RLS Policies ### `orders` | Policy | Command | Roles | Action | USING | WITH CHECK | |--------|---------|-------|--------|-------|------------| | `users_own_orders` | SELECT | authenticated | PERMISSIVE | `auth.uid() = user_id` | — | ``` ## Additional context - 3 files changed: `Schemas.utils.ts`, `SchemaGraph.tsx`, `Schemas.utils.test.ts` - New utility functions `getEnumsAsMarkdown()` and `getPoliciesAsMarkdown()` with unit tests - Reuses existing `useEnumeratedTypesQuery` and `useDatabasePoliciesQuery` hooks - No breaking changes — existing markdown output is preserved, new sections are appended <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * "Copy as Markdown" now includes enumerated types and database policies alongside existing schema/table content; policies are included with their rule details and grouped by table. * **Tests** * Added tests covering enum and policy markdown generation, including matching/non-matching schema cases and policy formatting. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46189?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import dagre from '@dagrejs/dagre'
|
|
import type { PGSchema, PGTable } from '@supabase/pg-meta'
|
|
import { Edge, Node, Position } from '@xyflow/react'
|
|
import { uniqBy } from 'lodash'
|
|
|
|
import '@xyflow/react/dist/style.css'
|
|
|
|
import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
|
|
|
|
import { TableNodeData } from './Schemas.constants'
|
|
import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH } from './SchemaTableNode'
|
|
import { tryParseJson } from '@/lib/helpers'
|
|
|
|
const NODE_SEP = 25
|
|
const RANK_SEP = 50
|
|
|
|
export async function getGraphDataFromTables(
|
|
ref?: string,
|
|
schema?: PGSchema,
|
|
tables?: PGTable[]
|
|
): 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,
|
|
description: column.comment ?? '',
|
|
}
|
|
})
|
|
|
|
const data: TableNodeData = {
|
|
ref,
|
|
id: table.id,
|
|
name: table.name,
|
|
description: table.comment ?? '',
|
|
schema: table.schema,
|
|
isForeign: false,
|
|
columns,
|
|
}
|
|
|
|
return {
|
|
data,
|
|
id: `${table.id}`,
|
|
type: 'table',
|
|
position: { x: 0, y: 0 },
|
|
}
|
|
})
|
|
|
|
const edges: Edge[] = []
|
|
const currentSchema = tables[0].schema
|
|
const uniqueRelationships = uniqBy(
|
|
tables.flatMap((t) => t.relationships),
|
|
'id'
|
|
)
|
|
|
|
// Precompute name → { tableId, columnsByName } lookup so each relationship
|
|
// resolves its source/target handles in O(1) instead of scanning every table+column.
|
|
const tablesByName = new Map<string, { tableId: number; columnsByName: Map<string, string> }>()
|
|
for (const table of tables) {
|
|
const columnsByName = new Map<string, string>()
|
|
for (const column of table.columns || []) {
|
|
columnsByName.set(column.name, column.id)
|
|
}
|
|
tablesByName.set(table.name, { tableId: table.id, columnsByName })
|
|
}
|
|
|
|
const findHandleIds = (tableName: string, columnName: string): [string?, string?] => {
|
|
const entry = tablesByName.get(tableName)
|
|
if (!entry) return []
|
|
const columnId = entry.columnsByName.get(columnName)
|
|
if (columnId === undefined) return []
|
|
return [String(entry.tableId), columnId]
|
|
}
|
|
|
|
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) {
|
|
const targetId = `${rel.target_table_schema}.${rel.target_table_name}.${rel.target_column_name}`
|
|
|
|
const targetNode = nodes.find((n) => n.id === targetId)
|
|
if (!targetNode) {
|
|
const data: TableNodeData = {
|
|
id: rel.id,
|
|
ref: ref!,
|
|
schema: rel.target_table_schema,
|
|
name: targetId,
|
|
description: '',
|
|
isForeign: true,
|
|
columns: [],
|
|
}
|
|
|
|
nodes.push({
|
|
id: targetId,
|
|
type: 'table',
|
|
data: data,
|
|
position: { x: 0, y: 0 },
|
|
})
|
|
}
|
|
|
|
const [source, sourceHandle] = findHandleIds(rel.source_table_name, rel.source_column_name)
|
|
|
|
if (source) {
|
|
edges.push({
|
|
id: String(rel.id),
|
|
source,
|
|
sourceHandle,
|
|
target: targetId,
|
|
targetHandle: targetId,
|
|
deletable: false,
|
|
data: {
|
|
sourceName: rel.source_table_name,
|
|
sourceSchemaName: rel.source_schema,
|
|
sourceColumnName: rel.source_column_name,
|
|
targetName: rel.target_table_name,
|
|
targetSchemaName: rel.target_table_schema,
|
|
targetColumnName: rel.target_column_name,
|
|
},
|
|
})
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
const [source, sourceHandle] = findHandleIds(rel.source_table_name, rel.source_column_name)
|
|
const [target, targetHandle] = findHandleIds(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,
|
|
type: 'default',
|
|
data: {
|
|
sourceName: rel.source_table_name,
|
|
sourceSchemaName: rel.source_schema,
|
|
sourceColumnName: rel.source_column_name,
|
|
targetName: rel.target_table_name,
|
|
targetSchemaName: rel.target_table_schema,
|
|
targetColumnName: rel.target_column_name,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const savedPositionsLocalStorage = safeLocalStorage.getItem(
|
|
LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref ?? 'project', schema?.id ?? 0)
|
|
)
|
|
const savedPositions = tryParseJson(savedPositionsLocalStorage)
|
|
return !!savedPositions
|
|
? getLayoutedElementsViaLocalStorage(nodes, edges, savedPositions)
|
|
: getLayoutedElementsViaDagre(nodes, edges)
|
|
}
|
|
|
|
export const getLayoutedElementsViaDagre = (nodes: Node<TableNodeData>[], edges: Edge[]) => {
|
|
const dagreGraph = new dagre.graphlib.Graph()
|
|
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
|
dagreGraph.setGraph({
|
|
rankdir: 'LR',
|
|
align: 'UR',
|
|
nodesep: NODE_SEP,
|
|
ranksep: RANK_SEP,
|
|
})
|
|
|
|
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 getLayoutedElementsViaLocalStorage = (
|
|
nodes: Node<TableNodeData>[],
|
|
edges: Edge[],
|
|
positions: { [key: string]: { x: number; y: number } }
|
|
) => {
|
|
// [Joshen] Potentially look into auto fitting new nodes?
|
|
// https://github.com/xyflow/xyflow/issues/1113
|
|
|
|
const nodesWithNoSavedPositons = nodes.filter((n) => !(n.id in positions))
|
|
let newNodeCount = 0
|
|
let basePosition = {
|
|
x: 0,
|
|
y: -(NODE_SEP + TABLE_NODE_ROW_HEIGHT + nodesWithNoSavedPositons.length * 10),
|
|
}
|
|
|
|
nodes.forEach((node) => {
|
|
const existingPosition = positions?.[node.id]
|
|
|
|
node.targetPosition = Position.Left
|
|
node.sourcePosition = Position.Right
|
|
|
|
if (existingPosition) {
|
|
node.position = existingPosition
|
|
} else {
|
|
node.position = {
|
|
x: basePosition.x + newNodeCount * 10,
|
|
y: basePosition.y + newNodeCount * 10,
|
|
}
|
|
newNodeCount += 1
|
|
}
|
|
})
|
|
return { nodes, edges }
|
|
}
|
|
|
|
export const getTableDefinitionAsMarkdown = (table: TableNodeData) => {
|
|
let markdown = `## Table \`${escapeForMarkdown(table.name)}\`\n\n`
|
|
if (table.description) {
|
|
markdown += `${table.description}\n\n`
|
|
}
|
|
markdown += `### Columns\n\n`
|
|
markdown += `| Name | Type | Constraints |\n`
|
|
markdown += `|------|------|-------------|\n`
|
|
|
|
return table.columns.reduce((current, column) => {
|
|
current += `| \`${escapeForMarkdown(column.name)}\` | \`${escapeForMarkdown(column.format)}\` | ${column.isPrimary ? 'Primary' : ''}${column.isNullable ? ' Nullable' : ''}${column.isUnique ? ' Unique' : ''}${column.isIdentity ? ' Identity' : ''} |\n`
|
|
return current
|
|
}, markdown)
|
|
}
|
|
|
|
export const getSchemaAsMarkdown = (schema: string, tables: TableNodeData[]) => {
|
|
return tables.reduce((current, table) => {
|
|
if (table.schema === schema) {
|
|
current += `${getTableDefinitionAsMarkdown(table)}\n`
|
|
}
|
|
return current
|
|
}, '')
|
|
}
|
|
|
|
const escapeForMarkdown = (str: string) => {
|
|
return (
|
|
str
|
|
// Escape backslashes first so later escapes are not ambiguous
|
|
.replace(/\\/g, '\\\\')
|
|
// Escape backticks and pipes for markdown tables
|
|
.replace(/([|`])/g, '\\$1')
|
|
// Remove new lines
|
|
.replace(/\n/g, ' ')
|
|
)
|
|
}
|
|
|
|
// ── Enum / Custom Type markdown ────────────────────────────
|
|
|
|
export type EnumForMarkdown = {
|
|
name: string
|
|
schema: string
|
|
enums: string[]
|
|
}
|
|
|
|
export const getEnumsAsMarkdown = (schema: string, enums: EnumForMarkdown[]): string => {
|
|
const filtered = enums.filter((e) => e.schema === schema && e.enums.length > 0)
|
|
if (filtered.length === 0) return ''
|
|
|
|
let md = `## Custom Types / Enums\n\n`
|
|
for (const enumType of filtered) {
|
|
const values = enumType.enums.map((v) => `\`${escapeForMarkdown(v)}\``).join(' | ')
|
|
md += `### \`${escapeForMarkdown(enumType.name)}\`\n\n${values}\n\n`
|
|
}
|
|
return md
|
|
}
|
|
|
|
// ── RLS Policy markdown ────────────────────────────────────
|
|
|
|
export type PolicyForMarkdown = {
|
|
name: string
|
|
schema: string
|
|
table: string
|
|
command: string
|
|
roles: string[]
|
|
action: string
|
|
definition: string | null
|
|
check: string | null
|
|
}
|
|
|
|
export const getPoliciesAsMarkdown = (schema: string, policies: PolicyForMarkdown[]): string => {
|
|
const filtered = policies.filter((p) => p.schema === schema)
|
|
if (filtered.length === 0) return ''
|
|
|
|
// Group by table
|
|
const byTable = new Map<string, PolicyForMarkdown[]>()
|
|
for (const policy of filtered) {
|
|
const existing = byTable.get(policy.table) ?? []
|
|
existing.push(policy)
|
|
byTable.set(policy.table, existing)
|
|
}
|
|
|
|
let md = `## RLS Policies\n\n`
|
|
for (const [table, tablePolicies] of byTable) {
|
|
md += `### \`${escapeForMarkdown(table)}\`\n\n`
|
|
md += `| Policy | Command | Roles | Action | USING | WITH CHECK |\n`
|
|
md += `|--------|---------|-------|--------|-------|------------|\n`
|
|
for (const p of tablePolicies) {
|
|
const roles = p.roles.map((r) => escapeForMarkdown(r)).join(', ')
|
|
const using = p.definition ? `\`${escapeForMarkdown(p.definition)}\`` : '—'
|
|
const check = p.check ? `\`${escapeForMarkdown(p.check)}\`` : '—'
|
|
md += `| \`${escapeForMarkdown(p.name)}\` | ${p.command} | ${roles} | ${p.action} | ${using} | ${check} |\n`
|
|
}
|
|
md += `\n`
|
|
}
|
|
return md
|
|
}
|