Skip to content

Commit c67623f

Browse files
committed
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 <noreply@anthropic.com>
1 parent 851af0c commit c67623f

2 files changed

Lines changed: 123 additions & 15 deletions

File tree

‎packages/objectql/src/engine.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container'
219219
// registrar and `os validate` both call.
220220
import { viewContainerNameRefusal } from './view-container-name-refusal.js';
221221
import { bindHooksToEngine } from './hook-binder.js';
222-
import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
222+
import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
223223
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';
224224
import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js';
225225
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 {
1174211742
// [#20308] The write doors read a blank on a non-string-typed column as
1174311743
// `null` before anything else; the preview does the same at the same point,
1174411744
// or a blank on a required field with a `defaultValue` would preview
11745-
// `required` while the write takes the default.
11746-
const rawRows = normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]);
11745+
// `required` while the write takes the default. [#20309] Likewise a
11746+
// numeric string on a number field is its number here, as on the write.
11747+
const rawRows = normalizeNumericStringValues(
11748+
schemaForValidation,
11749+
normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]),
11750+
);
1174711751
const nowSnapshot = new Date();
1174811752
// [#20082] The preview's ONE permission resolution, shared by its CEL
1174911753
// defaults and its option gates below, exactly as the write shares one. A
@@ -11914,8 +11918,11 @@ export class ObjectQL implements IObjectQLEngine {
1191411918
// validation read the payload, so all of them see one image (a blank then
1191511919
// takes a `defaultValue` exactly as `null` does). See
1191611920
// `normalizeBlankTypedValues` for the scope; it never mutates the caller's
11917-
// rows.
11921+
// rows. [#20309] At the same point, a string on a number field that the
11922+
// spec's numeric grammar reads becomes that number, so the validator judges
11923+
// the value the driver stores (`normalizeNumericStringValues`).
1191811924
data = normalizeBlankTypedValues(this._registry.getObject(object), data);
11925+
data = normalizeNumericStringValues(this._registry.getObject(object), data);
1191911926

1192011927
const opCtx: OperationContext = {
1192111928
object,
@@ -12959,8 +12966,11 @@ export class ObjectQL implements IObjectQLEngine {
1295912966
// non-string-typed column is `null` before the middleware, the
1296012967
// caller-value snapshot (`suppliedValues`), the hooks, the read-only
1296112968
// strips and validation read the payload — so a `readonlyWhen` lock judges
12962-
// the value it snapshotted. See `normalizeBlankTypedValues`.
12969+
// the value it snapshotted. See `normalizeBlankTypedValues`. [#20309] The
12970+
// insert door's numeric-string rewrite, same place and same reason (see
12971+
// `normalizeNumericStringValues`).
1296312972
data = normalizeBlankTypedValues(this._registry.getObject(object), data);
12973+
data = normalizeNumericStringValues(this._registry.getObject(object), data);
1296412974

1296512975
// 1. Extract ID from data or where if it's a single update by ID.
1296612976
// Only a SCALAR `where.id` means "update one row by primary key". An

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
* spec's shared `isValueDomainMember` — the WRITTEN value
2727
* only (#14168, maintainer ruling 2026-09-02 option A)
2828
* - number types an array, boolean or object is `invalid_number`, never
29-
* coerced (#20309); a number, or a string by `Number()`,
30-
* must be finite
29+
* coerced (#20309); a number must be finite, and a string
30+
* must be one the spec's numeric grammar reads
31+
* (`parseNumericString`) — stored as that number
3132
* - `min` / `max` (number/currency/percent/rating/slider/progress — `progress`
3233
* since #20386; it takes neither `scale` nor `precision`)
3334
* - `scale` more decimal places than the field's STORED allowance →
@@ -81,6 +82,7 @@ import {
8182
COMPUTED_VALUE_TYPES,
8283
NON_TEXT_STORED_VALUE_TYPES,
8384
percentScaleOf,
85+
parseNumericString,
8486
} from '@objectstack/spec/data';
8587
import type { FieldErrorCode } from '@objectstack/spec/api';
8688
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
@@ -658,6 +660,96 @@ function normalizeBlankTypedRow(fields: Record<string, FieldDef>, row: unknown):
658660
return out ?? row;
659661
}
660662

663+
/**
664+
* [#20309] The declared types the record validator's number arm judges: the
665+
* spec's numeric class minus its server-computed class, both read as constants.
666+
* One predicate for the arm and for {@link normalizeNumericStringValues}, so
667+
* what is judged and what is rewritten cannot drift apart.
668+
*/
669+
function isJudgedNumberType(type: string): boolean {
670+
return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type);
671+
}
672+
673+
/**
674+
* [#20309] A STRING on a number-typed field that the platform's numeric grammar
675+
* reads is written as the NUMBER it denotes — so what the record validator's
676+
* number arm judges is what the driver stores.
677+
*
678+
* The grammar is the spec's one, `parseNumericString` (`@objectstack/spec/data`,
679+
* #20336): a JSON number literal naming a finite double. The filter door
680+
* narrows a comparand by the same reading. ⛔ No second grammar here: its case
681+
* table (`NUMERIC_STRING_GRAMMAR_CASES`) decides hex, padded, exponent and
682+
* every other form, and this function pre-decides none of them.
683+
*
684+
* "Number-typed" is exactly what the arm judges ({@link isJudgedNumberType}),
685+
* on exactly the fields `validateRecord` walks: never a `SKIP_FIELDS` name, a
686+
* `system` or a `readonly` field. A value nobody judges is not rewritten.
687+
*
688+
* ## Why the door has to say it
689+
*
690+
* The arm judged `Number(value)` while the write carried `value`, so an
691+
* accepted string reached the driver as sent: memory stored `'12'` and read it
692+
* back as the string `'12'`, while SQLite's column affinity stored the plain
693+
* forms as numbers but kept `'0x10'` as TEXT (read back as 16). One write, two
694+
* stored shapes. A shipped producer sends numeric strings — objectui's CSV
695+
* import legacy per-row fallback posts the raw cell — so the census answer on
696+
* #20309 accepts the grammar's strings and stores their number rather than
697+
* refusing every string.
698+
*
699+
* ## What it does NOT touch
700+
*
701+
* ⛔ A string the grammar does not read: it stays as sent, and the number arm
702+
* refuses it with `invalid_number`. (A blank never reaches here as a string on
703+
* these types: {@link normalizeBlankTypedValues} made it `null` first.) ⛔ Every
704+
* non-string value, of any type. ⛔ `summary` and the other computed types,
705+
* whose value's shape is their producer's (the seat ruling on #20308).
706+
*
707+
* ## Where it runs
708+
*
709+
* Beside {@link normalizeBlankTypedValues}, at the same three points of
710+
* `ObjectQL` — `insert()`, `update()` and `validate()` (the dry run) — before
711+
* anything reads the payload, so the middleware, the caller snapshots, the
712+
* hooks, the `readonlyWhen` locks and the validator all see the number. Every
713+
* REST, batch and import door reaches the engine through those methods. ⛔ No
714+
* driver copy. A value a `before*` hook writes after the door is the hook's
715+
* own and is not rewritten; the arm still judges it by the same grammar.
716+
*
717+
* Same contract as {@link normalizeBlankTypedValues}: one record or an array of
718+
* them, pure — the same reference comes back when nothing changed, else a
719+
* shallow copy (per row, and a copied array).
720+
*/
721+
export function normalizeNumericStringValues<T>(
722+
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
723+
data: T,
724+
): T {
725+
const fields = objectSchema?.fields;
726+
if (!fields || !data || typeof data !== 'object') return data;
727+
if (Array.isArray(data)) {
728+
let rows: unknown[] | undefined;
729+
for (let i = 0; i < data.length; i++) {
730+
const row = normalizeNumericStringRow(fields, data[i]);
731+
if (row !== data[i]) (rows ??= data.slice())[i] = row;
732+
}
733+
return (rows ?? data) as T;
734+
}
735+
return normalizeNumericStringRow(fields, data) as T;
736+
}
737+
738+
function normalizeNumericStringRow(fields: Record<string, FieldDef>, row: unknown): unknown {
739+
if (!isPlainRecord(row)) return row;
740+
let out: Record<string, unknown> | undefined;
741+
for (const [name, value] of Object.entries(row)) {
742+
if (typeof value !== 'string' || SKIP_FIELDS.has(name)) continue;
743+
// Own-property: a field name may be `constructor` / `valueOf`.
744+
const def = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined;
745+
if (!def || def.system || def.readonly || !isJudgedNumberType(def.type)) continue;
746+
const n = parseNumericString(value);
747+
if (n === undefined) continue;
748+
(out ??= { ...row })[name] = n;
749+
}
750+
return out ?? row;
751+
}
752+
661753
/**
662754
* Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`,
663755
* or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a
@@ -930,17 +1022,23 @@ function validateOne(
9301022
// to parse, so the arm refuses it and never silently alters it (the #7501
9311023
// posture). A number is judged as itself and written as itself.
9321024
//
933-
// ⛔ A STRING is still judged by `Number()` and written as sent, exactly as
934-
// before this change. Which strings a number field accepts is a separate
935-
// decision: it waits on the producer census and on the platform's one
936-
// numeric grammar, which belongs to `@objectstack/spec` (#20336), never to a
937-
// second copy here.
938-
if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) {
1025+
// [#20309] A STRING is judged by the platform's one numeric grammar,
1026+
// `parseNumericString` (`@objectstack/spec/data`, #20336), never by
1027+
// `Number()` and ⛔ never by a second grammar here. `Number()` also read a
1028+
// radix literal (`'0x10'`), a whitespace-padded one (`' 12 '`) and the
1029+
// non-JSON spellings `'+5'` / `'.5'` / `'5.'` / `'007'` as finite, so those
1030+
// were accepted and are now `invalid_number`; the grammar's case table
1031+
// decides every form. An admitted string is judged as the number it denotes,
1032+
// and `normalizeNumericStringValues` has already written that number into
1033+
// the payload at the door, so the driver stores what was judged. `min`,
1034+
// `max`, `scale` and `precision` below read that number, as they read a
1035+
// number.
1036+
if (isJudgedNumberType(t)) {
9391037
if (typeof value !== 'number' && typeof value !== 'string') {
9401038
return fail('invalid_number');
9411039
}
942-
const n = typeof value === 'number' ? value : Number(value);
943-
if (!Number.isFinite(n)) {
1040+
const n = typeof value === 'number' ? value : parseNumericString(value);
1041+
if (n === undefined || !Number.isFinite(n)) {
9441042
return fail('invalid_number');
9451043
}
9461044
// `min` / `max` bind on every type through this door, `progress` included.

0 commit comments

Comments
 (0)