Skip to content

Commit 94e0692

Browse files
committed
test(objectql,rest): pin the number arm's string half on the spec grammar's case table (#20309)
Validator, engine door (stub driver payload, hooks, dry run) and REST on SQLite (physical cell and storage class), each driven by NUMERIC_STRING_GRAMMAR_CASES: admitted strings are judged and stored as their number, refused ones answer invalid_number and write nothing. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent c67623f commit 94e0692

3 files changed

Lines changed: 392 additions & 21 deletions

File tree

‎packages/objectql/src/engine-number-value-door.test.ts‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@
1515
* (`Object.is`), so for a number what the arm judged is what the driver
1616
* receives. Memory and MongoDB store exactly this payload. The SQL physical
1717
* column is pinned in `packages/rest/src/rest-data-number-value.test.ts`.
18-
* A string is not judged differently here: that half waits on #20336.
18+
*
19+
* The string half (#20309, second part): a string the spec's numeric grammar
20+
* reads (`parseNumericString`) reaches the driver as the NUMBER it denotes on
21+
* every door, and every stage between the door and the driver (a `before*`
22+
* hook, the dry run) sees that number. Measured on `origin/main` 851af0c27
23+
* before it: memory stored `'12'` as the string `'12'` and read it back as a
24+
* string, and `'0x10'` / `' 12 '` / `'+5'` were accepted and stored as sent. A
25+
* string the grammar does not read is refused and never reaches the driver.
1926
*/
2027

2128
import { describe, it, expect, beforeEach } from 'vitest';
22-
import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
29+
import { COMPUTED_VALUE_TYPES, NUMERIC_STRING_GRAMMAR_CASES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
2330
import { ObjectQL } from './engine.js';
2431
import { ValidationError } from './validation/record-validator.js';
2532

@@ -155,3 +162,80 @@ describe('engine write doors: the number arm judges what the driver receives (#2
155162
}
156163
});
157164
});
165+
166+
/** The spec grammar's own verdicts (#20336): admitted strings with their number, and refused non-blank strings. */
167+
const ADMITTED = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (c.numeric ? [[JSON.stringify(c.input), c.input, c.value] as const] : []));
168+
const REFUSED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (!c.numeric && c.form !== 'empty' ? [[JSON.stringify(c.input), c.input] as const] : []));
169+
170+
describe('engine write doors: a numeric string reaches the driver as its number (#20309, the string half)', () => {
171+
let engine: ObjectQL;
172+
let stub: ReturnType<typeof makeStubDriver>;
173+
174+
beforeEach(async () => {
175+
stub = makeStubDriver();
176+
engine = new ObjectQL();
177+
engine.registerDriver(stub.driver, true);
178+
await engine.init();
179+
engine.registry.registerObject(OBJ as any);
180+
});
181+
182+
const written = (field: string) =>
183+
stub.calls.flatMap((c) => c.rows).filter((r) => field in r).map((r) => r[field]);
184+
185+
it('CONTROL: the grammar table has both halves', () => {
186+
expect(ADMITTED.length).toBeGreaterThanOrEqual(10);
187+
expect(REFUSED_STRINGS.length).toBeGreaterThanOrEqual(20);
188+
});
189+
190+
describe.each(JUDGED)('%s', (type) => {
191+
it.each(ADMITTED)('%s arrives as the number on insert, insert([...]), insertMany, update by id and update by predicate', async (_l, input, value) => {
192+
await engine.insert('num_door', { id: 'seed', [f(type)]: 1 });
193+
stub.calls.length = 0;
194+
const caller = { id: 'a', [f(type)]: input };
195+
await engine.insert('num_door', caller);
196+
await engine.insert('num_door', [{ id: 'b', [f(type)]: input }]);
197+
const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: input }]);
198+
expect(outcomes.map((o) => o.ok)).toEqual([true]);
199+
await engine.update('num_door', { id: 'seed', [f(type)]: input });
200+
await engine.update('num_door', { [f(type)]: input }, { where: { id: { $in: ['seed'] } }, multi: true } as any);
201+
202+
const got = written(f(type));
203+
expect(got).toHaveLength(5);
204+
for (const g of got) expect(Object.is(g, value), `${JSON.stringify(input)} -> ${String(g)}`).toBe(true);
205+
// The rewrite is copy-on-write: the caller's object still holds its string.
206+
expect(caller[f(type)]).toBe(input);
207+
});
208+
209+
it.each(REFUSED_STRINGS)('%s is refused on every door and never reaches the driver', async (_l, input) => {
210+
await engine.insert('num_door', { id: 'seed', [f(type)]: 1 });
211+
stub.calls.length = 0;
212+
const expected = { code: 'VALIDATION_FAILED', fields: [[f(type), 'invalid_number']] };
213+
214+
expect(await refusal(() => engine.insert('num_door', { id: 'a', [f(type)]: input }))).toEqual(expected);
215+
expect(await refusal(() => engine.insert('num_door', [{ id: 'b', [f(type)]: input }]))).toEqual(expected);
216+
expect(await refusal(() => engine.update('num_door', { id: 'seed', [f(type)]: input }))).toEqual(expected);
217+
expect(await refusal(() => engine.update('num_door', { [f(type)]: input }, { where: { id: { $in: ['seed'] } }, multi: true } as any))).toEqual(expected);
218+
const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: input }]);
219+
expect(outcomes.map((o) => o.ok)).toEqual([false]);
220+
221+
expect(written(f(type))).toEqual([]);
222+
});
223+
});
224+
225+
it('a before-hook sees the number, on insert and on update: every stage after the door reads one image', async () => {
226+
const seen: Array<[string, unknown]> = [];
227+
engine.registerHook('beforeInsert', async (ctx: any) => { seen.push(['insert', ctx.input.data.f_number]); }, { object: 'num_door' });
228+
engine.registerHook('beforeUpdate', async (ctx: any) => { seen.push(['update', ctx.input.data.f_number]); }, { object: 'num_door' });
229+
await engine.insert('num_door', { id: 'h1', f_number: '12.5' });
230+
await engine.update('num_door', { id: 'h1', f_number: '-3' });
231+
expect(seen).toEqual([['insert', 12.5], ['update', -3]]);
232+
});
233+
234+
it('the dry run agrees with the write on a string', async () => {
235+
const refused = await engine.validate('num_door', { id: 'p1', f_number: '0x10' });
236+
expect(refused.valid).toBe(false);
237+
expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['f_number', 'invalid_number']]);
238+
expect((await engine.validate('num_door', { id: 'p2', f_number: '12' })).valid).toBe(true);
239+
expect((await engine.validate('num_door', { id: 'p3', f_number: ' 12 ' })).valid).toBe(false);
240+
});
241+
});

‎packages/objectql/src/validation/record-validator.number-value.test.ts‎

Lines changed: 224 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,38 @@
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

4048
import { 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

4463
const 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

Comments
 (0)