Skip to content

Commit 840a0b2

Browse files
icecrasher321emir-karabeg
authored andcommitted
feat(tables): upload csvs (#3607)
* feat(tables): upload csvs * address comments * address comments * user id attribution * fix boolean coercion
1 parent ceb73c1 commit 840a0b2

File tree

4 files changed

+442
-4
lines changed

4 files changed

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

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)