@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'
55import { TABLE_LIMITS } from '../constants'
66import {
77 type ColumnDefinition ,
8+ coerceRowToSchema ,
9+ coerceRowValues ,
810 getUniqueColumns ,
911 type TableSchema ,
1012 validateColumnDefinition ,
@@ -277,6 +279,127 @@ describe('Validation', () => {
277279 } )
278280 } )
279281
282+ describe ( 'coerceRowToSchema' , ( ) => {
283+ const schema : TableSchema = {
284+ columns : [
285+ { name : 'name' , type : 'string' , required : true } ,
286+ { name : 'age' , type : 'number' } ,
287+ { name : 'founded' , type : 'number' , required : true } ,
288+ { name : 'active' , type : 'boolean' } ,
289+ { name : 'created' , type : 'date' } ,
290+ { name : 'metadata' , type : 'json' } ,
291+ ] ,
292+ }
293+
294+ it ( 'coerces a numeric string to a number in place' , ( ) => {
295+ const data = { name : 'Acme' , founded : '1999' }
296+ const result = coerceRowToSchema ( data , schema )
297+ expect ( result . valid ) . toBe ( true )
298+ expect ( data . founded ) . toBe ( 1999 )
299+ } )
300+
301+ it ( 'nulls an un-coercible value for an optional number column' , ( ) => {
302+ const data = { name : 'Acme' , founded : 2000 , age : 'unknown' }
303+ const result = coerceRowToSchema ( data , schema )
304+ expect ( result . valid ) . toBe ( true )
305+ expect ( data . age ) . toBeNull ( )
306+ } )
307+
308+ it ( 'rejects an un-coercible value for a required number column' , ( ) => {
309+ const data = { name : 'Acme' , founded : 'unknown' }
310+ const result = coerceRowToSchema ( data , schema )
311+ expect ( result . valid ) . toBe ( false )
312+ expect ( result . errors [ 0 ] ) . toContain ( 'founded must be number' )
313+ expect ( data . founded ) . toBe ( 'unknown' )
314+ } )
315+
316+ it ( 'coerces a number to a string for a string column' , ( ) => {
317+ const data = { name : 12345 , founded : 2000 }
318+ const result = coerceRowToSchema ( data , schema )
319+ expect ( result . valid ) . toBe ( true )
320+ expect ( data . name ) . toBe ( '12345' )
321+ } )
322+
323+ it ( 'coerces "true"/"false" strings to booleans' , ( ) => {
324+ const data = { name : 'Acme' , founded : 2000 , active : 'false' }
325+ const result = coerceRowToSchema ( data , schema )
326+ expect ( result . valid ) . toBe ( true )
327+ expect ( data . active ) . toBe ( false )
328+ } )
329+
330+ it ( 'coerces an epoch number to an ISO date string' , ( ) => {
331+ const epoch = Date . parse ( '2024-01-15T00:00:00Z' )
332+ const data = { name : 'Acme' , founded : 2000 , created : epoch }
333+ const result = coerceRowToSchema ( data , schema )
334+ expect ( result . valid ) . toBe ( true )
335+ expect ( data . created ) . toBe ( new Date ( epoch ) . toISOString ( ) )
336+ } )
337+
338+ it ( 'coerces a Date instance to an ISO date string' , ( ) => {
339+ const date = new Date ( '2024-01-15T00:00:00Z' )
340+ const data = { name : 'Acme' , founded : 2000 , created : date }
341+ const result = coerceRowToSchema ( data , schema )
342+ expect ( result . valid ) . toBe ( true )
343+ expect ( data . created ) . toBe ( date . toISOString ( ) )
344+ } )
345+
346+ it ( 'nulls an out-of-range epoch number for an optional date column without throwing' , ( ) => {
347+ const data = { name : 'Acme' , founded : 2000 , created : 1e20 }
348+ const result = coerceRowToSchema ( data , schema )
349+ expect ( result . valid ) . toBe ( true )
350+ expect ( data . created ) . toBeNull ( )
351+ } )
352+
353+ it ( 'nulls an invalid Date instance for an optional date column without throwing' , ( ) => {
354+ const data = { name : 'Acme' , founded : 2000 , created : new Date ( 'not-a-date' ) }
355+ const result = coerceRowToSchema ( data , schema )
356+ expect ( result . valid ) . toBe ( true )
357+ expect ( data . created ) . toBeNull ( )
358+ } )
359+
360+ it ( 'leaves already-correct values untouched and passes through json' , ( ) => {
361+ const data = { name : 'Acme' , founded : 2000 , metadata : { k : 'v' } }
362+ const result = coerceRowToSchema ( data , schema )
363+ expect ( result . valid ) . toBe ( true )
364+ expect ( data ) . toEqual ( { name : 'Acme' , founded : 2000 , metadata : { k : 'v' } } )
365+ } )
366+
367+ it ( 'still rejects a missing required field' , ( ) => {
368+ const data = { name : 'Acme' }
369+ const result = coerceRowToSchema ( data , schema )
370+ expect ( result . valid ) . toBe ( false )
371+ expect ( result . errors ) . toContain ( 'Missing required field: founded' )
372+ } )
373+ } )
374+
375+ describe ( 'coerceRowValues' , ( ) => {
376+ const schema : TableSchema = {
377+ columns : [
378+ { name : 'name' , type : 'string' , required : true } ,
379+ { name : 'founded' , type : 'number' , required : true } ,
380+ { name : 'age' , type : 'number' } ,
381+ ] ,
382+ }
383+
384+ it ( 'coerces a partial patch in place without flagging absent required fields' , ( ) => {
385+ const patch = { age : '42' }
386+ coerceRowValues ( patch , schema )
387+ expect ( patch . age ) . toBe ( 42 )
388+ } )
389+
390+ it ( 'nulls an un-coercible optional value in a patch' , ( ) => {
391+ const patch : { age : unknown } = { age : 'nope' }
392+ coerceRowValues ( patch as never , schema )
393+ expect ( patch . age ) . toBeNull ( )
394+ } )
395+
396+ it ( 'leaves an un-coercible required value in place for downstream validation' , ( ) => {
397+ const patch : { founded : unknown } = { founded : 'nope' }
398+ coerceRowValues ( patch as never , schema )
399+ expect ( patch . founded ) . toBe ( 'nope' )
400+ } )
401+ } )
402+
280403 describe ( 'getUniqueColumns' , ( ) => {
281404 it ( 'should return only columns with unique=true' , ( ) => {
282405 const schema : TableSchema = {
0 commit comments