2727 * - The controls: a finite number passes, including `0` and a negative; a
2828 * blank is still `null` before the arm (#20308); `summary` is still not
2929 * judged (the seat ruling on #20308).
30- * - The STRING half is unchanged: a string is still judged by `Number()`, as
31- * at base. Which strings a number field accepts waits on the producer census
32- * and the spec's numeric grammar (#20336). The characterization below turns
33- * red when that half lands, on purpose.
30+ * - The STRING half (the second part of #20309): a string is judged by the
31+ * spec's one numeric grammar, `parseNumericString`, driven here through its
32+ * own case table `NUMERIC_STRING_GRAMMAR_CASES` — never a list of this
33+ * file's. Before it, the arm read a string by `Number()` and the write
34+ * carried the string: memory stored `'12'` as the string `'12'`, SQLite
35+ * stored `'0x10'` as TEXT. An admitted string is now judged as its number
36+ * and `normalizeNumericStringValues` writes that number into the payload; a
37+ * string the grammar does not read is `invalid_number`. The strings
38+ * `Number()` read as finite and the grammar refuses are the narrowing, named
39+ * below.
40+ * - `min`, `max`, `scale` and `precision` read the parsed number: a string and
41+ * the number it denotes get the same answer.
3442 *
3543 * The driver-facing half (what reaches the driver on each engine door) is
3644 * `../engine-number-value-door.test.ts`. The physical column, through REST on
3745 * SQLite, is `packages/rest/src/rest-data-number-value.test.ts`.
3846 */
3947
4048import { describe , it , expect } from 'vitest' ;
41- import { COMPUTED_VALUE_TYPES , NUMERIC_VALUE_TYPES , valueSchemaFor } from '@objectstack/spec/data' ;
42- import { normalizeBlankTypedValues , validateRecord , ValidationError } from './record-validator.js' ;
49+ import {
50+ COMPUTED_VALUE_TYPES ,
51+ NUMERIC_STRING_GRAMMAR_CASES ,
52+ NUMERIC_VALUE_TYPES ,
53+ parseNumericString ,
54+ valueSchemaFor ,
55+ } from '@objectstack/spec/data' ;
56+ import {
57+ normalizeBlankTypedValues ,
58+ normalizeNumericStringValues ,
59+ validateRecord ,
60+ ValidationError ,
61+ } from './record-validator.js' ;
4362
4463const JUDGED = [ ...NUMERIC_VALUE_TYPES ] . filter ( ( t ) => ! COMPUTED_VALUE_TYPES . has ( t ) ) ;
4564
@@ -120,16 +139,6 @@ describe('the number arm: an array, boolean or object is invalid_number (#20309)
120139 }
121140 } ) ;
122141
123- it ( 'UNCHANGED here: a string is still judged by Number(), as at base (the string half waits on #20336)' , ( ) => {
124- // A characterization, not an endorsement: the spec's stored value schema
125- // refuses every one of these. It turns red when the string half lands.
126- for ( const type of JUDGED ) {
127- for ( const s of [ '12' , '12.5' , '0x10' , ' 12 ' , '1e3' ] ) {
128- expect ( answer ( type , s , 'insert' ) , `${ type } ${ JSON . stringify ( s ) } ` ) . toBeNull ( ) ;
129- }
130- }
131- } ) ;
132-
133142 it ( 'CONTROL: a blank is still null before the arm, so it is never judged (#20308)' , ( ) => {
134143 for ( const blank of [ '' , ' ' ] ) {
135144 const row = normalizeBlankTypedValues ( schemaOf ( JUDGED ) , Object . fromEntries ( JUDGED . map ( ( t ) => [ `f_${ t } ` , blank ] ) ) ) ;
@@ -145,3 +154,202 @@ describe('the number arm: an array, boolean or object is invalid_number (#20309)
145154 }
146155 } ) ;
147156} ) ;
157+
158+ // ── The string half (#20309): the spec's numeric grammar ─────────────────────
159+
160+ /** The grammar's own table, split by its own verdict. Blank rows are the blank
161+ * rule's (#20308): they never reach the arm as a string on these types. */
162+ const ADMITTED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES . filter ( ( c ) => c . numeric ) ;
163+ const REFUSED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES . filter ( ( c ) => ! c . numeric && c . form !== 'empty' ) ;
164+ const BLANK_STRINGS = NUMERIC_STRING_GRAMMAR_CASES . filter ( ( c ) => ! c . numeric && c . form === 'empty' ) ;
165+
166+ /** The rewrite, then the validator: the two halves of the write door, in order. */
167+ function doorAnswer ( type : string , value : unknown , mode : 'insert' | 'update' ) {
168+ const row = normalizeNumericStringValues ( schemaOf ( [ type ] ) , { [ `f_${ type } ` ] : value } ) ;
169+ return answer ( type , row [ `f_${ type } ` ] , mode ) ;
170+ }
171+
172+ describe ( 'the number arm reads a string by the spec numeric grammar (#20309, the string half)' , ( ) => {
173+ it ( 'CONTROL: the case table has both halves and every non-blank form, so nothing below passes over nothing' , ( ) => {
174+ expect ( ADMITTED_STRINGS . length ) . toBeGreaterThanOrEqual ( 10 ) ;
175+ expect ( new Set ( REFUSED_STRINGS . map ( ( c ) => ( c . numeric ? '' : c . form ) ) ) ) . toEqual ( new Set ( [
176+ 'padded' , 'placeholder' , 'radix-prefix' , 'non-finite' , 'digit-separator' , 'non-json-spelling' , 'not-a-number' ,
177+ ] ) ) ;
178+ expect ( BLANK_STRINGS . length ) . toBeGreaterThan ( 0 ) ;
179+ } ) ;
180+
181+ describe . each ( JUDGED ) ( '%s' , ( type ) => {
182+ it . each ( ADMITTED_STRINGS . map ( ( c ) => [ JSON . stringify ( c . input ) , c . input ] as const ) ) (
183+ 'admits %s on insert and update, straight to the arm and through the door rewrite' ,
184+ ( _l , input ) => {
185+ for ( const mode of [ 'insert' , 'update' ] as const ) {
186+ expect ( answer ( type , input , mode ) , mode ) . toBeNull ( ) ;
187+ expect ( doorAnswer ( type , input , mode ) , mode ) . toBeNull ( ) ;
188+ }
189+ } ,
190+ ) ;
191+
192+ it . each ( REFUSED_STRINGS . map ( ( c ) => [ JSON . stringify ( c . input ) , c . numeric ? '' : c . form , c . input ] as const ) ) (
193+ 'refuses %s (%s) with invalid_number on insert and update, straight to the arm and through the door rewrite' ,
194+ ( _l , _form , input ) => {
195+ const expected = [ [ `f_${ type } ` , 'invalid_number' ] ] ;
196+ for ( const mode of [ 'insert' , 'update' ] as const ) {
197+ expect ( answer ( type , input , mode ) , mode ) . toEqual ( expected ) ;
198+ expect ( doorAnswer ( type , input , mode ) , mode ) . toEqual ( expected ) ;
199+ }
200+ } ,
201+ ) ;
202+ } ) ;
203+
204+ it ( 'the arm accepts a string exactly when parseNumericString reads it — no second grammar' , ( ) => {
205+ const probes = [
206+ ...NUMERIC_STRING_GRAMMAR_CASES . map ( ( c ) => c . input ) ,
207+ // Beyond the table, to catch a private reading that happens to agree on it.
208+ '-0.0' , '1E3' , '1.e3' , '-.5' , '00' , '0x' , '١٢' , '12 ' , ' ' , ' 12' , '1e+' , '9007199254740993' ,
209+ ] . filter ( ( s ) => s . trim ( ) !== '' ) ;
210+ for ( const type of JUDGED ) {
211+ for ( const s of probes ) {
212+ const read = parseNumericString ( s ) !== undefined ;
213+ expect ( answer ( type , s , 'insert' ) === null , `${ type } ${ JSON . stringify ( s ) } ` ) . toBe ( read ) ;
214+ }
215+ }
216+ } ) ;
217+
218+ it ( 'NAMED NARROWING: the strings Number() read as finite that the grammar refuses, each now invalid_number' , ( ) => {
219+ // What the old arm (`Number(value)` finite) admitted and the grammar does
220+ // not: the BREAKING set, read off the spec table rather than listed here
221+ // as a claim — the literal below pins that the table's set is what the
222+ // changeset names.
223+ const narrowed = NUMERIC_STRING_GRAMMAR_CASES
224+ . filter ( ( c ) => ! c . numeric && c . form !== 'empty' && Number . isFinite ( Number ( c . input ) ) )
225+ . map ( ( c ) => c . input ) ;
226+ expect ( narrowed ) . toEqual ( [ ' 12 ' , '12\n' , '\t-3' , '0x10' , '0X1A' , '0o17' , '0b101' , '+5' , '.5' , '5.' , '007' ] ) ;
227+ for ( const type of JUDGED ) {
228+ for ( const s of narrowed ) expect ( answer ( type , s , 'insert' ) , `${ type } ${ JSON . stringify ( s ) } ` ) . toEqual ( [ [ `f_${ type } ` , 'invalid_number' ] ] ) ;
229+ }
230+ } ) ;
231+
232+ it ( 'CONTROL: the table\'s blank rows never reach the arm — the blank rule makes them null first (#20308)' , ( ) => {
233+ for ( const c of BLANK_STRINGS ) {
234+ const row = normalizeBlankTypedValues ( schemaOf ( JUDGED ) , Object . fromEntries ( JUDGED . map ( ( t ) => [ `f_${ t } ` , c . input ] ) ) ) ;
235+ for ( const t of JUDGED ) expect ( ( row as Record < string , unknown > ) [ `f_${ t } ` ] , `${ t } ${ JSON . stringify ( c . input ) } ` ) . toBeNull ( ) ;
236+ // …and the numeric rewrite leaves a blank for the blank rule: it reads no number.
237+ const untouched = { f_number : c . input } ;
238+ expect ( normalizeNumericStringValues ( schemaOf ( [ 'number' ] ) , untouched ) ) . toBe ( untouched ) ;
239+ }
240+ } ) ;
241+ } ) ;
242+
243+ describe ( 'normalizeNumericStringValues: an admitted string is written as its number (#20309)' , ( ) => {
244+ it ( 'writes every admitted row of the grammar table as the table\'s own number, on every judged type' , ( ) => {
245+ for ( const type of JUDGED ) {
246+ for ( const c of ADMITTED_STRINGS ) {
247+ if ( ! c . numeric ) continue ;
248+ const out = normalizeNumericStringValues ( schemaOf ( [ type ] ) , { [ `f_${ type } ` ] : c . input } ) ;
249+ expect ( Object . is ( out [ `f_${ type } ` ] , c . value ) , `${ type } ${ JSON . stringify ( c . input ) } -> ${ String ( out [ `f_${ type } ` ] ) } ` ) . toBe ( true ) ;
250+ }
251+ }
252+ } ) ;
253+
254+ it ( 'what it writes is the spec\'s stored value for the type, and the arm accepts it: one value judged and stored' , ( ) => {
255+ for ( const type of JUDGED ) {
256+ const stored = valueSchemaFor ( { type } , 'stored' ) ;
257+ for ( const c of ADMITTED_STRINGS ) {
258+ const out = normalizeNumericStringValues ( schemaOf ( [ type ] ) , { [ `f_${ type } ` ] : c . input } ) ;
259+ expect ( stored . safeParse ( out [ `f_${ type } ` ] ) . success , `${ type } ${ JSON . stringify ( c . input ) } ` ) . toBe ( true ) ;
260+ expect ( answer ( type , out [ `f_${ type } ` ] , 'insert' ) , `${ type } ${ JSON . stringify ( c . input ) } ` ) . toBeNull ( ) ;
261+ }
262+ }
263+ } ) ;
264+
265+ it ( 'leaves every string the grammar refuses exactly as sent (the same reference back), so the arm refuses it' , ( ) => {
266+ for ( const type of JUDGED ) {
267+ for ( const c of REFUSED_STRINGS ) {
268+ const row = { [ `f_${ type } ` ] : c . input } ;
269+ expect ( normalizeNumericStringValues ( schemaOf ( [ type ] ) , row ) , `${ type } ${ JSON . stringify ( c . input ) } ` ) . toBe ( row ) ;
270+ }
271+ }
272+ } ) ;
273+
274+ it ( 'rewrites only what the arm judges: not a text field, not summary, not a system or readonly field, not id, not a non-string' , ( ) => {
275+ const schema = {
276+ fields : {
277+ id : { name : 'id' , type : 'number' } ,
278+ created_at : { name : 'created_at' , type : 'number' } ,
279+ f_text : { name : 'f_text' , type : 'text' } ,
280+ f_summary : { name : 'f_summary' , type : 'summary' } ,
281+ f_formula : { name : 'f_formula' , type : 'formula' } ,
282+ f_system : { name : 'f_system' , type : 'number' , system : true } ,
283+ f_readonly : { name : 'f_readonly' , type : 'number' , readonly : true } ,
284+ f_number : { name : 'f_number' , type : 'number' } ,
285+ } ,
286+ } as any ;
287+ const row = {
288+ id : '12' , created_at : '12' , f_text : '12' , f_summary : '12' , f_formula : '12' , f_system : '12' , f_readonly : '12' ,
289+ f_undeclared : '12' , f_number : 12 ,
290+ } ;
291+ expect ( normalizeNumericStringValues ( schema , row ) ) . toBe ( row ) ;
292+ // CONTROL: the same schema does rewrite its judged field when it is a string.
293+ expect ( normalizeNumericStringValues ( schema , { ...row , f_number : '12' } ) . f_number ) . toBe ( 12 ) ;
294+ } ) ;
295+
296+ it ( 'is pure: the caller\'s record is never mutated; one record or an array of them, copied only where changed' , ( ) => {
297+ const schema = schemaOf ( [ 'number' ] ) ;
298+ const a = { f_number : '12' } ;
299+ const b = { f_number : 7 } ;
300+ const one = normalizeNumericStringValues ( schema , a ) ;
301+ expect ( one ) . not . toBe ( a ) ;
302+ expect ( one ) . toEqual ( { f_number : 12 } ) ;
303+ expect ( a ) . toEqual ( { f_number : '12' } ) ;
304+
305+ const list = [ a , b ] ;
306+ const out = normalizeNumericStringValues ( schema , list ) ;
307+ expect ( out ) . not . toBe ( list ) ;
308+ expect ( out [ 0 ] ) . toEqual ( { f_number : 12 } ) ;
309+ expect ( out [ 1 ] ) . toBe ( b ) ;
310+ expect ( list [ 0 ] ) . toBe ( a ) ;
311+
312+ const clean = [ b ] ;
313+ expect ( normalizeNumericStringValues ( schema , clean ) ) . toBe ( clean ) ;
314+ // A field named after an Object.prototype member is looked up as an own property.
315+ const proto = { fields : { valueOf : { name : 'valueOf' , type : 'number' } } } as any ;
316+ expect ( normalizeNumericStringValues ( proto , { valueOf : '3' } ) ) . toEqual ( { valueOf : 3 } ) ;
317+ expect ( normalizeNumericStringValues ( schema , { constructor : '3' } ) ) . toEqual ( { constructor : '3' } ) ;
318+ } ) ;
319+ } ) ;
320+
321+ describe ( 'bounds, scale and precision read the parsed number: a string answers as its number does (#20309)' , ( ) => {
322+ const field = ( type : string , extra : Record < string , unknown > ) => ( { fields : { f : { name : 'f' , type, ...extra } } } ) as any ;
323+ /** Every field-level error, whole — code, constraint and message. */
324+ const whole = ( schema : any , value : unknown ) => {
325+ try { validateRecord ( schema , { f : value } , 'insert' ) ; return null ; }
326+ catch ( e ) { expect ( e ) . toBeInstanceOf ( ValidationError ) ; return ( e as ValidationError ) . fields ; }
327+ } ;
328+
329+ const CASES : ReadonlyArray < readonly [ string , string , Record < string , unknown > , string , string | null ] > = [
330+ [ '12.50 under scale 1' , 'number' , { scale : 1 } , '12.50' , null ] ,
331+ [ '12.55 under scale 1' , 'number' , { scale : 1 } , '12.55' , 'max_scale' ] ,
332+ [ '1e-7 under scale 2' , 'number' , { scale : 2 } , '1e-7' , 'max_scale' ] ,
333+ [ '0.10 under scale 1' , 'number' , { scale : 1 } , '0.10' , null ] ,
334+ [ '2.5E+3 under scale 0' , 'number' , { scale : 0 } , '2.5E+3' , null ] ,
335+ [ '3.5 on a rating under scale 0' , 'rating' , { scale : 0 } , '3.5' , 'max_scale' ] ,
336+ [ '3 on a rating under scale 0' , 'rating' , { scale : 0 } , '3' , null ] ,
337+ [ '150 over max 100' , 'number' , { max : 100 } , '150' , 'max_value' ] ,
338+ [ '-5 under min 0' , 'number' , { min : 0 } , '-5' , 'min_value' ] ,
339+ [ '150 over a progress max 100' , 'progress' , { max : 100 } , '150' , 'max_value' ] ,
340+ [ '1234.5 over precision 5 at scale 2' , 'number' , { precision : 5 , scale : 2 } , '1234.5' , 'max_precision' ] ,
341+ [ '999.99 at precision 5, scale 2' , 'number' , { precision : 5 , scale : 2 } , '999.99' , null ] ,
342+ [ '0.1234 on a fraction percent at scale 2' , 'percent' , { scale : 2 } , '0.1234' , null ] ,
343+ [ '0.12345 on a fraction percent at scale 2' , 'percent' , { scale : 2 } , '0.12345' , 'max_scale' ] ,
344+ ] ;
345+
346+ it . each ( CASES ) ( '%s' , ( _l , type , extra , input , code ) => {
347+ const schema = field ( type , extra ) ;
348+ const asString = whole ( schema , input ) ;
349+ expect ( asString ?. map ( ( x ) => x . code ) ?? null ) . toEqual ( code === null ? null : [ code ] ) ;
350+ // The same answer, byte for byte, as the number the string denotes.
351+ expect ( asString ) . toEqual ( whole ( schema , parseNumericString ( input ) ) ) ;
352+ // And the door writes that number.
353+ expect ( normalizeNumericStringValues ( schema , { f : input } ) . f ) . toBe ( parseNumericString ( input ) ) ;
354+ } ) ;
355+ } ) ;
0 commit comments