Schemas.utils.ts294 lines · main
| 1 | import dagre from '@dagrejs/dagre' |
| 2 | import type { PGSchema, PGTable } from '@supabase/pg-meta' |
| 3 | import { Edge, Node, Position } from '@xyflow/react' |
| 4 | import { uniqBy } from 'lodash' |
| 5 | |
| 6 | import '@xyflow/react/dist/style.css' |
| 7 | |
| 8 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 9 | |
| 10 | import { TableNodeData } from './Schemas.constants' |
| 11 | import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH } from './SchemaTableNode' |
| 12 | import { tryParseJson } from '@/lib/helpers' |
| 13 | |
| 14 | const NODE_SEP = 25 |
| 15 | const RANK_SEP = 50 |
| 16 | |
| 17 | export async function getGraphDataFromTables( |
| 18 | ref?: string, |
| 19 | schema?: PGSchema, |
| 20 | tables?: PGTable[] |
| 21 | ): Promise<{ |
| 22 | nodes: Node<TableNodeData>[] |
| 23 | edges: Edge[] |
| 24 | }> { |
| 25 | if (!tables?.length) { |
| 26 | return { nodes: [], edges: [] } |
| 27 | } |
| 28 | |
| 29 | const nodes = tables.map((table) => { |
| 30 | const columns = (table.columns || []).map((column) => { |
| 31 | return { |
| 32 | id: column.id, |
| 33 | isPrimary: table.primary_keys.some((pk) => pk.name === column.name), |
| 34 | name: column.name, |
| 35 | format: column.format, |
| 36 | isNullable: column.is_nullable, |
| 37 | isUnique: column.is_unique, |
| 38 | isUpdateable: column.is_updatable, |
| 39 | isIdentity: column.is_identity, |
| 40 | description: column.comment ?? '', |
| 41 | } |
| 42 | }) |
| 43 | |
| 44 | const data: TableNodeData = { |
| 45 | ref, |
| 46 | id: table.id, |
| 47 | name: table.name, |
| 48 | description: table.comment ?? '', |
| 49 | schema: table.schema, |
| 50 | isForeign: false, |
| 51 | columns, |
| 52 | } |
| 53 | |
| 54 | return { |
| 55 | data, |
| 56 | id: `${table.id}`, |
| 57 | type: 'table', |
| 58 | position: { x: 0, y: 0 }, |
| 59 | } |
| 60 | }) |
| 61 | |
| 62 | const edges: Edge[] = [] |
| 63 | const currentSchema = tables[0].schema |
| 64 | const uniqueRelationships = uniqBy( |
| 65 | tables.flatMap((t) => t.relationships), |
| 66 | 'id' |
| 67 | ) |
| 68 | |
| 69 | for (const rel of uniqueRelationships) { |
| 70 | // TODO: Support [external->this] relationship? |
| 71 | if (rel.source_schema !== currentSchema) { |
| 72 | continue |
| 73 | } |
| 74 | |
| 75 | // Create additional [this->foreign] node that we can point to on the graph. |
| 76 | if (rel.target_table_schema !== currentSchema) { |
| 77 | const targetId = `${rel.target_table_schema}.${rel.target_table_name}.${rel.target_column_name}` |
| 78 | |
| 79 | const targetNode = nodes.find((n) => n.id === targetId) |
| 80 | if (!targetNode) { |
| 81 | const data: TableNodeData = { |
| 82 | id: rel.id, |
| 83 | ref: ref!, |
| 84 | schema: rel.target_table_schema, |
| 85 | name: targetId, |
| 86 | description: '', |
| 87 | isForeign: true, |
| 88 | columns: [], |
| 89 | } |
| 90 | |
| 91 | nodes.push({ |
| 92 | id: targetId, |
| 93 | type: 'table', |
| 94 | data: data, |
| 95 | position: { x: 0, y: 0 }, |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | const [source, sourceHandle] = findTablesHandleIds( |
| 100 | tables, |
| 101 | rel.source_table_name, |
| 102 | rel.source_column_name |
| 103 | ) |
| 104 | |
| 105 | if (source) { |
| 106 | edges.push({ |
| 107 | id: String(rel.id), |
| 108 | source, |
| 109 | sourceHandle, |
| 110 | target: targetId, |
| 111 | targetHandle: targetId, |
| 112 | deletable: false, |
| 113 | data: { |
| 114 | sourceName: rel.source_table_name, |
| 115 | sourceSchemaName: rel.source_schema, |
| 116 | sourceColumnName: rel.source_column_name, |
| 117 | targetName: rel.target_table_name, |
| 118 | targetSchemaName: rel.target_table_schema, |
| 119 | targetColumnName: rel.target_column_name, |
| 120 | }, |
| 121 | }) |
| 122 | } |
| 123 | |
| 124 | continue |
| 125 | } |
| 126 | |
| 127 | const [source, sourceHandle] = findTablesHandleIds( |
| 128 | tables, |
| 129 | rel.source_table_name, |
| 130 | rel.source_column_name |
| 131 | ) |
| 132 | const [target, targetHandle] = findTablesHandleIds( |
| 133 | tables, |
| 134 | rel.target_table_name, |
| 135 | rel.target_column_name |
| 136 | ) |
| 137 | |
| 138 | // We do not support [external->this] flow currently. |
| 139 | if (source && target) { |
| 140 | edges.push({ |
| 141 | id: String(rel.id), |
| 142 | source, |
| 143 | sourceHandle, |
| 144 | target, |
| 145 | targetHandle, |
| 146 | type: 'default', |
| 147 | data: { |
| 148 | sourceName: rel.source_table_name, |
| 149 | sourceSchemaName: rel.source_schema, |
| 150 | sourceColumnName: rel.source_column_name, |
| 151 | targetName: rel.target_table_name, |
| 152 | targetSchemaName: rel.target_table_schema, |
| 153 | targetColumnName: rel.target_column_name, |
| 154 | }, |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | const savedPositionsLocalStorage = localStorage.getItem( |
| 160 | LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref ?? 'project', schema?.id ?? 0) |
| 161 | ) |
| 162 | const savedPositions = tryParseJson(savedPositionsLocalStorage) |
| 163 | return !!savedPositions |
| 164 | ? getLayoutedElementsViaLocalStorage(nodes, edges, savedPositions) |
| 165 | : getLayoutedElementsViaDagre(nodes, edges) |
| 166 | } |
| 167 | |
| 168 | function findTablesHandleIds( |
| 169 | tables: PGTable[], |
| 170 | table_name: string, |
| 171 | column_name: string |
| 172 | ): [string?, string?] { |
| 173 | for (const table of tables) { |
| 174 | if (table_name !== table.name) continue |
| 175 | |
| 176 | for (const column of table.columns || []) { |
| 177 | if (column_name !== column.name) continue |
| 178 | |
| 179 | return [String(table.id), column.id] |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | return [] |
| 184 | } |
| 185 | |
| 186 | export const getLayoutedElementsViaDagre = (nodes: Node<TableNodeData>[], edges: Edge[]) => { |
| 187 | const dagreGraph = new dagre.graphlib.Graph() |
| 188 | dagreGraph.setDefaultEdgeLabel(() => ({})) |
| 189 | dagreGraph.setGraph({ |
| 190 | rankdir: 'LR', |
| 191 | align: 'UR', |
| 192 | nodesep: NODE_SEP, |
| 193 | ranksep: RANK_SEP, |
| 194 | }) |
| 195 | |
| 196 | nodes.forEach((node) => { |
| 197 | dagreGraph.setNode(node.id, { |
| 198 | width: TABLE_NODE_WIDTH / 2, |
| 199 | height: (TABLE_NODE_ROW_HEIGHT / 2) * (node.data.columns.length + 1), // columns + header |
| 200 | }) |
| 201 | }) |
| 202 | |
| 203 | edges.forEach((edge) => { |
| 204 | dagreGraph.setEdge(edge.source, edge.target) |
| 205 | }) |
| 206 | |
| 207 | dagre.layout(dagreGraph) |
| 208 | |
| 209 | nodes.forEach((node) => { |
| 210 | const nodeWithPosition = dagreGraph.node(node.id) |
| 211 | node.targetPosition = Position.Left |
| 212 | node.sourcePosition = Position.Right |
| 213 | // We are shifting the dagre node position (anchor=center center) to the top left |
| 214 | // so it matches the React Flow node anchor point (top left). |
| 215 | node.position = { |
| 216 | x: nodeWithPosition.x - nodeWithPosition.width / 2, |
| 217 | y: nodeWithPosition.y - nodeWithPosition.height / 2, |
| 218 | } |
| 219 | |
| 220 | return node |
| 221 | }) |
| 222 | |
| 223 | return { nodes, edges } |
| 224 | } |
| 225 | |
| 226 | const getLayoutedElementsViaLocalStorage = ( |
| 227 | nodes: Node<TableNodeData>[], |
| 228 | edges: Edge[], |
| 229 | positions: { [key: string]: { x: number; y: number } } |
| 230 | ) => { |
| 231 | // [Joshen] Potentially look into auto fitting new nodes? |
| 232 | // https://github.com/xyflow/xyflow/issues/1113 |
| 233 | |
| 234 | const nodesWithNoSavedPositons = nodes.filter((n) => !(n.id in positions)) |
| 235 | let newNodeCount = 0 |
| 236 | let basePosition = { |
| 237 | x: 0, |
| 238 | y: -(NODE_SEP + TABLE_NODE_ROW_HEIGHT + nodesWithNoSavedPositons.length * 10), |
| 239 | } |
| 240 | |
| 241 | nodes.forEach((node) => { |
| 242 | const existingPosition = positions?.[node.id] |
| 243 | |
| 244 | node.targetPosition = Position.Left |
| 245 | node.sourcePosition = Position.Right |
| 246 | |
| 247 | if (existingPosition) { |
| 248 | node.position = existingPosition |
| 249 | } else { |
| 250 | node.position = { |
| 251 | x: basePosition.x + newNodeCount * 10, |
| 252 | y: basePosition.y + newNodeCount * 10, |
| 253 | } |
| 254 | newNodeCount += 1 |
| 255 | } |
| 256 | }) |
| 257 | return { nodes, edges } |
| 258 | } |
| 259 | |
| 260 | export const getTableDefinitionAsMarkdown = (table: TableNodeData) => { |
| 261 | let markdown = `## Table \`${escapeForMarkdown(table.name)}\`\n\n` |
| 262 | if (table.description) { |
| 263 | markdown += `${table.description}\n\n` |
| 264 | } |
| 265 | markdown += `### Columns\n\n` |
| 266 | markdown += `| Name | Type | Constraints |\n` |
| 267 | markdown += `|------|------|-------------|\n` |
| 268 | |
| 269 | return table.columns.reduce((current, column) => { |
| 270 | current += `| \`${escapeForMarkdown(column.name)}\` | \`${escapeForMarkdown(column.format)}\` | ${column.isPrimary ? 'Primary' : ''}${column.isNullable ? ' Nullable' : ''}${column.isUnique ? ' Unique' : ''}${column.isIdentity ? ' Identity' : ''} |\n` |
| 271 | return current |
| 272 | }, markdown) |
| 273 | } |
| 274 | |
| 275 | export const getSchemaAsMarkdown = (schema: string, tables: TableNodeData[]) => { |
| 276 | return tables.reduce((current, table) => { |
| 277 | if (table.schema === schema) { |
| 278 | current += `${getTableDefinitionAsMarkdown(table)}\n` |
| 279 | } |
| 280 | return current |
| 281 | }, '') |
| 282 | } |
| 283 | |
| 284 | const escapeForMarkdown = (str: string) => { |
| 285 | return ( |
| 286 | str |
| 287 | // Escape backslashes first so later escapes are not ambiguous |
| 288 | .replace(/\\/g, '\\\\') |
| 289 | // Escape backticks and pipes for markdown tables |
| 290 | .replace(/([|`])/g, '\\$1') |
| 291 | // Remove new lines |
| 292 | .replace(/\n/g, ' ') |
| 293 | ) |
| 294 | } |