@@ -7,20 +7,19 @@ import { userTableRows } from '@sim/db/schema'
77import { and , eq , or , type SQL , sql } from 'drizzle-orm'
88import { NextResponse } from 'next/server'
99import { getColumnId } from '@/lib/table/column-keys'
10+ import { isValidColumnValue , parseColumnValue } from '@/lib/table/column-types'
1011import {
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'
1818import { withSeqscanOff } from '@/lib/table/planner'
1919import 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+ */
222226export 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 */
332254function 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