Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/audit-log.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/features/database-access-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions docs/features/sql-editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
![Active processes](/images/features/sql-editor/sql-editor-processes.webp)
43 changes: 29 additions & 14 deletions src/components/sql-editor/QueryResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -1509,15 +1512,27 @@ export function QueryResults({
<Clipboard className="w-4 h-4" />
</Button>
{hasExport && (
<Button
variant="ghost"
size="xs"
onClick={handleExport}
disabled={!activeResult || !!activeResult.result.error || activeResult.result.rows.length === 0}
title="Export to CSV"
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Menu>
<MenuTrigger
render={
<Button
variant="ghost"
size="xs"
disabled={!activeResult || !!activeResult.result.error || activeResult.result.rows.length === 0}
title="Export results"
>
<Download className="w-4 h-4" />
</Button>
}
/>
<MenuPopup align="end" side="bottom">
{EXPORT_FORMATS.map((format) => (
<MenuItem key={format.value} onClick={() => handleExport(format.value)}>
{format.label}
</MenuItem>
))}
</MenuPopup>
</Menu>
)}
</div>
</div>
Expand Down
109 changes: 104 additions & 5 deletions src/lib/export-csv.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -9,22 +23,107 @@ 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<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[],
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<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[]
): 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, ' ')
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The regex replaces and but leaves bare unchanged, which some Markdown renderers treat as a line break inside the table cell.

Suggested change
return String(value)
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
return String(value)
.replace(/\|/g, '\\|')
.replace(/\r?\n|\r/g, ' ')

}
Comment on lines +58 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Standalone \r not sanitized in Markdown cells

formatMarkdownCell replaces \r?\n (CRLF or LF) with a space, but a bare carriage return (\r) without a following newline is left unchanged. Some Markdown renderers treat a bare \r as a line break, which can corrupt the table row. Consider broadening the replacement to /\r?\n|\r/g.

Comment on lines +58 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Markdown inline formatting characters are not escaped in cell values

formatMarkdownCell only escapes pipe characters. Cell values containing Markdown formatting tokens — *, _, `, [, ], or \ — will be interpreted as inline Markdown rather than literal text. For example, a DB value of **status** exports as bold text. If the intent is a plain-text data dump, escaping these characters would prevent unintended formatting.


function serializeMarkdown(
columns: Array<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[]
): 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<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[],
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
link.download = filename
link.click()
URL.revokeObjectURL(url)
}

export function exportRows(
columns: Array<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[],
filename: string,
format: ExportFormat
) {
const exportFormat = EXPORT_FORMATS.find((item) => item.value === format)
if (!exportFormat) return

downloadText(serializeExport(columns, rows, format), filename, exportFormat.mimeType)
}
Comment on lines +111 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 EXPORT_FORMATS.find() is called twice per export

handleExport calls EXPORT_FORMATS.find() to get the file extension, then exportRows calls it again for the MIME type. Since ExportFormat is a discriminated union and always a known value at call sites, the second lookup is redundant. Consider passing the metadata object directly into exportRows to make the contract explicit.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


export function exportToCsv(
columns: Array<Pick<ColumnMetadata, 'name'>>,
rows: Record<string, unknown>[],
filename: string
) {
exportRows(columns, rows, filename, 'csv')
}
43 changes: 43 additions & 0 deletions tests/export.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
})
Loading