From c67623f2297dff3501ab03e7cfe921788bf3be55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:08:02 +0000 Subject: [PATCH 1/4] fix(objectql)!: a number field reads a string by the spec's numeric grammar and stores its number (#20309) The record validator's number arm judges a string with parseNumericString (@objectstack/spec/data) instead of Number()-finite, and a new write-side rewrite, normalizeNumericStringValues, stores an admitted string as the number it denotes at the three points normalizeBlankTypedValues runs (insert, update, validate). Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- packages/objectql/src/engine.ts | 20 ++- .../src/validation/record-validator.ts | 118 ++++++++++++++++-- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0924074a338..478bea52d6c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -219,7 +219,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' // registrar and `os validate` both call. import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine } from './hook-binder.js'; -import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -11742,8 +11742,12 @@ export class ObjectQL implements IObjectQLEngine { // [#20308] The write doors read a blank on a non-string-typed column as // `null` before anything else; the preview does the same at the same point, // or a blank on a required field with a `defaultValue` would preview - // `required` while the write takes the default. - const rawRows = normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]); + // `required` while the write takes the default. [#20309] Likewise a + // numeric string on a number field is its number here, as on the write. + const rawRows = normalizeNumericStringValues( + schemaForValidation, + normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]), + ); const nowSnapshot = new Date(); // [#20082] The preview's ONE permission resolution, shared by its CEL // defaults and its option gates below, exactly as the write shares one. A @@ -11914,8 +11918,11 @@ export class ObjectQL implements IObjectQLEngine { // validation read the payload, so all of them see one image (a blank then // takes a `defaultValue` exactly as `null` does). See // `normalizeBlankTypedValues` for the scope; it never mutates the caller's - // rows. + // rows. [#20309] At the same point, a string on a number field that the + // spec's numeric grammar reads becomes that number, so the validator judges + // the value the driver stores (`normalizeNumericStringValues`). data = normalizeBlankTypedValues(this._registry.getObject(object), data); + data = normalizeNumericStringValues(this._registry.getObject(object), data); const opCtx: OperationContext = { object, @@ -12959,8 +12966,11 @@ export class ObjectQL implements IObjectQLEngine { // non-string-typed column is `null` before the middleware, the // caller-value snapshot (`suppliedValues`), the hooks, the read-only // strips and validation read the payload — so a `readonlyWhen` lock judges - // the value it snapshotted. See `normalizeBlankTypedValues`. + // the value it snapshotted. See `normalizeBlankTypedValues`. [#20309] The + // insert door's numeric-string rewrite, same place and same reason (see + // `normalizeNumericStringValues`). data = normalizeBlankTypedValues(this._registry.getObject(object), data); + data = normalizeNumericStringValues(this._registry.getObject(object), data); // 1. Extract ID from data or where if it's a single update by ID. // Only a SCALAR `where.id` means "update one row by primary key". An diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 9ede5b5be63..4a253dd4083 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -26,8 +26,9 @@ * spec's shared `isValueDomainMember` — the WRITTEN value * only (#14168, maintainer ruling 2026-09-02 option A) * - number types an array, boolean or object is `invalid_number`, never - * coerced (#20309); a number, or a string by `Number()`, - * must be finite + * coerced (#20309); a number must be finite, and a string + * must be one the spec's numeric grammar reads + * (`parseNumericString`) — stored as that number * - `min` / `max` (number/currency/percent/rating/slider/progress — `progress` * since #20386; it takes neither `scale` nor `precision`) * - `scale` more decimal places than the field's STORED allowance → @@ -81,6 +82,7 @@ import { COMPUTED_VALUE_TYPES, NON_TEXT_STORED_VALUE_TYPES, percentScaleOf, + parseNumericString, } from '@objectstack/spec/data'; import type { FieldErrorCode } from '@objectstack/spec/api'; import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared'; @@ -658,6 +660,96 @@ function normalizeBlankTypedRow(fields: Record, row: unknown): return out ?? row; } +/** + * [#20309] The declared types the record validator's number arm judges: the + * spec's numeric class minus its server-computed class, both read as constants. + * One predicate for the arm and for {@link normalizeNumericStringValues}, so + * what is judged and what is rewritten cannot drift apart. + */ +function isJudgedNumberType(type: string): boolean { + return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type); +} + +/** + * [#20309] A STRING on a number-typed field that the platform's numeric grammar + * reads is written as the NUMBER it denotes — so what the record validator's + * number arm judges is what the driver stores. + * + * The grammar is the spec's one, `parseNumericString` (`@objectstack/spec/data`, + * #20336): a JSON number literal naming a finite double. The filter door + * narrows a comparand by the same reading. ⛔ No second grammar here: its case + * table (`NUMERIC_STRING_GRAMMAR_CASES`) decides hex, padded, exponent and + * every other form, and this function pre-decides none of them. + * + * "Number-typed" is exactly what the arm judges ({@link isJudgedNumberType}), + * on exactly the fields `validateRecord` walks: never a `SKIP_FIELDS` name, a + * `system` or a `readonly` field. A value nobody judges is not rewritten. + * + * ## Why the door has to say it + * + * The arm judged `Number(value)` while the write carried `value`, so an + * accepted string reached the driver as sent: memory stored `'12'` and read it + * back as the string `'12'`, while SQLite's column affinity stored the plain + * forms as numbers but kept `'0x10'` as TEXT (read back as 16). One write, two + * stored shapes. A shipped producer sends numeric strings — objectui's CSV + * import legacy per-row fallback posts the raw cell — so the census answer on + * #20309 accepts the grammar's strings and stores their number rather than + * refusing every string. + * + * ## What it does NOT touch + * + * ⛔ A string the grammar does not read: it stays as sent, and the number arm + * refuses it with `invalid_number`. (A blank never reaches here as a string on + * these types: {@link normalizeBlankTypedValues} made it `null` first.) ⛔ Every + * non-string value, of any type. ⛔ `summary` and the other computed types, + * whose value's shape is their producer's (the seat ruling on #20308). + * + * ## Where it runs + * + * Beside {@link normalizeBlankTypedValues}, at the same three points of + * `ObjectQL` — `insert()`, `update()` and `validate()` (the dry run) — before + * anything reads the payload, so the middleware, the caller snapshots, the + * hooks, the `readonlyWhen` locks and the validator all see the number. Every + * REST, batch and import door reaches the engine through those methods. ⛔ No + * driver copy. A value a `before*` hook writes after the door is the hook's + * own and is not rewritten; the arm still judges it by the same grammar. + * + * Same contract as {@link normalizeBlankTypedValues}: one record or an array of + * them, pure — the same reference comes back when nothing changed, else a + * shallow copy (per row, and a copied array). + */ +export function normalizeNumericStringValues( + objectSchema: { fields?: Record } | undefined | null, + data: T, +): T { + const fields = objectSchema?.fields; + if (!fields || !data || typeof data !== 'object') return data; + if (Array.isArray(data)) { + let rows: unknown[] | undefined; + for (let i = 0; i < data.length; i++) { + const row = normalizeNumericStringRow(fields, data[i]); + if (row !== data[i]) (rows ??= data.slice())[i] = row; + } + return (rows ?? data) as T; + } + return normalizeNumericStringRow(fields, data) as T; +} + +function normalizeNumericStringRow(fields: Record, row: unknown): unknown { + if (!isPlainRecord(row)) return row; + let out: Record | undefined; + for (const [name, value] of Object.entries(row)) { + if (typeof value !== 'string' || SKIP_FIELDS.has(name)) continue; + // Own-property: a field name may be `constructor` / `valueOf`. + const def = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined; + if (!def || def.system || def.readonly || !isJudgedNumberType(def.type)) continue; + const n = parseNumericString(value); + if (n === undefined) continue; + (out ??= { ...row })[name] = n; + } + return out ?? row; +} + /** * Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`, * or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a @@ -930,17 +1022,23 @@ function validateOne( // to parse, so the arm refuses it and never silently alters it (the #7501 // posture). A number is judged as itself and written as itself. // - // ⛔ A STRING is still judged by `Number()` and written as sent, exactly as - // before this change. Which strings a number field accepts is a separate - // decision: it waits on the producer census and on the platform's one - // numeric grammar, which belongs to `@objectstack/spec` (#20336), never to a - // second copy here. - if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) { + // [#20309] A STRING is judged by the platform's one numeric grammar, + // `parseNumericString` (`@objectstack/spec/data`, #20336), never by + // `Number()` and ⛔ never by a second grammar here. `Number()` also read a + // radix literal (`'0x10'`), a whitespace-padded one (`' 12 '`) and the + // non-JSON spellings `'+5'` / `'.5'` / `'5.'` / `'007'` as finite, so those + // were accepted and are now `invalid_number`; the grammar's case table + // decides every form. An admitted string is judged as the number it denotes, + // and `normalizeNumericStringValues` has already written that number into + // the payload at the door, so the driver stores what was judged. `min`, + // `max`, `scale` and `precision` below read that number, as they read a + // number. + if (isJudgedNumberType(t)) { if (typeof value !== 'number' && typeof value !== 'string') { return fail('invalid_number'); } - const n = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(n)) { + const n = typeof value === 'number' ? value : parseNumericString(value); + if (n === undefined || !Number.isFinite(n)) { return fail('invalid_number'); } // `min` / `max` bind on every type through this door, `progress` included. From 94e06922f352fefcb0dc8453195c539ff047c4b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:12:41 +0000 Subject: [PATCH 2/4] 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 --- .../src/engine-number-value-door.test.ts | 88 ++++++- .../record-validator.number-value.test.ts | 240 ++++++++++++++++-- .../rest/src/rest-data-number-value.test.ts | 85 ++++++- 3 files changed, 392 insertions(+), 21 deletions(-) diff --git a/packages/objectql/src/engine-number-value-door.test.ts b/packages/objectql/src/engine-number-value-door.test.ts index 30c284095df..c80f13cc311 100644 --- a/packages/objectql/src/engine-number-value-door.test.ts +++ b/packages/objectql/src/engine-number-value-door.test.ts @@ -15,11 +15,18 @@ * (`Object.is`), so for a number what the arm judged is what the driver * receives. Memory and MongoDB store exactly this payload. The SQL physical * column is pinned in `packages/rest/src/rest-data-number-value.test.ts`. - * A string is not judged differently here: that half waits on #20336. + * + * The string half (#20309, second part): a string the spec's numeric grammar + * reads (`parseNumericString`) reaches the driver as the NUMBER it denotes on + * every door, and every stage between the door and the driver (a `before*` + * hook, the dry run) sees that number. Measured on `origin/main` 851af0c27 + * before it: memory stored `'12'` as the string `'12'` and read it back as a + * string, and `'0x10'` / `' 12 '` / `'+5'` were accepted and stored as sent. A + * string the grammar does not read is refused and never reaches the driver. */ import { describe, it, expect, beforeEach } from 'vitest'; -import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +import { COMPUTED_VALUE_TYPES, NUMERIC_STRING_GRAMMAR_CASES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; import { ValidationError } from './validation/record-validator.js'; @@ -155,3 +162,80 @@ describe('engine write doors: the number arm judges what the driver receives (#2 } }); }); + +/** The spec grammar's own verdicts (#20336): admitted strings with their number, and refused non-blank strings. */ +const ADMITTED = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (c.numeric ? [[JSON.stringify(c.input), c.input, c.value] as const] : [])); +const REFUSED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (!c.numeric && c.form !== 'empty' ? [[JSON.stringify(c.input), c.input] as const] : [])); + +describe('engine write doors: a numeric string reaches the driver as its number (#20309, the string half)', () => { + let engine: ObjectQL; + let stub: ReturnType; + + beforeEach(async () => { + stub = makeStubDriver(); + engine = new ObjectQL(); + engine.registerDriver(stub.driver, true); + await engine.init(); + engine.registry.registerObject(OBJ as any); + }); + + const written = (field: string) => + stub.calls.flatMap((c) => c.rows).filter((r) => field in r).map((r) => r[field]); + + it('CONTROL: the grammar table has both halves', () => { + expect(ADMITTED.length).toBeGreaterThanOrEqual(10); + expect(REFUSED_STRINGS.length).toBeGreaterThanOrEqual(20); + }); + + describe.each(JUDGED)('%s', (type) => { + it.each(ADMITTED)('%s arrives as the number on insert, insert([...]), insertMany, update by id and update by predicate', async (_l, input, value) => { + await engine.insert('num_door', { id: 'seed', [f(type)]: 1 }); + stub.calls.length = 0; + const caller = { id: 'a', [f(type)]: input }; + await engine.insert('num_door', caller); + await engine.insert('num_door', [{ id: 'b', [f(type)]: input }]); + const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: input }]); + expect(outcomes.map((o) => o.ok)).toEqual([true]); + await engine.update('num_door', { id: 'seed', [f(type)]: input }); + await engine.update('num_door', { [f(type)]: input }, { where: { id: { $in: ['seed'] } }, multi: true } as any); + + const got = written(f(type)); + expect(got).toHaveLength(5); + for (const g of got) expect(Object.is(g, value), `${JSON.stringify(input)} -> ${String(g)}`).toBe(true); + // The rewrite is copy-on-write: the caller's object still holds its string. + expect(caller[f(type)]).toBe(input); + }); + + it.each(REFUSED_STRINGS)('%s is refused on every door and never reaches the driver', async (_l, input) => { + await engine.insert('num_door', { id: 'seed', [f(type)]: 1 }); + stub.calls.length = 0; + const expected = { code: 'VALIDATION_FAILED', fields: [[f(type), 'invalid_number']] }; + + expect(await refusal(() => engine.insert('num_door', { id: 'a', [f(type)]: input }))).toEqual(expected); + expect(await refusal(() => engine.insert('num_door', [{ id: 'b', [f(type)]: input }]))).toEqual(expected); + expect(await refusal(() => engine.update('num_door', { id: 'seed', [f(type)]: input }))).toEqual(expected); + expect(await refusal(() => engine.update('num_door', { [f(type)]: input }, { where: { id: { $in: ['seed'] } }, multi: true } as any))).toEqual(expected); + const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: input }]); + expect(outcomes.map((o) => o.ok)).toEqual([false]); + + expect(written(f(type))).toEqual([]); + }); + }); + + it('a before-hook sees the number, on insert and on update: every stage after the door reads one image', async () => { + const seen: Array<[string, unknown]> = []; + engine.registerHook('beforeInsert', async (ctx: any) => { seen.push(['insert', ctx.input.data.f_number]); }, { object: 'num_door' }); + engine.registerHook('beforeUpdate', async (ctx: any) => { seen.push(['update', ctx.input.data.f_number]); }, { object: 'num_door' }); + await engine.insert('num_door', { id: 'h1', f_number: '12.5' }); + await engine.update('num_door', { id: 'h1', f_number: '-3' }); + expect(seen).toEqual([['insert', 12.5], ['update', -3]]); + }); + + it('the dry run agrees with the write on a string', async () => { + const refused = await engine.validate('num_door', { id: 'p1', f_number: '0x10' }); + expect(refused.valid).toBe(false); + expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['f_number', 'invalid_number']]); + expect((await engine.validate('num_door', { id: 'p2', f_number: '12' })).valid).toBe(true); + expect((await engine.validate('num_door', { id: 'p3', f_number: ' 12 ' })).valid).toBe(false); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.number-value.test.ts b/packages/objectql/src/validation/record-validator.number-value.test.ts index f343ff45255..3e92258ff72 100644 --- a/packages/objectql/src/validation/record-validator.number-value.test.ts +++ b/packages/objectql/src/validation/record-validator.number-value.test.ts @@ -27,10 +27,18 @@ * - The controls: a finite number passes, including `0` and a negative; a * blank is still `null` before the arm (#20308); `summary` is still not * judged (the seat ruling on #20308). - * - The STRING half is unchanged: a string is still judged by `Number()`, as - * at base. Which strings a number field accepts waits on the producer census - * and the spec's numeric grammar (#20336). The characterization below turns - * red when that half lands, on purpose. + * - The STRING half (the second part of #20309): a string is judged by the + * spec's one numeric grammar, `parseNumericString`, driven here through its + * own case table `NUMERIC_STRING_GRAMMAR_CASES` — never a list of this + * file's. Before it, the arm read a string by `Number()` and the write + * carried the string: memory stored `'12'` as the string `'12'`, SQLite + * stored `'0x10'` as TEXT. An admitted string is now judged as its number + * and `normalizeNumericStringValues` writes that number into the payload; a + * string the grammar does not read is `invalid_number`. The strings + * `Number()` read as finite and the grammar refuses are the narrowing, named + * below. + * - `min`, `max`, `scale` and `precision` read the parsed number: a string and + * the number it denotes get the same answer. * * The driver-facing half (what reaches the driver on each engine door) is * `../engine-number-value-door.test.ts`. The physical column, through REST on @@ -38,8 +46,19 @@ */ import { describe, it, expect } from 'vitest'; -import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; -import { normalizeBlankTypedValues, validateRecord, ValidationError } from './record-validator.js'; +import { + COMPUTED_VALUE_TYPES, + NUMERIC_STRING_GRAMMAR_CASES, + NUMERIC_VALUE_TYPES, + parseNumericString, + valueSchemaFor, +} from '@objectstack/spec/data'; +import { + normalizeBlankTypedValues, + normalizeNumericStringValues, + validateRecord, + ValidationError, +} from './record-validator.js'; const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t)); @@ -120,16 +139,6 @@ describe('the number arm: an array, boolean or object is invalid_number (#20309) } }); - it('UNCHANGED here: a string is still judged by Number(), as at base (the string half waits on #20336)', () => { - // A characterization, not an endorsement: the spec's stored value schema - // refuses every one of these. It turns red when the string half lands. - for (const type of JUDGED) { - for (const s of ['12', '12.5', '0x10', ' 12 ', '1e3']) { - expect(answer(type, s, 'insert'), `${type} ${JSON.stringify(s)}`).toBeNull(); - } - } - }); - it('CONTROL: a blank is still null before the arm, so it is never judged (#20308)', () => { for (const blank of ['', ' ']) { 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) } }); }); + +// ── The string half (#20309): the spec's numeric grammar ───────────────────── + +/** The grammar's own table, split by its own verdict. Blank rows are the blank + * rule's (#20308): they never reach the arm as a string on these types. */ +const ADMITTED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.filter((c) => c.numeric); +const REFUSED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.filter((c) => !c.numeric && c.form !== 'empty'); +const BLANK_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.filter((c) => !c.numeric && c.form === 'empty'); + +/** The rewrite, then the validator: the two halves of the write door, in order. */ +function doorAnswer(type: string, value: unknown, mode: 'insert' | 'update') { + const row = normalizeNumericStringValues(schemaOf([type]), { [`f_${type}`]: value }); + return answer(type, row[`f_${type}`], mode); +} + +describe('the number arm reads a string by the spec numeric grammar (#20309, the string half)', () => { + it('CONTROL: the case table has both halves and every non-blank form, so nothing below passes over nothing', () => { + expect(ADMITTED_STRINGS.length).toBeGreaterThanOrEqual(10); + expect(new Set(REFUSED_STRINGS.map((c) => (c.numeric ? '' : c.form)))).toEqual(new Set([ + 'padded', 'placeholder', 'radix-prefix', 'non-finite', 'digit-separator', 'non-json-spelling', 'not-a-number', + ])); + expect(BLANK_STRINGS.length).toBeGreaterThan(0); + }); + + describe.each(JUDGED)('%s', (type) => { + it.each(ADMITTED_STRINGS.map((c) => [JSON.stringify(c.input), c.input] as const))( + 'admits %s on insert and update, straight to the arm and through the door rewrite', + (_l, input) => { + for (const mode of ['insert', 'update'] as const) { + expect(answer(type, input, mode), mode).toBeNull(); + expect(doorAnswer(type, input, mode), mode).toBeNull(); + } + }, + ); + + it.each(REFUSED_STRINGS.map((c) => [JSON.stringify(c.input), c.numeric ? '' : c.form, c.input] as const))( + 'refuses %s (%s) with invalid_number on insert and update, straight to the arm and through the door rewrite', + (_l, _form, input) => { + const expected = [[`f_${type}`, 'invalid_number']]; + for (const mode of ['insert', 'update'] as const) { + expect(answer(type, input, mode), mode).toEqual(expected); + expect(doorAnswer(type, input, mode), mode).toEqual(expected); + } + }, + ); + }); + + it('the arm accepts a string exactly when parseNumericString reads it — no second grammar', () => { + const probes = [ + ...NUMERIC_STRING_GRAMMAR_CASES.map((c) => c.input), + // Beyond the table, to catch a private reading that happens to agree on it. + '-0.0', '1E3', '1.e3', '-.5', '00', '0x', '١٢', '12 ', ' ', ' 12', '1e+', '9007199254740993', + ].filter((s) => s.trim() !== ''); + for (const type of JUDGED) { + for (const s of probes) { + const read = parseNumericString(s) !== undefined; + expect(answer(type, s, 'insert') === null, `${type} ${JSON.stringify(s)}`).toBe(read); + } + } + }); + + it('NAMED NARROWING: the strings Number() read as finite that the grammar refuses, each now invalid_number', () => { + // What the old arm (`Number(value)` finite) admitted and the grammar does + // not: the BREAKING set, read off the spec table rather than listed here + // as a claim — the literal below pins that the table's set is what the + // changeset names. + const narrowed = NUMERIC_STRING_GRAMMAR_CASES + .filter((c) => !c.numeric && c.form !== 'empty' && Number.isFinite(Number(c.input))) + .map((c) => c.input); + expect(narrowed).toEqual([' 12 ', '12\n', '\t-3', '0x10', '0X1A', '0o17', '0b101', '+5', '.5', '5.', '007']); + for (const type of JUDGED) { + for (const s of narrowed) expect(answer(type, s, 'insert'), `${type} ${JSON.stringify(s)}`).toEqual([[`f_${type}`, 'invalid_number']]); + } + }); + + it('CONTROL: the table\'s blank rows never reach the arm — the blank rule makes them null first (#20308)', () => { + for (const c of BLANK_STRINGS) { + const row = normalizeBlankTypedValues(schemaOf(JUDGED), Object.fromEntries(JUDGED.map((t) => [`f_${t}`, c.input]))); + for (const t of JUDGED) expect((row as Record)[`f_${t}`], `${t} ${JSON.stringify(c.input)}`).toBeNull(); + // …and the numeric rewrite leaves a blank for the blank rule: it reads no number. + const untouched = { f_number: c.input }; + expect(normalizeNumericStringValues(schemaOf(['number']), untouched)).toBe(untouched); + } + }); +}); + +describe('normalizeNumericStringValues: an admitted string is written as its number (#20309)', () => { + it('writes every admitted row of the grammar table as the table\'s own number, on every judged type', () => { + for (const type of JUDGED) { + for (const c of ADMITTED_STRINGS) { + if (!c.numeric) continue; + const out = normalizeNumericStringValues(schemaOf([type]), { [`f_${type}`]: c.input }); + expect(Object.is(out[`f_${type}`], c.value), `${type} ${JSON.stringify(c.input)} -> ${String(out[`f_${type}`])}`).toBe(true); + } + } + }); + + it('what it writes is the spec\'s stored value for the type, and the arm accepts it: one value judged and stored', () => { + for (const type of JUDGED) { + const stored = valueSchemaFor({ type }, 'stored'); + for (const c of ADMITTED_STRINGS) { + const out = normalizeNumericStringValues(schemaOf([type]), { [`f_${type}`]: c.input }); + expect(stored.safeParse(out[`f_${type}`]).success, `${type} ${JSON.stringify(c.input)}`).toBe(true); + expect(answer(type, out[`f_${type}`], 'insert'), `${type} ${JSON.stringify(c.input)}`).toBeNull(); + } + } + }); + + it('leaves every string the grammar refuses exactly as sent (the same reference back), so the arm refuses it', () => { + for (const type of JUDGED) { + for (const c of REFUSED_STRINGS) { + const row = { [`f_${type}`]: c.input }; + expect(normalizeNumericStringValues(schemaOf([type]), row), `${type} ${JSON.stringify(c.input)}`).toBe(row); + } + } + }); + + 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', () => { + const schema = { + fields: { + id: { name: 'id', type: 'number' }, + created_at: { name: 'created_at', type: 'number' }, + f_text: { name: 'f_text', type: 'text' }, + f_summary: { name: 'f_summary', type: 'summary' }, + f_formula: { name: 'f_formula', type: 'formula' }, + f_system: { name: 'f_system', type: 'number', system: true }, + f_readonly: { name: 'f_readonly', type: 'number', readonly: true }, + f_number: { name: 'f_number', type: 'number' }, + }, + } as any; + const row = { + id: '12', created_at: '12', f_text: '12', f_summary: '12', f_formula: '12', f_system: '12', f_readonly: '12', + f_undeclared: '12', f_number: 12, + }; + expect(normalizeNumericStringValues(schema, row)).toBe(row); + // CONTROL: the same schema does rewrite its judged field when it is a string. + expect(normalizeNumericStringValues(schema, { ...row, f_number: '12' }).f_number).toBe(12); + }); + + it('is pure: the caller\'s record is never mutated; one record or an array of them, copied only where changed', () => { + const schema = schemaOf(['number']); + const a = { f_number: '12' }; + const b = { f_number: 7 }; + const one = normalizeNumericStringValues(schema, a); + expect(one).not.toBe(a); + expect(one).toEqual({ f_number: 12 }); + expect(a).toEqual({ f_number: '12' }); + + const list = [a, b]; + const out = normalizeNumericStringValues(schema, list); + expect(out).not.toBe(list); + expect(out[0]).toEqual({ f_number: 12 }); + expect(out[1]).toBe(b); + expect(list[0]).toBe(a); + + const clean = [b]; + expect(normalizeNumericStringValues(schema, clean)).toBe(clean); + // A field named after an Object.prototype member is looked up as an own property. + const proto = { fields: { valueOf: { name: 'valueOf', type: 'number' } } } as any; + expect(normalizeNumericStringValues(proto, { valueOf: '3' })).toEqual({ valueOf: 3 }); + expect(normalizeNumericStringValues(schema, { constructor: '3' })).toEqual({ constructor: '3' }); + }); +}); + +describe('bounds, scale and precision read the parsed number: a string answers as its number does (#20309)', () => { + const field = (type: string, extra: Record) => ({ fields: { f: { name: 'f', type, ...extra } } }) as any; + /** Every field-level error, whole — code, constraint and message. */ + const whole = (schema: any, value: unknown) => { + try { validateRecord(schema, { f: value }, 'insert'); return null; } + catch (e) { expect(e).toBeInstanceOf(ValidationError); return (e as ValidationError).fields; } + }; + + const CASES: ReadonlyArray, string, string | null]> = [ + ['12.50 under scale 1', 'number', { scale: 1 }, '12.50', null], + ['12.55 under scale 1', 'number', { scale: 1 }, '12.55', 'max_scale'], + ['1e-7 under scale 2', 'number', { scale: 2 }, '1e-7', 'max_scale'], + ['0.10 under scale 1', 'number', { scale: 1 }, '0.10', null], + ['2.5E+3 under scale 0', 'number', { scale: 0 }, '2.5E+3', null], + ['3.5 on a rating under scale 0', 'rating', { scale: 0 }, '3.5', 'max_scale'], + ['3 on a rating under scale 0', 'rating', { scale: 0 }, '3', null], + ['150 over max 100', 'number', { max: 100 }, '150', 'max_value'], + ['-5 under min 0', 'number', { min: 0 }, '-5', 'min_value'], + ['150 over a progress max 100', 'progress', { max: 100 }, '150', 'max_value'], + ['1234.5 over precision 5 at scale 2', 'number', { precision: 5, scale: 2 }, '1234.5', 'max_precision'], + ['999.99 at precision 5, scale 2', 'number', { precision: 5, scale: 2 }, '999.99', null], + ['0.1234 on a fraction percent at scale 2', 'percent', { scale: 2 }, '0.1234', null], + ['0.12345 on a fraction percent at scale 2', 'percent', { scale: 2 }, '0.12345', 'max_scale'], + ]; + + it.each(CASES)('%s', (_l, type, extra, input, code) => { + const schema = field(type, extra); + const asString = whole(schema, input); + expect(asString?.map((x) => x.code) ?? null).toEqual(code === null ? null : [code]); + // The same answer, byte for byte, as the number the string denotes. + expect(asString).toEqual(whole(schema, parseNumericString(input))); + // And the door writes that number. + expect(normalizeNumericStringValues(schema, { f: input }).f).toBe(parseNumericString(input)); + }); +}); diff --git a/packages/rest/src/rest-data-number-value.test.ts b/packages/rest/src/rest-data-number-value.test.ts index e1209fc83e1..3af3aacf479 100644 --- a/packages/rest/src/rest-data-number-value.test.ts +++ b/packages/rest/src/rest-data-number-value.test.ts @@ -18,12 +18,20 @@ * * Controls: `[5, 7]` and `{}` were already refused and still are; a JS number * is stored as a SQLite `real` (`integer` on `rating`) and read back - * unchanged; a blank is still stored as `null` (#20308). A string is not - * judged differently here: that half waits on #20336. + * unchanged; a blank is still stored as `null` (#20308). + * + * The string half (#20309, second part), measured on `origin/main` 851af0c27 + * with this harness before it: `'0x10'` answered 201 and SQLite stored the TEXT + * `'0x10'` (read back as 16), and `' 12 '`, `'+5'`, `'.5'`, `'5.'`, `'007'` + * answered 201 and were stored as numbers by the column's affinity. A string + * is now read by the spec's numeric grammar (`parseNumericString`): an + * admitted one is stored as its number (a SQLite `real`, `integer` on + * `rating`), one the grammar refuses answers `400` / `invalid_number` and + * nothing is written. The grammar's own case table drives both halves. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +import { COMPUTED_VALUE_TYPES, NUMERIC_STRING_GRAMMAR_CASES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; @@ -153,3 +161,74 @@ describe('REST write doors on SQLite: a number field refuses a non-number (#2030 for (const t of JUDGED) expect(await ctx.cell('e1', f(t)), t).toEqual({ v: null, c: 'null' }); }); }); + +/** The spec grammar's own verdicts (#20336), blank rows aside (#20308 owns them). */ +const ADMITTED = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (c.numeric ? [[JSON.stringify(c.input), c.input, c.value] as const] : [])); +const REFUSED_STRINGS = NUMERIC_STRING_GRAMMAR_CASES.flatMap((c) => (!c.numeric && c.form !== 'empty' ? [[JSON.stringify(c.input), c.input] as const] : [])); + +describe('REST write doors on SQLite: a number field reads a string by the spec numeric grammar (#20309)', () => { + let ctx: Awaited>; + beforeEach(async () => { ctx = await boot(); }); + + const expectRefused = (res: { status: number; body: any }, field: string) => { + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res.body.fields.map((x: any) => [x.field, x.code])).toEqual([[field, 'invalid_number']]); + }; + const expectRowRefused = (res: { status: number; body: any }) => { + expect(res.body.results.map((r: any) => [r.success, r.errors?.[0]?.code])).toEqual([[false, 'VALIDATION_FAILED']]); + }; + const expectRowOk = (res: { status: number; body: any }) => { + expect(res.body.results.map((r: any) => r.success)).toEqual([true]); + }; + /** SQLite's storage class for a stored number: an integral value in the `rating` column is `integer`, + * unless it is past the 64-bit integer range (`1e21`), which stays `real`. */ + const classOf = (type: string, n: number) => (type === 'rating' && Number.isSafeInteger(n) ? 'integer' : 'real'); + + describe.each(JUDGED)('%s', (type) => { + const col = f(type); + + it.each(ADMITTED)('%s: POST, batch create, PATCH, batch update and updateMany store the number', async (_l, input, value) => { + // `-0` is stored as the number 0: SQLite keeps no negative zero. + const expected = { v: Object.is(value, -0) ? 0 : value, c: classOf(type, value) }; + + expect((await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 's1', [col]: input })).status).toBe(201); + expect(await ctx.cell('s1', col)).toEqual(expected); + + expectRowOk(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'create', records: [{ data: { id: 's2', [col]: input } }] })); + expect(await ctx.cell('s2', col)).toEqual(expected); + + for (const [door, send] of [ + ['PATCH', (id: string) => ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id }, { [col]: input })], + ['batch update', (id: string) => ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'update', records: [{ id, data: { [col]: input } }] })], + ['updateMany', (id: string) => ctx.call('POST', '/api/v1/data/:object/updateMany', { object: 'num_rest' }, + { records: [{ id, data: { [col]: input } }] })], + ] as const) { + const id = `u_${door.replace(' ', '_')}`; + await ctx.engine.insert('num_rest', { id, [col]: 7 }); + const res = await send(id); + expect(res.status, door).toBeLessThan(300); + expect(await ctx.cell(id, col), door).toEqual(expected); + } + }); + + it.each(REFUSED_STRINGS)('%s: POST and batch create write no row; PATCH, batch update and updateMany leave the stored number', async (_l, input) => { + expectRefused(await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'c1', [col]: input }), col); + expect(await ctx.cell('c1', col)).toBeUndefined(); + + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'create', records: [{ data: { id: 'c2', [col]: input } }] })); + expect(await ctx.cell('c2', col)).toBeUndefined(); + + await ctx.engine.insert('num_rest', { id: 'u1', [col]: 7 }); + expectRefused(await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id: 'u1' }, { [col]: input }), col); + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'update', records: [{ id: 'u1', data: { [col]: input } }] })); + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/updateMany', { object: 'num_rest' }, + { records: [{ id: 'u1', data: { [col]: input } }] })); + expect(await ctx.cell('u1', col)).toEqual({ v: 7, c: type === 'rating' ? 'integer' : 'real' }); + }); + }); +}); From 03580e7cb25d681e30c37d6ff09fd481be91f8cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:13:22 +0000 Subject: [PATCH 3/4] chore(changeset): objectql minor, BREAKING, for the number arm's string half (#20309) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- ...20309-number-arm-numeric-string-grammar.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .changeset/20309-number-arm-numeric-string-grammar.md diff --git a/.changeset/20309-number-arm-numeric-string-grammar.md b/.changeset/20309-number-arm-numeric-string-grammar.md new file mode 100644 index 00000000000..49fb387af1e --- /dev/null +++ b/.changeset/20309-number-arm-numeric-string-grammar.md @@ -0,0 +1,88 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: a number, currency, percent, rating, slider or progress field reads a string by the platform's numeric grammar and stores the number it denotes (#20309) + +Clause-②: no (narrowing) + +**BREAKING**: shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner and the ADR-0087 disposition below, never by the level). The +narrowing: a string that `Number()` reads as a finite number but the platform's +numeric grammar does not is now refused with `400 VALIDATION_FAILED` / +`invalid_number`. It used to be accepted. + +**FROM → TO, and the one-line fix.** These strings, written to one of those +fields, were accepted and are now refused, with nothing written: + +- a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`; +- a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`; +- a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`. + +Fix: send a JS number, or the number's plain JSON spelling — `'16'`, `'12'`, +`'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any finite number always +qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). + +## What was wrong + +This is the separate change the earlier #20309 note (arrays, booleans and +objects refused) left open. The record validator judged a string by `Number()` +while the write carried the string itself, so an accepted string reached the +driver as sent: + +- **memory** stored `'12'` as the string `'12'` and read it back as a string; +- **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the + other accepted strings as numbers through the column's affinity. + +One write, two stored shapes, depending on the backend. + +## What changes + +- A string is judged by `parseNumericString` from `@objectstack/spec/data`, the + one numeric grammar the filter door also reads: the whole string is a JSON + number literal naming a finite double. Its case table, + `NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in + the engine. +- An admitted string is stored as the number it denotes, on every backend: + `'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write + door, before the middleware, the hooks, the `readonlyWhen` locks and + validation read the payload, so a `before*` hook now sees the number. The + caller's own object is not mutated. +- `min`, `max`, `scale` and `precision` read that number, exactly as they read + a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over + `max: 100` is `max_value`. +- This holds on every engine, REST, batch and updateMany door, and in + `validate` (the dry run). The server `/import` route is unchanged: its own + cell reader turns a numeric cell into a number before the write, so the + grammar never sees a string from it. +- A blank is still `null` before the check (#20308). `summary` is still not + judged. A number, and an array, boolean or object, are answered as before. + +## Who sends numeric strings + +objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`, +used only when the connected client cannot reach the server `/import` route), +posts each raw cell to `create` after a client check of +`!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can +send the radix literals and the non-JSON spellings. Those rows now fail with +`invalid_number` instead of storing a string. The fix there is the wizard's +default path: import through the server `/import` route, whose cell reader +converts the number before the write. Every interactive form widget sends a JS +number or `null`, and is unaffected. + +## Rows already stored + +This judges new writes only; a stored value is never re-read by the check. On +memory, an accepted string stayed a string until the record is next written. +On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT +(such as `'0x10'`): + +```sql +SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; +``` + +OBJECT is the object name and FIELD is the field name. Nothing here rewrites +such a cell; decide its number by hand. + + From b78c66612e426ba89d2d86008e8ac1320cc119bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:14:25 +0000 Subject: [PATCH 4/4] chore(changeset): word the caller-facing change as before -> after, as the sibling value narrowings do Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../20309-number-arm-numeric-string-grammar.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.changeset/20309-number-arm-numeric-string-grammar.md b/.changeset/20309-number-arm-numeric-string-grammar.md index 49fb387af1e..60a7169a9fc 100644 --- a/.changeset/20309-number-arm-numeric-string-grammar.md +++ b/.changeset/20309-number-arm-numeric-string-grammar.md @@ -13,16 +13,20 @@ narrowing: a string that `Number()` reads as a finite number but the platform's numeric grammar does not is now refused with `400 VALIDATION_FAILED` / `invalid_number`. It used to be accepted. -**FROM → TO, and the one-line fix.** These strings, written to one of those -fields, were accepted and are now refused, with nothing written: +**What a caller sees, before → after.** One of these strings written to one of +those fields: `201`, stored as sent (memory kept the string; SQLite kept +`'0x10'` as TEXT and the others as numbers by column affinity) → `400 +VALIDATION_FAILED` with the field code `invalid_number`, nothing stored. The +REST create, batch, update and updateMany routes all answer it, and `validate` +(the dry run) predicts it. The forms: - a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`; - a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`; - a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`. -Fix: send a JS number, or the number's plain JSON spelling — `'16'`, `'12'`, -`'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any finite number always -qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). +The fix, when a write is refused: send a JS number, or the number's plain JSON +spelling — `'16'`, `'12'`, `'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any +finite number always qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). ## What was wrong