Skip to content
Open
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
106 changes: 52 additions & 54 deletions src/export/csv.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { exportTableToCsvRoute } from './csv'
import { getTableData, createExportResponse } from './index'
import { getTableDataPage, tableExists } from './index'
import { createResponse } from '../utils'
import type { DataSource } from '../types'
import type { StarbaseDBConfiguration } from '../handler'

vi.mock('./index', () => ({
getTableData: vi.fn(),
createExportResponse: vi.fn(),
EXPORT_PAGE_SIZE: 500,
getTableDataPage: vi.fn(),
tableExists: vi.fn(),
}))

vi.mock('../utils', () => ({
Expand Down Expand Up @@ -43,50 +44,36 @@ beforeEach(() => {

describe('CSV Export Module', () => {
it('should return a CSV file when table data exists', async () => {
vi.mocked(getTableData).mockResolvedValue([
vi.mocked(tableExists).mockResolvedValue(true)
vi.mocked(getTableDataPage).mockResolvedValueOnce([
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 25 },
])

vi.mocked(createExportResponse).mockReturnValue(
new Response('mocked-csv-content', {
headers: { 'Content-Type': 'text/csv' },
})
)

const response = await exportTableToCsvRoute(
'users',
mockDataSource,
mockConfig
)

expect(getTableData).toHaveBeenCalledWith(
'users',
mockDataSource,
mockConfig
expect(response.headers.get('Content-Type')).toBe('text/csv')
expect(response.headers.get('Content-Disposition')).toBe(
'attachment; filename="users_export.csv"'
)
expect(createExportResponse).toHaveBeenCalledWith(
'id,name,age\n1,Alice,30\n2,Bob,25\n',
'users_export.csv',
'text/csv'
await expect(response.text()).resolves.toBe(
'id,name,age\n1,Alice,30\n2,Bob,25\n'
)
expect(response.headers.get('Content-Type')).toBe('text/csv')
})

it('should return 404 if table does not exist', async () => {
vi.mocked(getTableData).mockResolvedValue(null)
vi.mocked(tableExists).mockResolvedValue(false)

const response = await exportTableToCsvRoute(
'non_existent_table',
mockDataSource,
mockConfig
)

expect(getTableData).toHaveBeenCalledWith(
'non_existent_table',
mockDataSource,
mockConfig
)
expect(response.status).toBe(404)

const jsonResponse: { error: string } = await response.json()
Expand All @@ -96,63 +83,74 @@ describe('CSV Export Module', () => {
})

it('should handle empty table (return only headers)', async () => {
vi.mocked(getTableData).mockResolvedValue([])

vi.mocked(createExportResponse).mockReturnValue(
new Response('mocked-csv-content', {
headers: { 'Content-Type': 'text/csv' },
})
)
vi.mocked(tableExists).mockResolvedValue(true)
vi.mocked(getTableDataPage).mockResolvedValueOnce([])

const response = await exportTableToCsvRoute(
'empty_table',
mockDataSource,
mockConfig
)

expect(getTableData).toHaveBeenCalledWith(
'empty_table',
mockDataSource,
mockConfig
)
expect(createExportResponse).toHaveBeenCalledWith(
'',
'empty_table_export.csv',
'text/csv'
)
expect(response.headers.get('Content-Type')).toBe('text/csv')
await expect(response.text()).resolves.toBe('')
})

it('should escape commas and quotes in CSV values', async () => {
vi.mocked(getTableData).mockResolvedValue([
vi.mocked(tableExists).mockResolvedValue(true)
vi.mocked(getTableDataPage).mockResolvedValueOnce([
{ id: 1, name: 'Sahithi, is', bio: 'my forever "penguin"' },
])

vi.mocked(createExportResponse).mockReturnValue(
new Response('mocked-csv-content', {
headers: { 'Content-Type': 'text/csv' },
})
const response = await exportTableToCsvRoute(
'special_chars',
mockDataSource,
mockConfig
)

expect(response.headers.get('Content-Type')).toBe('text/csv')
await expect(response.text()).resolves.toBe(
'id,name,bio\n1,"Sahithi, is","my forever ""penguin"""\n'
)
})

it('should stream table data in pages instead of loading a full table at once', async () => {
const firstPage = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
}))
const secondPage = [{ id: 501 }]
vi.mocked(tableExists).mockResolvedValue(true)
vi.mocked(getTableDataPage)
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce(secondPage)

const response = await exportTableToCsvRoute(
'special_chars',
'users',
mockDataSource,
mockConfig
)

expect(createExportResponse).toHaveBeenCalledWith(
'id,name,bio\n1,"Sahithi, is","my forever ""penguin"""\n',
'special_chars_export.csv',
'text/csv'
const csv = await response.text()
expect(csv).toContain('501')
expect(getTableDataPage).toHaveBeenCalledWith(
'users',
0,
mockDataSource,
mockConfig
)
expect(getTableDataPage).toHaveBeenCalledWith(
'users',
500,
mockDataSource,
mockConfig
)
expect(response.headers.get('Content-Type')).toBe('text/csv')
})

it('should return 500 on an unexpected error', async () => {
const consoleErrorMock = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
vi.mocked(getTableData).mockRejectedValue(new Error('Database Error'))
vi.mocked(tableExists).mockRejectedValue(new Error('Database Error'))

const response = await exportTableToCsvRoute(
'users',
Expand Down
103 changes: 70 additions & 33 deletions src/export/csv.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,91 @@
import { getTableData, createExportResponse } from './index'
import { EXPORT_PAGE_SIZE, getTableDataPage, tableExists } from './index'
import { createResponse } from '../utils'
import { DataSource } from '../types'
import { StarbaseDBConfiguration } from '../handler'

function serializeCsvValue(value: unknown): string {
if (value === null || value === undefined) {
return ''
}

const stringValue = String(value)
if (
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n')
) {
return `"${stringValue.replace(/"/g, '""')}"`
}

return stringValue
}

export async function exportTableToCsvRoute(
tableName: string,
dataSource: DataSource,
config: StarbaseDBConfiguration
): Promise<Response> {
try {
const data = await getTableData(tableName, dataSource, config)

if (data === null) {
if (!(await tableExists(tableName, dataSource, config))) {
return createResponse(
undefined,
`Table '${tableName}' does not exist.`,
404
)
}

// Convert the result to CSV
let csvContent = ''
if (data.length > 0) {
// Add headers
csvContent += Object.keys(data[0]).join(',') + '\n'

// Add data rows
data.forEach((row: any) => {
csvContent +=
Object.values(row)
.map((value) => {
if (
typeof value === 'string' &&
(value.includes(',') ||
value.includes('"') ||
value.includes('\n'))
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
})
.join(',') + '\n'
})
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let offset = 0
let wroteHeader = false
let hasMoreRows = true

while (hasMoreRows) {
const dataResult = await getTableDataPage(
tableName,
offset,
dataSource,
config
)

if (!wroteHeader && dataResult.length > 0) {
controller.enqueue(
encoder.encode(
Object.keys(dataResult[0]).join(',') + '\n'
)
)
wroteHeader = true
}

for (const row of dataResult) {
controller.enqueue(
encoder.encode(
Object.values(row)
.map(serializeCsvValue)
.join(',') + '\n'
)
)
}

hasMoreRows = dataResult.length === EXPORT_PAGE_SIZE
offset += EXPORT_PAGE_SIZE

if (hasMoreRows) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}

controller.close()
},
})

return createExportResponse(
csvContent,
`${tableName}_export.csv`,
'text/csv'
)
return new Response(stream, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="${tableName}_export.csv"`,
},
})
} catch (error: any) {
console.error('CSV Export Error:', error)
return createResponse(undefined, 'Failed to export table to CSV', 500)
Expand Down
Loading