From f5178813f3c8d6b74d72326b45961557e61b4923 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 26 Jun 2026 03:22:04 -0700 Subject: [PATCH] feat: add result export formats --- docs/features/audit-log.mdx | 2 +- docs/features/database-access-control.mdx | 2 +- docs/features/sql-editor.mdx | 4 +- src/components/sql-editor/QueryResults.tsx | 43 +++++--- src/lib/export-csv.ts | 109 ++++++++++++++++++++- tests/export.test.ts | 43 ++++++++ 6 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 tests/export.test.ts diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index f7e2e88..dbc4f39 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -145,7 +145,7 @@ All audit events are JSON objects with a common structure: | `database` | Database name | | `sql` | SQL query that produced the exported data | | `row_count` | Number of rows exported | -| `format` | Export format (`csv`) | +| `format` | Export format (`csv`, `tsv`, `json`, or `markdown`) | | `source` | Export origin: currently `"web"` | ## Capturing Logs diff --git a/docs/features/database-access-control.mdx b/docs/features/database-access-control.mdx index 4cbd677..1b9ec84 100644 --- a/docs/features/database-access-control.mdx +++ b/docs/features/database-access-control.mdx @@ -45,7 +45,7 @@ Independent permissions control what users can do: | `admin` | Role/database management, `pg_terminate_backend` | Terminate sessions, cancel other users' queries | | `explain` | `EXPLAIN` queries | Explain button in editor toolbar and context menu | | `execute` | `CALL` stored procedures | Procedure execution | -| `export` | Export from results | Export button | +| `export` | Export from results | Export menu | A single SQL statement can require multiple permissions. For example, `SELECT pg_terminate_backend(123)` requires both `read` (for the SELECT) and `admin` (for the function call). diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index bad2a3b..7aac3e7 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -70,7 +70,7 @@ When changes are pending, a floating bar lets you **Preview** the generated SQL ### Data Export -- **Export to CSV** — downloads visible rows as a CSV file (requires `export` permission) +- **Export results** — downloads visible rows as CSV, TSV, JSON, or Markdown (requires `export` permission) - **Copy as Markdown** — copies results as a markdown table to the clipboard ## Schema Inspection @@ -83,4 +83,4 @@ Schema tabs provide detailed inspection of database objects. The Processes button in the toolbar opens a modal showing active database connections. The list auto-refreshes periodically. With `admin` permission, a **Terminate** button appears on each row with a confirmation countdown. -![Active processes](/images/features/sql-editor/sql-editor-processes.webp) \ No newline at end of file +![Active processes](/images/features/sql-editor/sql-editor-processes.webp) diff --git a/src/components/sql-editor/QueryResults.tsx b/src/components/sql-editor/QueryResults.tsx index 19bc586..5925cbb 100644 --- a/src/components/sql-editor/QueryResults.tsx +++ b/src/components/sql-editor/QueryResults.tsx @@ -7,7 +7,8 @@ import { SearchInput } from '../ui/search-input' import { Button } from '../ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip' import type { QueryResult, ResultTab } from './hooks/useEditorTabs' -import { exportToCsv } from '@/lib/export-csv' +import { EXPORT_FORMATS, exportRows, type ExportFormat } from '@/lib/export-csv' +import { Menu, MenuTrigger, MenuPopup, MenuItem } from '../ui/menu' import { queryClient } from '@/lib/connect-client' import { RowDetailPanel } from './RowDetailPanel' import { JsonExpandModal } from './JsonExpandModal' @@ -1385,21 +1386,23 @@ export function QueryResults({ await navigator.clipboard.writeText(lines.join('\n')) }, [activeResult, displayRows]) - const handleExport = useCallback(() => { + const handleExport = useCallback((format: ExportFormat) => { if (!activeResult) return const now = new Date() const timestamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, (m) => (m === 'T' ? '-' : '')) - const filename = `export-${timestamp}.csv` + const exportFormat = EXPORT_FORMATS.find((item) => item.value === format) + if (!exportFormat) return + const filename = `export-${timestamp}.${exportFormat.extension}` const rows = displayRows.filter(r => r.status === 'normal').map(r => r.data) - exportToCsv(activeResult.result.columns, rows, filename) + exportRows(activeResult.result.columns, rows, filename, format) queryClient.auditExport({ connectionId, sql: activeResult.sql || '', rowCount: rows.length, - format: 'csv', + format, }).catch(() => {}) }, [activeResult, displayRows, connectionId]) @@ -1509,15 +1512,27 @@ export function QueryResults({ {hasExport && ( - + + + + + } + /> + + {EXPORT_FORMATS.map((format) => ( + handleExport(format.value)}> + {format.label} + + ))} + + )} diff --git a/src/lib/export-csv.ts b/src/lib/export-csv.ts index 2d02070..b9963c5 100644 --- a/src/lib/export-csv.ts +++ b/src/lib/export-csv.ts @@ -1,6 +1,20 @@ import Papa from 'papaparse' import type { ColumnMetadata } from '@/components/sql-editor/hooks/useEditorTabs' +export type ExportFormat = 'csv' | 'tsv' | 'json' | 'markdown' + +export const EXPORT_FORMATS: Array<{ + value: ExportFormat + label: string + extension: string + mimeType: string +}> = [ + { value: 'csv', label: 'CSV', extension: 'csv', mimeType: 'text/csv;charset=utf-8;' }, + { value: 'tsv', label: 'TSV', extension: 'tsv', mimeType: 'text/tab-separated-values;charset=utf-8;' }, + { value: 'json', label: 'JSON', extension: 'json', mimeType: 'application/json;charset=utf-8;' }, + { value: 'markdown', label: 'Markdown', extension: 'md', mimeType: 'text/markdown;charset=utf-8;' }, +] + function sanitizeCsvString(value: string): string { return /^[\s]*[=+\-@\t\r]/.test(value) ? `'${value}` : value } @@ -9,18 +23,83 @@ function sanitizeCsvCell(value: unknown): unknown { return typeof value === 'string' ? sanitizeCsvString(value) : value } -export function exportToCsv( +function normalizeJsonValue(value: unknown): unknown { + if (value === undefined) return null + if (typeof value === 'bigint') return value.toString() + if (value instanceof Date) return value.toISOString() + return value +} + +function serializeDelimited( columns: Array>, rows: Record[], - filename: string -) { + delimiter: ',' | '\t' +): string { const columnNames = columns.map((col) => col.name) - const csv = Papa.unparse({ + return Papa.unparse({ fields: columnNames.map((name) => sanitizeCsvString(name)), data: rows.map((row) => columnNames.map((name) => sanitizeCsvCell(row[name]))), + }, { + delimiter, }) +} + +function serializeJson( + columns: Array>, + rows: Record[] +): string { + const columnNames = columns.map((col) => col.name) + const projectedRows = rows.map((row) => Object.fromEntries( + columnNames.map((name) => [name, normalizeJsonValue(row[name])]) + )) + return `${JSON.stringify(projectedRows, null, 2)}\n` +} + +function formatMarkdownCell(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return '' + return String(value) + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' ') +} + +function serializeMarkdown( + columns: Array>, + rows: Record[] +): string { + const columnNames = columns.map((col) => col.name) + const lines = [ + `| ${columnNames.map(formatMarkdownCell).join(' | ')} |`, + `| ${columnNames.map(() => '---').join(' | ')} |`, + ] + + for (const row of rows) { + lines.push(`| ${columnNames.map((name) => formatMarkdownCell(row[name])).join(' | ')} |`) + } + + return `${lines.join('\n')}\n` +} + +export function serializeExport( + columns: Array>, + rows: Record[], + format: ExportFormat +): string { + switch (format) { + case 'csv': + return serializeDelimited(columns, rows, ',') + case 'tsv': + return serializeDelimited(columns, rows, '\t') + case 'json': + return serializeJson(columns, rows) + case 'markdown': + return serializeMarkdown(columns, rows) + } +} + +function downloadText(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }) - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url @@ -28,3 +107,23 @@ export function exportToCsv( link.click() URL.revokeObjectURL(url) } + +export function exportRows( + columns: Array>, + rows: Record[], + filename: string, + format: ExportFormat +) { + const exportFormat = EXPORT_FORMATS.find((item) => item.value === format) + if (!exportFormat) return + + downloadText(serializeExport(columns, rows, format), filename, exportFormat.mimeType) +} + +export function exportToCsv( + columns: Array>, + rows: Record[], + filename: string +) { + exportRows(columns, rows, filename, 'csv') +} diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..9d4c9ef --- /dev/null +++ b/tests/export.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { serializeExport } from '../src/lib/export-csv' + +const columns = [ + { name: 'id' }, + { name: 'name' }, + { name: 'note' }, +] + +const rows = [ + { id: 1, name: 'alpha', note: '=cmd' }, + { id: 2, name: 'pipe|name', note: 'line\nbreak' }, +] + +describe('serializeExport', () => { + it('serializes CSV and sanitizes formula-like cells', () => { + expect(serializeExport(columns, rows, 'csv')).toBe('id,name,note\r\n1,alpha,\'=cmd\r\n2,pipe|name,"line\nbreak"') + }) + + it('serializes TSV', () => { + expect(serializeExport(columns, rows, 'tsv')).toBe('id\tname\tnote\r\n1\talpha\t\'=cmd\r\n2\tpipe|name\t"line\nbreak"') + }) + + it('serializes JSON with the selected column order', () => { + expect(serializeExport(columns, [{ id: 1, name: undefined, note: 3n }], 'json')).toBe(`[ + { + "id": 1, + "name": null, + "note": "3" + } +]\n`) + }) + + it('serializes Markdown and escapes table pipes', () => { + expect(serializeExport(columns, rows, 'markdown')).toBe([ + '| id | name | note |', + '| --- | --- | --- |', + '| 1 | alpha | =cmd |', + '| 2 | pipe\\|name | line break |', + '', + ].join('\n')) + }) +})