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
150 changes: 88 additions & 62 deletions src/export/csv.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { exportTableToCsvRoute } from './csv'
import { getTableData, createExportResponse } from './index'
import { executeOperation } from '.'
import { createResponse } from '../utils'
import type { DataSource } from '../types'
import type { StarbaseDBConfiguration } from '../handler'

vi.mock('./index', () => ({
getTableData: vi.fn(),
createExportResponse: vi.fn(),
vi.mock('.', () => ({
executeOperation: vi.fn(),
}))

vi.mock('../utils', () => ({
Expand Down Expand Up @@ -42,51 +41,43 @@ beforeEach(() => {
})

describe('CSV Export Module', () => {
it('should return a CSV file when table data exists', async () => {
vi.mocked(getTableData).mockResolvedValue([
{ 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' },
})
)
it('should stream a CSV file when table data exists', async () => {
vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'users' }])
.mockResolvedValueOnce([
{ name: 'id' },
{ name: 'name' },
{ name: 'age' },
])
.mockResolvedValueOnce([
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 25 },
])

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(executeOperation).mockResolvedValueOnce([])

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 @@ -95,64 +86,98 @@ 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' },
})
)
it('should include headers for empty tables', async () => {
vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'empty_table' }])
.mockResolvedValueOnce([{ name: 'id' }, { name: 'name' }])
.mockResolvedValueOnce([])

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

expect(getTableData).toHaveBeenCalledWith(
'empty_table',
await expect(response.text()).resolves.toBe('id,name\n')
})

it('should escape commas, quotes, and newlines in CSV values', async () => {
vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'special_chars' }])
.mockResolvedValueOnce([
{ name: 'id' },
{ name: 'name' },
{ name: 'bio' },
])
.mockResolvedValueOnce([
{
id: 1,
name: 'Sahithi, is',
bio: 'my forever "penguin"\nline',
},
])

const response = await exportTableToCsvRoute(
'special_chars',
mockDataSource,
mockConfig
)
expect(createExportResponse).toHaveBeenCalledWith(
'',
'empty_table_export.csv',
'text/csv'

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

it('should escape commas and quotes in CSV values', async () => {
vi.mocked(getTableData).mockResolvedValue([
{ id: 1, name: 'Sahithi, is', bio: 'my forever "penguin"' },
])
it('should page table data instead of loading the full table', async () => {
const firstPage = Array.from({ length: 1000 }, (_, index) => ({
id: index + 1,
name: `User ${index + 1}`,
}))

vi.mocked(createExportResponse).mockReturnValue(
new Response('mocked-csv-content', {
headers: { 'Content-Type': 'text/csv' },
})
)
vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'users' }])
.mockResolvedValueOnce([{ name: 'id' }, { name: 'name' }])
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([{ id: 1001, name: 'Last User' }])

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('1001,Last User\n')
expect(executeOperation).toHaveBeenNthCalledWith(
3,
[
{
sql: 'SELECT * FROM "users" LIMIT ? OFFSET ?;',
params: [1000, 0],
},
],
mockDataSource,
mockConfig
)
expect(executeOperation).toHaveBeenNthCalledWith(
4,
[
{
sql: 'SELECT * FROM "users" LIMIT ? OFFSET ?;',
params: [1000, 1000],
},
],
mockDataSource,
mockConfig
)
expect(response.headers.get('Content-Type')).toBe('text/csv')
})

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

const response = await exportTableToCsvRoute(
'users',
Expand All @@ -163,5 +188,6 @@ describe('CSV Export Module', () => {
expect(response.status).toBe(500)
const jsonResponse: { error: string } = await response.json()
expect(jsonResponse.error).toBe('Failed to export table to CSV')
consoleErrorMock.mockRestore()
})
})
56 changes: 26 additions & 30 deletions src/export/csv.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,47 @@
import { getTableData, createExportResponse } from './index'
import { createResponse } from '../utils'
import { DataSource } from '../types'
import { StarbaseDBConfiguration } from '../handler'
import {
createStreamingExportResponse,
formatCsvValue,
getTableColumns,
iterateTableRows,
tableExists,
} from './streaming'

async function* csvTableChunks(
tableName: string,
columns: string[],
dataSource: DataSource,
config: StarbaseDBConfiguration
): AsyncGenerator<string> {
if (columns.length) {
yield `${columns.map(formatCsvValue).join(',')}\n`
}

for await (const row of iterateTableRows(tableName, dataSource, config)) {
yield `${columns.map((column) => formatCsvValue(row[column])).join(',')}\n`
}
}

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 columns = await getTableColumns(tableName, dataSource, config)

return createExportResponse(
csvContent,
return createStreamingExportResponse(
csvTableChunks(tableName, columns, dataSource, config),
`${tableName}_export.csv`,
'text/csv'
)
Expand Down
68 changes: 63 additions & 5 deletions src/export/dump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ describe('Database Dump Module', () => {
expect(dumpText).toContain(
'CREATE TABLE users (id INTEGER, name TEXT);'
)
expect(dumpText).toContain("INSERT INTO users VALUES (1, 'Alice');")
expect(dumpText).toContain("INSERT INTO users VALUES (2, 'Bob');")
expect(dumpText).toContain('INSERT INTO "users" VALUES (1, \'Alice\');')
expect(dumpText).toContain('INSERT INTO "users" VALUES (2, \'Bob\');')
expect(dumpText).toContain(
'CREATE TABLE orders (id INTEGER, total REAL);'
)
expect(dumpText).toContain('INSERT INTO orders VALUES (1, 99.99);')
expect(dumpText).toContain('INSERT INTO orders VALUES (2, 49.5);')
expect(dumpText).toContain('INSERT INTO "orders" VALUES (1, 99.99);')
expect(dumpText).toContain('INSERT INTO "orders" VALUES (2, 49.5);')
})

it('should handle empty databases (no tables)', async () => {
Expand Down Expand Up @@ -124,10 +124,68 @@ describe('Database Dump Module', () => {
expect(response).toBeInstanceOf(Response)
const dumpText = await response.text()
expect(dumpText).toContain(
"INSERT INTO users VALUES (1, 'Alice''s adventure');"
'INSERT INTO "users" VALUES (1, \'Alice\'\'s adventure\');'
)
})

it('should page table data instead of loading a full table at once', async () => {
const firstPage = Array.from({ length: 1000 }, (_, index) => ({
id: index + 1,
name: `User ${index + 1}`,
}))

vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'users' }])
.mockResolvedValueOnce([
{ sql: 'CREATE TABLE users (id INTEGER, name TEXT);' },
])
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([{ id: 1001, name: 'Last User' }])

const response = await dumpDatabaseRoute(mockDataSource, mockConfig)
const dumpText = await response.text()

expect(dumpText).toContain(
'INSERT INTO "users" VALUES (1001, \'Last User\');'
)
expect(executeOperation).toHaveBeenNthCalledWith(
3,
[
{
sql: 'SELECT * FROM "users" LIMIT ? OFFSET ?;',
params: [1000, 0],
},
],
mockDataSource,
mockConfig
)
expect(executeOperation).toHaveBeenNthCalledWith(
4,
[
{
sql: 'SELECT * FROM "users" LIMIT ? OFFSET ?;',
params: [1000, 1000],
},
],
mockDataSource,
mockConfig
)
})

it('should emit SQL NULL for nullish values', async () => {
vi.mocked(executeOperation)
.mockResolvedValueOnce([{ name: 'users' }])
.mockResolvedValueOnce([
{ sql: 'CREATE TABLE users (id INTEGER, bio TEXT);' },
])
.mockResolvedValueOnce([{ id: 1, bio: null }])

const response = await dumpDatabaseRoute(mockDataSource, mockConfig)
const dumpText = await response.text()

expect(dumpText).toContain('INSERT INTO "users" VALUES (1, NULL);')
})

it('should return a 500 response when an error occurs', async () => {
const consoleErrorMock = vi
.spyOn(console, 'error')
Expand Down
Loading