Skip to content

Commit b167896

Browse files
j15zclaude
andcommitted
refactor(tables): migrate row parse/validation to the column-type registry
coerceValueToColumnType and validateRowAgainstSchema's switches are replaced by delegates to the registry's parse/isValidValue, so a new column type's coercion and shape-check rules only need to be declared once. The now-dead optionIds helper is removed, and resolveSelectOptionId/splitMultiSelectInput move to select-values.ts (their tests move with them) now that the registry depends on them living there instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b458727 commit b167896

2 files changed

Lines changed: 13 additions & 165 deletions

File tree

apps/sim/lib/table/validation.test.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest'
55
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
66
import {
77
coerceRowToSchema,
8-
resolveSelectOptionId,
98
validateColumnDefinition,
109
validateRowAgainstSchema,
1110
} from '@/lib/table/validation'
@@ -123,22 +122,6 @@ describe('coerceRowToSchema — multiselect', () => {
123122
})
124123
})
125124

126-
describe('resolveSelectOptionId', () => {
127-
const options = selectColumn.options ?? []
128-
129-
it('resolves a stable id', () => {
130-
expect(resolveSelectOptionId('opt_open', options)).toBe('opt_open')
131-
})
132-
133-
it('resolves a display name (case-insensitively)', () => {
134-
expect(resolveSelectOptionId('closed', options)).toBe('opt_closed')
135-
})
136-
137-
it('returns null for an unknown value (drives the type-conversion compatibility gate)', () => {
138-
expect(resolveSelectOptionId('nope', options)).toBeNull()
139-
})
140-
})
141-
142125
describe('validateColumnDefinition — select options', () => {
143126
it('accepts a well-formed select column', () => {
144127
expect(validateColumnDefinition(selectColumn).valid).toBe(true)

apps/sim/lib/table/validation.ts

Lines changed: 13 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,19 @@ import { userTableRows } from '@sim/db/schema'
77
import { and, eq, or, type SQL, sql } from 'drizzle-orm'
88
import { NextResponse } from 'next/server'
99
import { getColumnId } from '@/lib/table/column-keys'
10+
import { isValidColumnValue, parseColumnValue } from '@/lib/table/column-types'
1011
import {
1112
COLUMN_TYPES,
1213
getMaxRowSizeBytes,
1314
MAX_SELECT_OPTIONS,
1415
NAME_PATTERN,
1516
TABLE_LIMITS,
1617
} from '@/lib/table/constants'
17-
import { normalizeDateCellValue } from '@/lib/table/dates'
1818
import { withSeqscanOff } from '@/lib/table/planner'
1919
import type {
2020
ColumnDefinition,
2121
JsonValue,
2222
RowData,
23-
SelectOption,
2423
TableSchema,
2524
ValidationResult,
2625
} from '@/lib/table/types'
@@ -218,7 +217,12 @@ export function validateTableSchema(schema: TableSchema): ValidationResult {
218217
return { valid: errors.length === 0, errors }
219218
}
220219

221-
/** Validates row data matches schema column types and required fields. */
220+
/**
221+
* Validates row data matches schema column types and required fields.
222+
* Delegates each column's shape check to the column-type registry
223+
* (`column-types.ts`) so a new column type's validation rule only needs to be
224+
* added in one place — see `ColumnTypeDefinition.isValidValue`.
225+
*/
222226
export function validateRowAgainstSchema(data: RowData, schema: TableSchema): ValidationResult {
223227
const errors: string[] = []
224228

@@ -232,165 +236,26 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va
232236

233237
if (value === null || value === undefined) continue
234238

235-
switch (column.type) {
236-
case 'string':
237-
if (typeof value !== 'string') {
238-
errors.push(`${column.name} must be string, got ${typeof value}`)
239-
}
240-
break
241-
case 'number':
242-
if (typeof value !== 'number' || Number.isNaN(value)) {
243-
errors.push(`${column.name} must be number`)
244-
}
245-
break
246-
case 'boolean':
247-
if (typeof value !== 'boolean') {
248-
errors.push(`${column.name} must be boolean`)
249-
}
250-
break
251-
case 'date':
252-
if (
253-
!(value instanceof Date) &&
254-
(typeof value !== 'string' || Number.isNaN(Date.parse(value)))
255-
) {
256-
errors.push(`${column.name} must be valid date`)
257-
}
258-
break
259-
case 'json':
260-
try {
261-
JSON.stringify(value)
262-
} catch {
263-
errors.push(`${column.name} must be valid JSON`)
264-
}
265-
break
266-
case 'select': {
267-
const ids = optionIds(column)
268-
if (column.multiple) {
269-
if (!Array.isArray(value)) {
270-
errors.push(`${column.name} must be a list of options`)
271-
} else if (!value.every((v) => typeof v === 'string' && ids.has(v))) {
272-
errors.push(`${column.name} must only contain defined options`)
273-
} else if (column.required && value.length === 0) {
274-
errors.push(`Missing required field: ${column.name}`)
275-
}
276-
} else if (typeof value !== 'string' || !ids.has(value)) {
277-
errors.push(`${column.name} must be one of the defined options`)
278-
}
279-
break
280-
}
281-
}
239+
const error = isValidColumnValue(value, column)
240+
if (error) errors.push(error)
282241
}
283242

284243
return { valid: errors.length === 0, errors }
285244
}
286245

287-
/** Set of valid option ids for a `select`/`multiselect` column. */
288-
function optionIds(column: ColumnDefinition): Set<string> {
289-
return new Set((column.options ?? []).map((o) => o.id))
290-
}
291-
292-
/**
293-
* Resolves a raw cell value to a declared option id, accepting either the
294-
* stable id or (tolerant for tool/import writes) the option's display name.
295-
* Returns null when no option matches. Exported so the column-type-conversion
296-
* path can gate a `select`/`multiselect` change on whether existing values
297-
* actually fit the target option set.
298-
*/
299-
export function resolveSelectOptionId(value: JsonValue, options: SelectOption[]): string | null {
300-
if (typeof value !== 'string') return null
301-
const byId = options.find((o) => o.id === value)
302-
if (byId) return byId.id
303-
const byName =
304-
options.find((o) => o.name === value) ??
305-
options.find((o) => o.name.toLowerCase() === value.toLowerCase())
306-
return byName ? byName.id : null
307-
}
308-
309-
/**
310-
* Splits a raw value into the parts a multi-select cell should resolve. A cell
311-
* may arrive as an array (canonical) or as a single comma-delimited string —
312-
* the shape a multi cell exports, copies, and converts to text as — so both the
313-
* write-path coercion and the column-conversion compatibility check read it
314-
* through here rather than each deciding for itself. Option names that
315-
* themselves contain commas are an accepted ambiguity.
316-
*/
317-
export function splitMultiSelectInput(value: JsonValue): JsonValue[] {
318-
if (Array.isArray(value)) return value
319-
if (typeof value !== 'string') return [value]
320-
return value
321-
.split(',')
322-
.map((part) => part.trim())
323-
.filter((part) => part !== '')
324-
}
325-
326246
/**
327247
* Attempts to coerce a non-null value to a column's declared type. Returns the
328248
* coerced value when the value already matches or can be converted without
329249
* ambiguity (e.g. the string `"1999"` to the number `1999`), and `ok: false`
330-
* when no safe conversion exists.
250+
* when no safe conversion exists. Delegates to the column-type registry
251+
* (`column-types.ts`) so a new column type's parse rule only needs to be
252+
* added in one place — see `ColumnTypeDefinition.parse`.
331253
*/
332254
function coerceValueToColumnType(
333255
value: JsonValue,
334256
column: ColumnDefinition
335257
): { ok: true; value: JsonValue } | { ok: false } {
336-
switch (column.type) {
337-
case 'string':
338-
if (typeof value === 'string') return { ok: true, value }
339-
if (typeof value === 'number' || typeof value === 'boolean') {
340-
return { ok: true, value: String(value) }
341-
}
342-
return { ok: false }
343-
case 'number':
344-
if (typeof value === 'number') {
345-
return Number.isFinite(value) ? { ok: true, value } : { ok: false }
346-
}
347-
if (typeof value === 'string' && value.trim() !== '') {
348-
const parsed = Number(value)
349-
return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false }
350-
}
351-
return { ok: false }
352-
case 'boolean':
353-
if (typeof value === 'boolean') return { ok: true, value }
354-
if (typeof value === 'string') {
355-
const normalized = value.trim().toLowerCase()
356-
if (normalized === 'true') return { ok: true, value: true }
357-
if (normalized === 'false') return { ok: true, value: false }
358-
}
359-
return { ok: false }
360-
case 'date': {
361-
if (typeof value === 'string') {
362-
const normalized = normalizeDateCellValue(value)
363-
return normalized === null ? { ok: false } : { ok: true, value: normalized }
364-
}
365-
// Date instances and epoch numbers may still be out of the representable
366-
// range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on
367-
// an Invalid Date, so an over-range value degrades to `{ ok: false }`
368-
// rather than crashing the write.
369-
const date =
370-
value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null
371-
if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() }
372-
return { ok: false }
373-
}
374-
case 'select': {
375-
const options = column.options ?? []
376-
if (column.multiple) {
377-
const raw = splitMultiSelectInput(value)
378-
const ids: string[] = []
379-
for (const entry of raw) {
380-
const id = resolveSelectOptionId(entry, options)
381-
if (id !== null && !ids.includes(id)) ids.push(id)
382-
}
383-
return { ok: true, value: ids }
384-
}
385-
// Single: tolerate an array (e.g. right after a multiple→single toggle) by
386-
// resolving its first element so the value isn't dropped wholesale.
387-
const single = Array.isArray(value) ? value[0] : value
388-
const id = single === undefined ? null : resolveSelectOptionId(single, options)
389-
return id !== null ? { ok: true, value: id } : { ok: false }
390-
}
391-
default:
392-
return { ok: true, value }
393-
}
258+
return parseColumnValue(value, column)
394259
}
395260

396261
/**

0 commit comments

Comments
 (0)