|
| 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`). --> |
0 commit comments