Schemas.utils.ts 8.0 KB

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