|
| 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 | +**FROM → TO, and the one-line fix.** These strings, written to one of those |
| 17 | +fields, were accepted and are now refused, with nothing written: |
| 18 | + |
| 19 | +- a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`; |
| 20 | +- a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`; |
| 21 | +- a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`. |
| 22 | + |
| 23 | +Fix: send a JS number, or the number's plain JSON spelling — `'16'`, `'12'`, |
| 24 | +`'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any finite number always |
| 25 | +qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). |
| 26 | + |
| 27 | +## What was wrong |
| 28 | + |
| 29 | +This is the separate change the earlier #20309 note (arrays, booleans and |
| 30 | +objects refused) left open. The record validator judged a string by `Number()` |
| 31 | +while the write carried the string itself, so an accepted string reached the |
| 32 | +driver as sent: |
| 33 | + |
| 34 | +- **memory** stored `'12'` as the string `'12'` and read it back as a string; |
| 35 | +- **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the |
| 36 | + other accepted strings as numbers through the column's affinity. |
| 37 | + |
| 38 | +One write, two stored shapes, depending on the backend. |
| 39 | + |
| 40 | +## What changes |
| 41 | + |
| 42 | +- A string is judged by `parseNumericString` from `@objectstack/spec/data`, the |
| 43 | + one numeric grammar the filter door also reads: the whole string is a JSON |
| 44 | + number literal naming a finite double. Its case table, |
| 45 | + `NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in |
| 46 | + the engine. |
| 47 | +- An admitted string is stored as the number it denotes, on every backend: |
| 48 | + `'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write |
| 49 | + door, before the middleware, the hooks, the `readonlyWhen` locks and |
| 50 | + validation read the payload, so a `before*` hook now sees the number. The |
| 51 | + caller's own object is not mutated. |
| 52 | +- `min`, `max`, `scale` and `precision` read that number, exactly as they read |
| 53 | + a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over |
| 54 | + `max: 100` is `max_value`. |
| 55 | +- This holds on every engine, REST, batch and updateMany door, and in |
| 56 | + `validate` (the dry run). The server `/import` route is unchanged: its own |
| 57 | + cell reader turns a numeric cell into a number before the write, so the |
| 58 | + grammar never sees a string from it. |
| 59 | +- A blank is still `null` before the check (#20308). `summary` is still not |
| 60 | + judged. A number, and an array, boolean or object, are answered as before. |
| 61 | + |
| 62 | +## Who sends numeric strings |
| 63 | + |
| 64 | +objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`, |
| 65 | +used only when the connected client cannot reach the server `/import` route), |
| 66 | +posts each raw cell to `create` after a client check of |
| 67 | +`!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can |
| 68 | +send the radix literals and the non-JSON spellings. Those rows now fail with |
| 69 | +`invalid_number` instead of storing a string. The fix there is the wizard's |
| 70 | +default path: import through the server `/import` route, whose cell reader |
| 71 | +converts the number before the write. Every interactive form widget sends a JS |
| 72 | +number or `null`, and is unaffected. |
| 73 | + |
| 74 | +## Rows already stored |
| 75 | + |
| 76 | +This judges new writes only; a stored value is never re-read by the check. On |
| 77 | +memory, an accepted string stayed a string until the record is next written. |
| 78 | +On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT |
| 79 | +(such as `'0x10'`): |
| 80 | + |
| 81 | +```sql |
| 82 | +SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; |
| 83 | +``` |
| 84 | + |
| 85 | +OBJECT is the object name and FIELD is the field name. Nothing here rewrites |
| 86 | +such a cell; decide its number by hand. |
| 87 | + |
| 88 | +<!-- 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`). --> |
0 commit comments