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
95 changes: 95 additions & 0 deletions apps/sim/app/api/table/[tableId]/export/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'

const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockQueryRows: vi.fn(),
}))

vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'Access denied' }, { status: result.status }),
}
})

vi.mock('@/lib/table/service', () => ({
queryRows: mockQueryRows,
}))

import { GET } from '@/app/api/table/[tableId]/export/route'

/** Table with an id-native column whose stable id (`col_email`) differs from its display name. */
function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_email', name: 'email', type: 'string' },
{ name: 'legacy', type: 'string' }, // legacy: id == name
],
},
metadata: null,
rowCount: 1,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
}

function callGet(format: string) {
const req = new NextRequest(`http://localhost:3000/api/table/tbl_1/export?format=${format}`, {
method: 'GET',
})
return GET(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

describe('table export route — id→name translation', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
// Row data is keyed by stable column id (`col_email`), not the display name.
mockQueryRows.mockResolvedValue({
rows: [{ id: 'r1', data: { col_email: 'a@b.c', legacy: 'x' }, executions: {}, position: 0 }],
rowCount: 1,
totalCount: 1,
limit: 1000,
offset: 0,
})
})

it('CSV: header uses display names and cell values resolve from id-keyed data', async () => {
const res = await callGet('csv')
expect(res.status).toBe(200)
const body = await res.text()
const [header, firstRow] = body.trim().split('\n')
expect(header).toBe('email,legacy')
// Without id→name resolution the email cell would be blank.
expect(firstRow).toBe('a@b.c,x')
})

it('JSON: keys are display names, never the stable column id', async () => {
const res = await callGet('json')
expect(res.status).toBe(200)
const parsed = JSON.parse(await res.text())
expect(parsed).toEqual([{ email: 'a@b.c', legacy: 'x' }])
expect(JSON.stringify(parsed)).not.toContain('col_email')
})
})
10 changes: 8 additions & 2 deletions apps/sim/app/api/table/[tableId]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
import { queryRows } from '@/lib/table/service'
import { accessError, checkAccess } from '@/app/api/table/utils'

Expand Down Expand Up @@ -45,6 +46,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
const { table } = access

const columns = table.schema.columns
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so
// translate id → name on the way out (export is a name-friendly boundary).
const nameById = buildNameById(table.schema)
const safeName = sanitizeFilename(table.name)
const filename = `${safeName}.${format}`

Expand All @@ -71,12 +75,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou

for (const row of result.rows) {
if (format === 'csv') {
const values = columns.map((c) => formatCsvValue(row.data[c.name]))
const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)]))
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
} else {
const prefix = firstJsonRow ? '' : ','
firstJsonRow = false
controller.enqueue(encoder.encode(prefix + JSON.stringify({ ...row.data })))
controller.enqueue(
encoder.encode(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
)
}
}

Expand Down
22 changes: 16 additions & 6 deletions apps/sim/app/api/table/[tableId]/import/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,10 +393,14 @@ describe('POST /api/table/[tableId]/import', () => {
)
expect(response.status).toBe(200)
expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'email', type: 'string' }),
])
// Existing columns have no id (legacy) → keyed by name; the new `email`
// column was assigned id `col_deadbeefcafef00d` (mocked generateId).
expect(appendRows()).toEqual([
{ name: 'Alice', age: 30, email: 'a@x.io' },
{ name: 'Bob', age: 40, email: 'b@x.io' },
{ name: 'Alice', age: 30, col_deadbeefcafef00d: 'a@x.io' },
{ name: 'Bob', age: 40, col_deadbeefcafef00d: 'b@x.io' },
])
})

Expand All @@ -408,7 +412,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
expect(appendAdditions()).toEqual([{ name: 'score', type: 'number' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'score', type: 'number' }),
])
})

it('dedupes when sanitized name collides with an existing column', async () => {
Expand All @@ -431,7 +437,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
expect(appendAdditions()).toEqual([{ name: 'Email_2', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'Email_2', type: 'string' }),
])
})

it('returns 400 when createColumns references a header not in the CSV', async () => {
Expand Down Expand Up @@ -494,7 +502,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
// Route forwarded the column addition into the (now atomic) import op.
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'email', type: 'string' }),
])
expect(response.status).toBe(400)
const data = await response.json()
expect(data.success).toBeUndefined()
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
coerceRowsForTable,
createCsvParser,
dispatchAfterBatchInsert,
generateColumnId,
importAppendRows,
importReplaceRows,
inferColumnType,
Expand Down Expand Up @@ -176,7 +177,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro

let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)
let prospectiveTable: TableDefinition = table
const additions: { name: string; type: string }[] = []
const additions: { id?: string; name: string; type: string }[] = []

if (createColumns && createColumns.length > 0) {
const headerSet = new Set(headers)
Expand Down Expand Up @@ -204,8 +205,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
usedNames.add(columnName.toLowerCase())
const inferredType = inferColumnType(rows.map((r) => r[header]))
additions.push({ name: columnName, type: inferredType })
// Pre-assign the id so the prospective schema (used to coerce rows) and
// the persisted column (created in importAppendRows) share the same key.
const id = generateColumnId()
additions.push({ id, name: columnName, type: inferredType })
newColumns.push({
id,
name: columnName,
type: inferredType as TableSchema['columns'][number]['type'],
required: false,
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/import-csv/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
requestId
)
return { table, schema, headerToColumn: inferred.headerToColumn }
// Coerce against the *created* schema so rows key by the ids `createTable`
// assigned (the local `schema` is the id-less inferred one).
return { table, schema: table.schema, headerToColumn: inferred.headerToColumn }
}

let state: ImportState | null = null
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ export const DeleteColumnSchema = deleteTableColumnBodySchema

export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
// Preserve the stable column id — it's the row-data storage key, so dropping
// it makes clients fall back to `name` and miss id-keyed cell values.
...(col.id ? { id: col.id } : {}),
name: col.name,
type: col.type,
required: col.required ?? false,
Expand Down
19 changes: 14 additions & 5 deletions apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ import {
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import { updateRow } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
updateRow,
} from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
Expand Down Expand Up @@ -81,12 +87,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}

const nameById = buildNameById(result.table.schema as TableSchema)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
data: rowDataIdToName(row.data as RowData, nameById),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
Expand Down Expand Up @@ -129,11 +136,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const updatedRow = await updateRow(
{
tableId,
rowId,
data: validated.data as RowData,
data: rowDataNameToId(validated.data as RowData, idByName),
workspaceId: validated.workspaceId,
},
table,
Expand All @@ -153,7 +162,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
data: rowDataIdToName(updatedRow.data, nameById),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
Expand Down
Loading
Loading