Skip to content

Commit de371f8

Browse files
committed
feat(tables): upload csvs
1 parent 6818c51 commit de371f8

File tree

4 files changed

+409
-4
lines changed

4 files changed

+409
-4
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
4+
import { generateRequestId } from '@/lib/core/utils/request'
5+
import {
6+
batchInsertRows,
7+
createTable,
8+
getWorkspaceTableLimits,
9+
type TableSchema,
10+
} from '@/lib/table'
11+
import type { ColumnDefinition, RowData } from '@/lib/table/types'
12+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
13+
import { normalizeColumn } from '@/app/api/table/utils'
14+
15+
const logger = createLogger('TableImportCSV')
16+
17+
const MAX_BATCH_SIZE = 1000
18+
const SCHEMA_SAMPLE_SIZE = 100
19+
20+
type ColumnType = 'string' | 'number' | 'boolean' | 'date'
21+
22+
async function parseCsvBuffer(
23+
buffer: Buffer
24+
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
25+
const { parse } = await import('csv-parse/sync')
26+
const parsed = parse(buffer.toString('utf-8'), {
27+
columns: true,
28+
skip_empty_lines: true,
29+
trim: true,
30+
relax_column_count: true,
31+
relax_quotes: true,
32+
skip_records_with_error: true,
33+
cast: false,
34+
}) as Record<string, unknown>[]
35+
36+
if (parsed.length === 0) {
37+
throw new Error('CSV file has no data rows')
38+
}
39+
40+
const headers = Object.keys(parsed[0])
41+
if (headers.length === 0) {
42+
throw new Error('CSV file has no headers')
43+
}
44+
45+
return { headers, rows: parsed }
46+
}
47+
48+
function inferColumnType(values: unknown[]): ColumnType {
49+
const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '')
50+
if (nonEmpty.length === 0) return 'string'
51+
52+
const allNumber = nonEmpty.every((v) => {
53+
const n = Number(v)
54+
return !Number.isNaN(n) && String(v).trim() !== ''
55+
})
56+
if (allNumber) return 'number'
57+
58+
const allBoolean = nonEmpty.every((v) => {
59+
const s = String(v).toLowerCase()
60+
return s === 'true' || s === 'false'
61+
})
62+
if (allBoolean) return 'boolean'
63+
64+
const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
65+
const allDate = nonEmpty.every((v) => {
66+
const s = String(v)
67+
return isoDatePattern.test(s) && !Number.isNaN(Date.parse(s))
68+
})
69+
if (allDate) return 'date'
70+
71+
return 'string'
72+
}
73+
74+
function inferSchema(headers: string[], rows: Record<string, unknown>[]): ColumnDefinition[] {
75+
const sample = rows.slice(0, SCHEMA_SAMPLE_SIZE)
76+
const seen = new Set<string>()
77+
78+
return headers.map((name) => {
79+
let colName = sanitizeName(name)
80+
let suffix = 2
81+
while (seen.has(colName)) {
82+
colName = `${sanitizeName(name)}_${suffix}`
83+
suffix++
84+
}
85+
seen.add(colName)
86+
87+
return {
88+
name: colName,
89+
type: inferColumnType(sample.map((r) => r[name])),
90+
}
91+
})
92+
}
93+
94+
/**
95+
* Strips non-alphanumeric characters (except underscore), collapses runs of
96+
* underscores, and ensures the name starts with a letter or underscore.
97+
* Used for both table names and column names to satisfy NAME_PATTERN.
98+
*/
99+
function sanitizeName(raw: string, fallbackPrefix = 'col'): string {
100+
let name = raw
101+
.trim()
102+
.replace(/[^a-zA-Z0-9_]/g, '_')
103+
.replace(/_+/g, '_')
104+
.replace(/^_+|_+$/g, '')
105+
106+
if (!name || /^\d/.test(name)) {
107+
name = `${fallbackPrefix}_${name}`
108+
}
109+
110+
return name
111+
}
112+
113+
function coerceValue(value: unknown, colType: ColumnType): string | number | boolean | null {
114+
if (value === null || value === undefined || value === '') return null
115+
switch (colType) {
116+
case 'number': {
117+
const n = Number(value)
118+
return Number.isNaN(n) ? null : n
119+
}
120+
case 'boolean': {
121+
const s = String(value).toLowerCase()
122+
return s === 'true'
123+
}
124+
case 'date':
125+
return new Date(String(value)).toISOString()
126+
default:
127+
return String(value)
128+
}
129+
}
130+
131+
function coerceRows(
132+
rows: Record<string, unknown>[],
133+
columns: ColumnDefinition[],
134+
headerToColumn: Map<string, string>
135+
): RowData[] {
136+
const colTypeMap = new Map(columns.map((c) => [c.name, c.type as ColumnType]))
137+
138+
return rows.map((row) => {
139+
const coerced: RowData = {}
140+
for (const [header, value] of Object.entries(row)) {
141+
const colName = headerToColumn.get(header)
142+
if (colName) {
143+
coerced[colName] = coerceValue(value, colTypeMap.get(colName) ?? 'string')
144+
}
145+
}
146+
return coerced
147+
})
148+
}
149+
150+
export async function POST(request: NextRequest) {
151+
const requestId = generateRequestId()
152+
153+
try {
154+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
155+
if (!authResult.success || !authResult.userId) {
156+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
157+
}
158+
159+
const formData = await request.formData()
160+
const file = formData.get('file')
161+
const workspaceId = formData.get('workspaceId') as string | null
162+
163+
if (!file || !(file instanceof File)) {
164+
return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
165+
}
166+
167+
if (!workspaceId) {
168+
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
169+
}
170+
171+
const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId)
172+
if (permission !== 'write' && permission !== 'admin') {
173+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
174+
}
175+
176+
const ext = file.name.split('.').pop()?.toLowerCase()
177+
if (ext !== 'csv' && ext !== 'tsv') {
178+
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
179+
}
180+
181+
const buffer = Buffer.from(await file.arrayBuffer())
182+
const { headers, rows } = await parseCsvBuffer(buffer)
183+
184+
const columns = inferSchema(headers, rows)
185+
const headerToColumn = new Map(headers.map((h, i) => [h, columns[i].name]))
186+
187+
const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table')
188+
const planLimits = await getWorkspaceTableLimits(workspaceId)
189+
190+
const normalizedSchema: TableSchema = {
191+
columns: columns.map(normalizeColumn),
192+
}
193+
194+
const table = await createTable(
195+
{
196+
name: tableName,
197+
description: `Imported from ${file.name}`,
198+
schema: normalizedSchema,
199+
workspaceId,
200+
userId: authResult.userId,
201+
maxRows: planLimits.maxRowsPerTable,
202+
maxTables: planLimits.maxTables,
203+
},
204+
requestId
205+
)
206+
207+
const coerced = coerceRows(rows, columns, headerToColumn)
208+
let inserted = 0
209+
for (let i = 0; i < coerced.length; i += MAX_BATCH_SIZE) {
210+
const batch = coerced.slice(i, i + MAX_BATCH_SIZE)
211+
const batchRequestId = crypto.randomUUID().slice(0, 8)
212+
const result = await batchInsertRows(
213+
{ tableId: table.id, rows: batch, workspaceId },
214+
table,
215+
batchRequestId
216+
)
217+
inserted += result.length
218+
}
219+
220+
logger.info(`[${requestId}] CSV imported`, {
221+
tableId: table.id,
222+
fileName: file.name,
223+
columns: columns.length,
224+
rows: inserted,
225+
})
226+
227+
return NextResponse.json({
228+
success: true,
229+
data: {
230+
table: {
231+
id: table.id,
232+
name: table.name,
233+
description: table.description,
234+
schema: normalizedSchema,
235+
rowCount: inserted,
236+
},
237+
},
238+
})
239+
} catch (error) {
240+
const message = error instanceof Error ? error.message : String(error)
241+
logger.error(`[${requestId}] CSV import failed:`, error)
242+
243+
const isClientError =
244+
message.includes('maximum table limit') ||
245+
message.includes('CSV file has no') ||
246+
message.includes('Invalid table name') ||
247+
message.includes('Invalid schema') ||
248+
message.includes('already exists')
249+
250+
return NextResponse.json(
251+
{ error: isClientError ? message : 'Failed to import CSV' },
252+
{ status: isClientError ? 400 : 500 }
253+
)
254+
}
255+
}

apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DropdownMenuContent,
66
DropdownMenuItem,
77
DropdownMenuTrigger,
8+
Upload,
89
} from '@/components/emcn'
910
import { Plus } from '@/components/emcn/icons'
1011

@@ -13,15 +14,19 @@ interface TablesListContextMenuProps {
1314
position: { x: number; y: number }
1415
onClose: () => void
1516
onCreateTable?: () => void
17+
onUploadCsv?: () => void
1618
disableCreate?: boolean
19+
disableUpload?: boolean
1720
}
1821

1922
export function TablesListContextMenu({
2023
isOpen,
2124
position,
2225
onClose,
2326
onCreateTable,
27+
onUploadCsv,
2428
disableCreate = false,
29+
disableUpload = false,
2530
}: TablesListContextMenuProps) {
2631
return (
2732
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -51,6 +56,12 @@ export function TablesListContextMenu({
5156
Create table
5257
</DropdownMenuItem>
5358
)}
59+
{onUploadCsv && (
60+
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadCsv}>
61+
<Upload />
62+
Upload CSV
63+
</DropdownMenuItem>
64+
)}
5465
</DropdownMenuContent>
5566
</DropdownMenu>
5667
)

0 commit comments

Comments
 (0)