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
+
+
+
+ }
+ >
+ Export options
+
+
+
+ {
+ 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()