Skip to content

Commit 2b24b8b

Browse files
fix(objectql)!: a number field reads a string by the spec's numeric grammar and stores its number (#20309) (#20496)
Fixes #20309 Clause-②: no (narrowing) A number, currency, percent, rating, slider or progress field now reads a **string** by the platform's one numeric grammar, `parseNumericString` from `@objectstack/spec/data` (landed with PR #20414), instead of `Number()`-finite, and **stores an admitted string as the number it denotes**. A string the grammar does not read answers `400 VALIDATION_FAILED` / `invalid_number` on every write door, with nothing written. This is the card's string half. The non-string half (arrays, booleans, objects) landed as PR #20370 (`db74b169dc`), so this PR completes the card. Measured head: **`6bf61e75a`** (this branch after a true merge of `origin/main` `fc0db22bc`). ## What changes (read from the code at that head) - **`packages/objectql/src/validation/record-validator.ts`** - The number arm (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`, now spelled once as `isJudgedNumberType` and shared with the rewrite below) judges a string by `parseNumericString`. ⛔ No private grammar: the spec's case table `NUMERIC_STRING_GRAMMAR_CASES` decides hex, padded, exponent and every other form, and this PR pre-decides none of them. `min`, `max`, `scale` and `precision` read the parsed number. The existing code and message key (`invalid_number`) are reused. - New `normalizeNumericStringValues`, beside `normalizeBlankTypedValues` and with its contract (one record or an array of them; pure; the same reference back when nothing changed, else a shallow copy). An admitted string on a field the arm judges becomes its number. It touches only the fields `validateRecord` walks (never a `SKIP_FIELDS` name, a `system` or a `readonly` field), never `summary` or another computed type, never a non-string, and never a string the grammar refuses. - **`packages/objectql/src/engine.ts`**: the rewrite runs right after `normalizeBlankTypedValues` at its three call sites: `insert()`, `update()` (by id and by predicate) and `validate()` (the dry run). So the middleware, the caller snapshots, the hooks, the `readonlyWhen` locks and the validator all see the number. Nothing else in `engine.ts`. The blank rule and its `COMPUTED_VALUE_TYPES` exemption are untouched. - **Tests** (test side only): the three pin files of this card gain the string half, each driven by the spec's own `NUMERIC_STRING_GRAMMAR_CASES`. - **`.changeset/20309-number-arm-numeric-string-grammar.md`**: `@objectstack/objectql` `minor`, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, the disposition PR #20370's changeset took for this arm. ## Measured, base to head (H1, H3, H4) Instrument: a scratch script, not committed, booting the real `ObjectQL`, `ObjectStackProtocolImplementation` and `RestServer` from the built packages, once on `InMemoryDriver` and once on `SqlDriver` over better-sqlite3 in memory. Types: the six judged types. Doors: engine `insert`, `insertMany`, `update` by id, `update` by predicate; REST `POST /data/:object`, `createMany`, batch create, `PATCH /data/:object/:id`, batch update, `updateMany`, and `/import` (JSON rows). Each cell records the answer, the physical cell (memory's own store; on SQLite the column and its `typeof()`) and `engine.findOne`. Base `851af0c27` (the branch point), head `c67623f22` (the validator and engine code measured here is what `6bf61e75a` carries, plus the date arm that arrived from `main`). 20 inputs x 6 types x 11 doors x 2 drivers = **2640 cells**. | input | base, memory | base, SQLite | head, both drivers, every door but `/import` | |---|---|---|---| | `'12'`, `'12.5'`, `'-3'`, `'-0'`, `'0.10'`, `'1e3'` | accepted, **stored the string**, read back a string | accepted, stored a number by column affinity | accepted, **stored the number**; the SQLite cell is byte-identical to base | | `'0x10'` | accepted, stored the string | accepted, stored the **TEXT** `'0x10'`, read back as `16` | `invalid_number`, nothing written | | `' 12 '`, `'12\n'` | accepted, stored the string | accepted, stored `12` | `invalid_number`, nothing written | | `'+5'`, `'.5'`, `'5.'`, `'007'` | accepted, stored the string | accepted, stored `5` / `0.5` / `5` / `7` | `invalid_number`, nothing written | | `'1,000'`, `'Infinity'`, `'NaN'`, `'1e400'`, `'abc'` | `invalid_number` | `invalid_number` | unchanged | | `''` | `null` (blank rule) | `null` | unchanged | | `12` (a number) | stored `12` | stored `12` (`real`; `integer` on `rating`) | unchanged | Of 2640 cells, **1200 moved**, exactly 60 per moved input (6 types x the 10 non-`/import` doors). The `/import` door moved **0** of its 240 cells: its own cell reader turns a numeric cell into a number before the engine sees it (below). Refused cells answer `400 VALIDATION_FAILED` with the field code `invalid_number` on POST and PATCH, a `VALIDATION_FAILED` row on batch / `createMany` / `updateMany`, and leave an existing cell unchanged on every update door. **H4, the narrowing.** Read off the spec table rather than listed by hand, the strings `Number()` read as finite that the grammar refuses are exactly `' 12 '`, `'12\n'`, `'\t-3'`, `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`, `'+5'`, `'.5'`, `'5.'`, `'007'` (pinned in `record-validator.number-value.test.ts`). The changeset names them, with the before and after answer and the fix (send a JS number or its plain JSON spelling). **H5.** Bounds, `scale` and `precision` read the parsed number, so a string answers byte-for-byte as its number does (pinned over 14 cases): `'12.50'` passes `scale: 1` and is stored as `12.5`; `'12.55'` and `'1e-7'` are `max_scale`; `'150'` over `max: 100` is `max_value` (on `progress` too); `'1234.5'` at `precision: 5, scale: 2` is `max_precision`; a fraction-stored `percent` keeps its `scale + 2` allowance. There is no integer check on `rating`, before or after: `'3.5'` on a `rating` passes unless it declares `scale: 0`. ## The server `/import` route and the grammar (H3) The route's cell reader, `parseNumberCell` (`packages/rest/src/import-coerce.ts`), coerces a numeric cell to a JS number before the write, so the engine's grammar never sees a string from it on a typed field. Over the 41 rows of `NUMERIC_STRING_GRAMMAR_CASES` the two **agree on 33** (every admitted row reads to the same number; hex, octal, binary, non-finite, placeholders, `'5.'`, `'1_000'`, `'1 000'` refused by both) and **disagree on 8**, each one the import reader accepting what the grammar refuses: `' 12 '` / `'12\n'` / `'\t-3'` (it trims), `'1,000'` (it strips commas), `'1.000,5'` (read as `1.0005`), `'+5'`, `'.5'`, `'007'`. That is the import route's own documented tolerance and is not changed here (not in this card's surface). ## Tests, all at `6bf61e75a` unless noted - `pnpm --filter @objectstack/objectql test`: 329 files, **6584 passed**. - `pnpm --filter @objectstack/rest test`: 218 files, **4160 passed**, 34 skipped. - `pnpm --filter @objectstack/objectql --filter @objectstack/rest typecheck`: exit 0, both test layers OK (`tsc --listFiles` over each `tsconfig.test.json` includes the edited test files). - Downstream sweep at `b78c66612` (before the merge): `@objectstack/service-automation` 149 files, 1837 passed; `@objectstack/metadata-protocol` 189 files passed, 3 skipped, 2750 tests passed. - Pin files: `record-validator.number-value.test.ts` 369 tests, `engine-number-value-door.test.ts` 275, `rest-data-number-value.test.ts` 277. - ESLint, narrowed and declared: the 5 changed `.ts` files, `eslint --no-inline-config --format json`: 5 files linted, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. **Ablations** (each through `scripts/ablation-replace.mjs`, which proved the anchor 1 to 0 and the blob change on disk and restored with blob == HEAD and an empty `git diff HEAD`; objectql rebuilt and `ablation-dist-preflight` confirmed the marker in 4 built files before each run, and absent from all 14 after each restore rebuild, with a clean tree): - **A, the arm reads strings by `Number()` again** (`parseNumericString(value)` replaced). Predicted 201 reds: 68 validator, 67 engine, 66 REST. Measured objectql **135** failed of 644 (68 + 67) and REST **66** failed of 277, all in the named narrowed strings, the table-parity and named-narrowing tests, and the dry-run test. - **B, the rewrite made a no-op.** Predicted 79 objectql reds and **0** REST reds, because SQLite's column affinity stores the plain numeric strings as numbers anyway. Measured objectql **79** failed of 644 (60 driver-payload cases, the hook test, 4 rewrite tests, 14 H5 cases) and REST **0** failed of 277. So on SQLite the physical-cell pin cannot see the rewrite; the engine pin on the driver payload is what covers memory (and MongoDB, which stores the payload as given). - After both restores: the three pin files 644 / 644 and 277 / 277. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6bf61e75a`: 66 commands, each run with its exit code recorded before any pipe. **64 exit 0.** 2 are **NOT MEASURED** with exit 3 (PREREQUISITE NOT MET, both need every package built; CI runs them): `pnpm check:dual-build-cjs-loads`, `pnpm check:type-check-debt`. `--ran` reconciliation: 66 derived, 64 run, 2 NOT-MEASURED, 0 UNRUN. ## Acceptance notes - **Producer census (triage direction 2).** The seat measured it (answer 5863923799 on the card, objectui source): every interactive form widget (`NumberField`, `CurrencyField`, `PercentField`, `RatingField`, `SliderField`, grid inline edit) sends a JS number or `null`, and the kanban quick add a number or a blank that the blank rule turns into `null`. One shipped path sends numeric **strings** to the record write door: objectui's CSV import wizard, legacy per-row fallback (`plugin-grid/src/ImportWizard.tsx`, `legacyImport` via `validateRow`), used only when the client cannot reach the server `/import` route. Re-read in this run at the local objectui checkout `b8e09415c9`: it posts the raw cell after a client check `!isNaN(Number(value))`, which covers `number` / `currency` / `percent` only (a `rating`, `slider` or `progress` cell reaches the server unchecked), and its parser (`importParsers.ts` `parseDelimited`, and the xlsx reader) trims every cell. So of the refused forms it can send the radix literals and the non-JSON spellings (`'0x10'`, `'+5'`, `'.5'`, `'5.'`, `'007'`), and those rows now fail per row with `invalid_number` where they used to store a string. Prescription (in the changeset): import through the server `/import` route, the wizard's default path. The in-repo rows (example seeds and defaults, flow templates, the `/import` route, the client SDK, driver read-back) send numbers, as PR #20370 recorded. - **`/import` and the grammar disagree on 8 rows** (above). Not changed here. One of them is reported to the seat as a finding: a decimal-comma cell is misread at the `/import` door, measured through the route on both drivers: `'3,14'` stored `314`, `'1,5'` stored `15`, `'1.000,5'` stored `1.0005`, `'1,2,3'` stored `123`, each with `ok: 1, errors: 0`. - **The earlier pending changeset** `.changeset/20309-number-arm-non-string-refused.md` says a string "is still judged by `Number()` and stored as sent. Which strings a number field accepts is a separate change." This PR is that separate change, and its own changeset says so. The earlier file is left untouched: editing another PR's pending changeset is refused by `check:empty-changeset` (the foreign-changeset rule) unless confirmed as a deliberate correction. - **The dispatch asked for a "FROM → TO" line** in the changeset. With that label `check-adr-0087-registration` reads a migration prescription and refuses `not-required (no-migration-prescription)`, the disposition PR #20370 used for this arm. The changeset carries the same mapping as a "before → after" line with the fix, the spelling the sibling value narrowing `20386-progress-min-max-enforced.md` uses. Nothing authored moves, so there is no ledger row to register. - **Memory stores `-0` for `'-0'`**, the grammar's own value; SQLite stores `0`. - **Hooks now see the number.** A `before*` hook reading a numeric field that a caller sent as a string sees a JS number (pinned). A value a hook itself writes after the door is not rewritten; the arm still judges it by the same grammar. - `driver-memory` is measured at every REST door (the table above) and pinned at the engine door on the driver payload, not with a new REST test consumer: `check:driver-memory-census` refuses one without a ruling (the constraint PR #20370 met). --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c1b8c8 commit 2b24b8b

6 files changed

Lines changed: 607 additions & 36 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING**: shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level). The
12+
narrowing: a string that `Number()` reads as a finite number but the platform's
13+
numeric grammar does not is now refused with `400 VALIDATION_FAILED` /
14+
`invalid_number`. It used to be accepted.
15+
16+
**What a caller sees, before → after.** One of these strings written to one of
17+
those fields: `201`, stored as sent (memory kept the string; SQLite kept
18+
`'0x10'` as TEXT and the others as numbers by column affinity) → `400
19+
VALIDATION_FAILED` with the field code `invalid_number`, nothing stored. The
20+
REST create, batch, update and updateMany routes all answer it, and `validate`
21+
(the dry run) predicts it. The forms:
22+
23+
- a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`;
24+
- a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`;
25+
- a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`.
26+
27+
The fix, when a write is refused: send a JS number, or the number's plain JSON
28+
spelling — `'16'`, `'12'`, `'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any
29+
finite number always qualifies, exponent forms included (`'1e-7'`, `'1e+21'`).
30+
31+
## What was wrong
32+
33+
This is the separate change the earlier #20309 note (arrays, booleans and
34+
objects refused) left open. The record validator judged a string by `Number()`
35+
while the write carried the string itself, so an accepted string reached the
36+
driver as sent:
37+
38+
- **memory** stored `'12'` as the string `'12'` and read it back as a string;
39+
- **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the
40+
other accepted strings as numbers through the column's affinity.
41+
42+
One write, two stored shapes, depending on the backend.
43+
44+
## What changes
45+
46+
- A string is judged by `parseNumericString` from `@objectstack/spec/data`, the
47+
one numeric grammar the filter door also reads: the whole string is a JSON
48+
number literal naming a finite double. Its case table,
49+
`NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in
50+
the engine.
51+
- An admitted string is stored as the number it denotes, on every backend:
52+
`'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write
53+
door, before the middleware, the hooks, the `readonlyWhen` locks and
54+
validation read the payload, so a `before*` hook now sees the number. The
55+
caller's own object is not mutated.
56+
- `min`, `max`, `scale` and `precision` read that number, exactly as they read
57+
a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over
58+
`max: 100` is `max_value`.
59+
- This holds on every engine, REST, batch and updateMany door, and in
60+
`validate` (the dry run). The server `/import` route is unchanged: its own
61+
cell reader turns a numeric cell into a number before the write, so the
62+
grammar never sees a string from it.
63+
- A blank is still `null` before the check (#20308). `summary` is still not
64+
judged. A number, and an array, boolean or object, are answered as before.
65+
66+
## Who sends numeric strings
67+
68+
objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`,
69+
used only when the connected client cannot reach the server `/import` route),
70+
posts each raw cell to `create` after a client check of
71+
`!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can
72+
send the radix literals and the non-JSON spellings. Those rows now fail with
73+
`invalid_number` instead of storing a string. The fix there is the wizard's
74+
default path: import through the server `/import` route, whose cell reader
75+
converts the number before the write. Every interactive form widget sends a JS
76+
number or `null`, and is unaffected.
77+
78+
## Rows already stored
79+
80+
This judges new writes only; a stored value is never re-read by the check. On
81+
memory, an accepted string stayed a string until the record is next written.
82+
On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT
83+
(such as `'0x10'`):
84+
85+
```sql
86+
SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text';
87+
```
88+
89+
OBJECT is the object name and FIELD is the field name. Nothing here rewrites
90+
such a cell; decide its number by hand.
91+
92+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched and no metadata key is added, removed or reshaped, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What narrows is the set of caller-written string VALUES a number-typed field accepts at the write door, judged by the spec's existing numeric grammar; stored rows are never re-read, and a caller that sends a number, a blank or a grammar-admitted string is unaffected. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎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/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';
@@ -11776,8 +11776,12 @@ export class ObjectQL implements IObjectQLEngine {
1177611776
// [#20308] The write doors read a blank on a non-string-typed column as
1177711777
// `null` before anything else; the preview does the same at the same point,
1177811778
// or a blank on a required field with a `defaultValue` would preview
11779-
// `required` while the write takes the default.
11780-
const rawRows = normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]);
11779+
// `required` while the write takes the default. [#20309] Likewise a
11780+
// numeric string on a number field is its number here, as on the write.
11781+
const rawRows = normalizeNumericStringValues(
11782+
schemaForValidation,
11783+
normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]),
11784+
);
1178111785
const nowSnapshot = new Date();
1178211786
// [#20082] The preview's ONE permission resolution, shared by its CEL
1178311787
// defaults and its option gates below, exactly as the write shares one. A
@@ -11948,8 +11952,11 @@ export class ObjectQL implements IObjectQLEngine {
1194811952
// validation read the payload, so all of them see one image (a blank then
1194911953
// takes a `defaultValue` exactly as `null` does). See
1195011954
// `normalizeBlankTypedValues` for the scope; it never mutates the caller's
11951-
// rows.
11955+
// rows. [#20309] At the same point, a string on a number field that the
11956+
// spec's numeric grammar reads becomes that number, so the validator judges
11957+
// the value the driver stores (`normalizeNumericStringValues`).
1195211958
data = normalizeBlankTypedValues(this._registry.getObject(object), data);
11959+
data = normalizeNumericStringValues(this._registry.getObject(object), data);
1195311960

1195411961
const opCtx: OperationContext = {
1195511962
object,
@@ -12993,8 +13000,11 @@ export class ObjectQL implements IObjectQLEngine {
1299313000
// non-string-typed column is `null` before the middleware, the
1299413001
// caller-value snapshot (`suppliedValues`), the hooks, the read-only
1299513002
// strips and validation read the payload — so a `readonlyWhen` lock judges
12996-
// the value it snapshotted. See `normalizeBlankTypedValues`.
13003+
// the value it snapshotted. See `normalizeBlankTypedValues`. [#20309] The
13004+
// insert door's numeric-string rewrite, same place and same reason (see
13005+
// `normalizeNumericStringValues`).
1299713006
data = normalizeBlankTypedValues(this._registry.getObject(object), data);
13007+
data = normalizeNumericStringValues(this._registry.getObject(object), data);
1299813008

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

0 commit comments

Comments
 (0)