diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index e6e0e7cda73..a2fa462056e 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -12,7 +12,7 @@ import { useReactFlow, } from '@xyflow/react' import { toPng, toSvg } from 'html-to-image' -import { Check, Copy, Download, Loader2, Plus } from 'lucide-react' +import { Check, ChevronDown, Copy, Download, Loader2, Plus } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useEffect, useMemo, useRef, useState } from 'react' @@ -45,7 +45,11 @@ import { DefaultEdge } from './DefaultEdge' import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext' import { SchemaGraphLegend } from './SchemaGraphLegend' import { EdgeData, TableNodeData } from './Schemas.constants' -import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' +import { + getGraphDataFromTables, + getLayoutedElementsViaDagre, + getSchemaAsMarkdown, +} from './Schemas.utils' import { TableNode } from './SchemaTableNode' import { useExportSchemaToImage } from './useExportSchemaToImage' import AlertError from '@/components/ui/AlertError' @@ -269,52 +273,85 @@ export const SchemaGraph = () => { /> {!hasNoTables && (
- : } - onClick={() => { - if (tables) { - copyToClipboard(tablesToSQL(tables)) - setCopied(true) - } - }} - tooltip={{ - content: { - side: 'bottom', - text: ( -
-

Note

-

- This schema is for context or debugging only. Table order and - constraints may be invalid. Not meant to be run as-is. -

-
- ), - }, - }} - > - Copy as SQL -
- - - } - tooltip={{ content: { side: 'bottom', text: 'Download current view' } }} - /> - - - downloadImage('png')}> - Download as PNG - - downloadImage('svg')}> - Download as SVG - - - +
+ : } + onClick={() => { + if (tables) { + copyToClipboard(tablesToSQL(tables)) + setCopied(true) + } + }} + tooltip={{ + content: { + side: 'bottom', + text: ( +
+

Note

+

+ This schema is for context or debugging only. Table order and + constraints may be invalid. Not meant to be run as-is. +

+
+ ), + }, + }} + > + Copy as SQL +
+ + + + + + { + e.stopPropagation() + const tables = reactFlowInstance + .getNodes() + .filter((node) => node.type === 'table') + .map((node) => node.data as TableNodeData) + + copyToClipboard(getSchemaAsMarkdown(selectedSchema, tables)) + setCopied(true) + }} + > + + Copy as Markdown + + { + e.stopPropagation() + downloadImage('png') + }} + > + + Download as PNG + + { + e.stopPropagation() + downloadImage('svg') + }} + > + + Download as SVG + + + +

Edit table

- { - e.stopPropagation() - copyToClipboard(data.name) - }} - > - - Copy name - @@ -151,6 +146,73 @@ export const TableNode = ({

View in Table Editor

+ + { + e.stopPropagation() + copyToClipboard(data.name) + }} + > + + Copy name + + { + e.stopPropagation() + const toastId = toast.loading('Getting table schema...') + + const formattedSchema = getTableDefinition({ + id: data.id, + projectRef: project?.ref, + connectionString: project?.connectionString, + }).then((tableDefinition) => { + if (!tableDefinition) { + throw new Error('Failed to get table schema') + } + return formatSql(tableDefinition) + }) + + try { + await copyToClipboard(formattedSchema, () => { + toast.success('Table schema copied to clipboard', { id: toastId }) + }) + } catch (err) { + toast.error( + 'Failed to copy schema: ' + ((err as Error).message || err), + { + id: toastId, + } + ) + } + }} + > + + Copy as SQL + + { + e.stopPropagation() + const markdown = getTableDefinitionAsMarkdown(data) + + try { + await copyToClipboard(markdown, () => { + toast.success('Table schema copied to clipboard') + }) + } catch (err) { + toast.error( + 'Failed to copy schema: ' + ((err as Error).message || err) + ) + } + }} + > + + Copy as Markdown + )} diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.constants.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.constants.ts index 3b1602e3f5f..303bb8785aa 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.constants.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.constants.ts @@ -13,6 +13,7 @@ export type TableNodeData = { isIdentity: boolean name: string format: string + description: string }[] } diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.test.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.test.ts new file mode 100644 index 00000000000..1a6146fdeb9 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'vitest' + +import { getSchemaAsMarkdown, getTableDefinitionAsMarkdown } from './Schemas.utils' + +describe('Schemas.utils', () => { + test('getSchemaAsMarkdown returns properly formatted markdown', () => { + const schema = 'public' + const tables = [ + { + ref: 'default', + id: 20999, + name: 'test', + description: 'An excellent description', + schema: 'public', + isForeign: false, + columns: [ + { + id: '20999.1', + isPrimary: true, + name: 'id', + format: 'int8', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: true, + description: '', + }, + { + id: '20999.2', + isPrimary: false, + name: 'created_at', + format: 'timestamptz', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: false, + description: '', + }, + { + id: '20999.3', + isPrimary: false, + name: 'user_id', + format: 'uuid', + isNullable: true, + isUnique: false, + isUpdateable: true, + isIdentity: false, + description: '', + }, + ], + }, + { + ref: 'default', + id: 21049, + name: 'test2', + description: '', + schema: 'public', + isForeign: false, + columns: [ + { + id: '21049.1', + isPrimary: true, + name: 'id', + format: 'int8', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: true, + description: '', + }, + { + id: '21049.2', + isPrimary: false, + name: 'created_at', + format: 'timestamptz', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: false, + description: '', + }, + ], + }, + { + id: 21009, + ref: 'default', + schema: 'auth', + name: 'auth.users.id', + description: '', + isForeign: true, + columns: [], + }, + ] + const result = getSchemaAsMarkdown(schema, tables) + expect(result).toBe(`## Table \`test\` + +An excellent description + +### Columns + +| Name | Type | Constraints | +|------|------|-------------| +| \`id\` | \`int8\` | Primary Identity | +| \`created_at\` | \`timestamptz\` | | +| \`user_id\` | \`uuid\` | Nullable | + +## Table \`test2\` + +### Columns + +| Name | Type | Constraints | +|------|------|-------------| +| \`id\` | \`int8\` | Primary Identity | +| \`created_at\` | \`timestamptz\` | | + +`) + }) + test('getTableDefinitionAsMarkdown returns properly formatted markdown', () => { + const table = { + ref: 'default', + id: 20999, + name: 'test', + description: 'An excellent description', + schema: 'public', + isForeign: false, + columns: [ + { + id: '20999.1', + isPrimary: true, + name: 'id', + format: 'int8', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: true, + description: '', + }, + { + id: '20999.2', + isPrimary: false, + name: 'created_at', + format: 'timestamptz', + isNullable: false, + isUnique: false, + isUpdateable: true, + isIdentity: false, + description: '', + }, + { + id: '20999.3', + isPrimary: false, + name: 'user_id', + format: 'uuid', + isNullable: true, + isUnique: false, + isUpdateable: true, + isIdentity: false, + description: '', + }, + ], + } + const result = getTableDefinitionAsMarkdown(table) + expect(result).toBe(`## Table \`test\` + +An excellent description + +### Columns + +| Name | Type | Constraints | +|------|------|-------------| +| \`id\` | \`int8\` | Primary Identity | +| \`created_at\` | \`timestamptz\` | | +| \`user_id\` | \`uuid\` | Nullable | +`) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index 315df763ebc..50eea3ca24e 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -37,6 +37,7 @@ export async function getGraphDataFromTables( isUnique: column.is_unique, isUpdateable: column.is_updatable, isIdentity: column.is_identity, + description: column.comment ?? '', } }) @@ -255,3 +256,39 @@ const getLayoutedElementsViaLocalStorage = ( }) 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, ' ') + ) +} diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index 50ec3f2e8ca..7cd1d8a1f54 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -52,7 +52,7 @@ test.describe('Database', () => { // downloads schema diagram when export is triggered const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Download Schema' }).click() + await page.getByRole('button', { name: 'Export options' }).click() await page.getByRole('menuitem', { name: 'Download as PNG' }).click() const download = await downloadPromise expect(download.suggestedFilename()).toContain('.png') @@ -100,6 +100,8 @@ test.describe('Database', () => { const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByText('timestamptz')).toBeVisible() + // FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled + await page.waitForTimeout(500) await page.getByLabel('Description').fill('Bazinga') await page.getByRole('button', { name: 'Save' }).click() await expect(page.getByText(`Successfully updated ${databaseTableName}!`)).toBeVisible() @@ -111,8 +113,6 @@ test.describe('Database', () => { await editTableMenuItem.press('Enter') await expect(editTableMenuItem).not.toBeVisible() await expect(page.getByRole('dialog')).toBeVisible() - // FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled - await page.waitForTimeout(500) await expect(page.getByLabel('Description')).toHaveValue('Bazinga') await page.getByRole('button', { name: 'Cancel' }).click() await expect(page.getByRole('dialog')).not.toBeVisible()