Skip to content

Commit 04e0c2c

Browse files
fix(objectql)!: a number or boolean written to a text-family field is stored as the string the validator judged (#29241) (#29384)
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9D6hLaUChirwQbD3RzAKEBf)_ Fixes #29241 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs/**` teaches writing a number or boolean into a text-family field, or how a `unique` text field compares `42` with `"42"` (a `git grep` of `skills/**` for unique and text/number shows none); the field-types rule already says a text field stores a string. Clause-② as amended by seat ruling 6579176393 on #29241. Measured: a single write is answered as before (the same payloads are admitted and refused). The unique key now compares the stored string, so on a `unique` text-family field the accept set of a write sequence moves in both directions (table under Why). Metadata-protocol stays `none`: no key, value or metadata type an author writes moves. > **BREAKING** for one case only, a `unique` text-family field. FROM: `{ code: 42 }` then `{ code: "42" }` (or `true` then `"true"`) both answered `201` and stored two rows, on driver-memory and on SQLite. TO: both store `"42"`, so the second write is refused `409 UNIQUE_VIOLATION`. The fix for a caller: the two writes are the same value; send it once, or update the existing row. The changeset carries one ADR-0087 marker, `not-required (no-migration-prescription)`: no metadata moves, and rows already stored are not rewritten, so nothing is prescribed. `check-adr-0087-registration` reports `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. ## 维护者速读 - **改了什么**:数字或布尔值写进文本类字段(text、textarea、email、phone 等 12 种)时,服务端写入口先把它改写成校验器判定用的那个字符串(`42` 存成 `"42"`,`true` 存成 `"true"`),再交给驱动。 - **为什么改**:校验器一直按字符串 `"42"` 判定,存的却是原值,结果因驱动而异:内存驱动存数字 `42`,SQLite 存成 `"42.0"`、`true` 存成 `"1.0"`(`maxLength: 2` 的字段放行了 `42`,最后存了 4 个字符)。同一次写入,三种存法,等值过滤和排序因库而异。 - **风险与回滚**:单次写入的放行与拒绝不变。变化在唯一(unique)文本字段上:先写 `42` 再写 `"42"`,以前两条都存,现在第二条 `409 UNIQUE_VIOLATION`(两个驱动都如此,这是 BREAKING);SQLite 上先写 `true` 再写 `1` 以前被拒,现在两条都存。已存的行不迁移,SQLite 旧行 `"42.0"` 和新写的 `"42"` 不再相等。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/validation/record-validator.ts` 的 `normalizeNumericStringRow` 新的 `else if` 分支;`packages/rest/src/rest-data-text-scalar-value.test.ts`;`.changeset/29241-text-field-scalar-stored-string.md`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/rest-data-text-scalar-value.test.ts`,预期 8 个全绿(含唯一字段的 4 个:两个 409、两个双 201)。 - **需要你做的**:无,席位落地。 ## What changes - **The seam.** `normalizeNumericStringValues` in `packages/objectql/src/validation/record-validator.ts`, the #20309 door that writes a numeric string on a number field as its number. `ObjectQL.insert()`, `update()`, `validate()` (the dry run) and `validateInsertValues` already call it on the caller's payload before the middleware, the snapshots, the hooks and the validator read it. Every REST, batch and import door goes through those methods. `engine.ts` is not touched, and the blank-value path (`normalizeBlankTypedValues`) is not touched. - **What moves (Clause-②).** Hooks, formulas, filters and a driver's unique check all now see the string. On a `unique` text-family field that changes which write sequences are admitted, both ways; see the second table under Why. - **The new arm.** A number or a boolean on a field whose type is in the spec's `BOUNDED_STRING_FIELD_TYPES` (the set the validator's string arm reads) is written as `String(value)`, the exact string the arm judges. The field scope is the numeric half's: the fields the validator walks, `readonly` included, never a `SKIP_FIELDS` name or a `system` field. - **Unchanged, and pinned:** a string, `null`, a missing key, an object or an array (refused since #29133), `json` and the structured types, number and boolean fields, a field the validator never judges, an undeclared key. `select` and the reference types are not in the set (#29244 owns the select arm). - The string arm's comment now says the door has written the judged string. ## Why Measured on `origin/main` @ `a19759ac` (base) and on this branch (head), through the real REST routes (`POST /data/:object`, then the physical row, `engine.findOne` and `GET /data/:object/:id`): | Sent on a `text` / `textarea` field | driver-memory, base | driver-sql (SQLite), base | both drivers, head | |---|---|---|---| | `42` / `true` | `42` / `true` (number, boolean) | `"42.0"` / `"1.0"` | `"42"` / `"true"` | | `false` / `0` | `false` / `0` | `"0.0"` / `"0.0"` | `"false"` / `"0"` | | `1.5` / `-3` | `1.5` / `-3` | `"1.5"` / `"-3.0"` | `"1.5"` / `"-3"` | | `"x42"` / `"true"` (control) | as sent | as sent | as sent | All three reads (physical row, engine, REST) agree within each cell. Every write answered `201` on base and head. On SQLite the stored value is neither the raw scalar nor the judged string: a number is bound as a REAL and the TEXT column keeps its REAL spelling, and a boolean is bound as `1`/`0`. So a `maxLength: 2` text field admitted `42` and stored four characters, and `{ title: true }` stored `"1.0"`. PostgreSQL and MongoDB: NOT MEASURED (no daemon in the container). The engine now hands every driver the string, so no driver decides. **Who writes a number or boolean into a text-family field today** (option A vs B). A log-only probe at the new arm recorded every rewrite while these suites ran: `@objectstack/objectql`, `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/service-automation` (the flow nodes, including the `create_record` / `update_record` and screen paths) and the dogfood battery, which boots the example apps and their seeds. Positive control: the probe was proven in `dist/` (4 built files) and recorded the 6 writes of the new REST test, which reach objectql through `dist/`. | Writer | Hits | |---|---| | Seeds and data in `examples/`, `packages/apps`, platform objects (booted by the dogfood battery and the runtime suite) | 0 | | Flow / screen paths (`service-automation` suite, dogfood flow fixtures) | 0 | | Dogfood fixtures | 0 | | Tests: `protocol-save-notice-answer.test.ts` (a `text` field used to hand a raw `42` to a hook) | 1 | No product writer under these suites relies on the raw scalar, so A was built as the card's default. The census did not cover the plugin suites (for example plugin-audit and service-messaging) or the other service packages, so it is not exhaustive; the review spot-read the obvious text-typed value columns there (`sys_audit_log.old_value` / `new_value` are written through `safeStringify`) and found no number writer. **A unique text-family field** (measured: the REST create route, the base leg with `origin/main` @ `7f277a86`'s `record-validator.ts` built into objectql's `dist/`, the head leg with this branch's; SQLite in `packages/rest`, driver-memory in a one-off probe beside it, deleted): | Sequence (two POSTs) | base, SQLite | base, driver-memory | head, both drivers | |---|---|---|---| | `42`, then `"42"` | 201, 201 | 201, 201 | 201, **409** `UNIQUE_VIOLATION` | | `"42"`, then `42` | 201, 201 | 201, 201 | 201, **409** | | `true`, then `"true"` | 201, 201 | 201, 201 | 201, **409** | | `true`, then `1` | 201, 409 | 201, 201 | 201, **201** | | `false`, then `0` | 201, 409 | 201, 201 | 201, **201** | | `1`, then `true` | 201, 409 | 201, 201 | 201, **201** | | `42`, then `"42.0"` | 201, 409 | 201, 201 | 201, **201** | The first three rows are the narrowing, on both drivers: the validator reads both values as one string, and now the unique key does too. The last four are SQLite's widening: base stored `true` and `1` both as `"1.0"`, and `42` as `"42.0"`. Each new answer is the one the validator's reading implies. ## Tests - `packages/objectql/src/validation/record-validator.text-scalar-value.test.ts` (70 tests): the spec set (12 types) × `42`, `0`, `-1.5`, `true`, `false`; the written string is the one a `maxLength` reads; a readonly field; a batch per row, caller objects unmutated; the controls listed above; and the engine doors (insert, insertMany, update by id, update by predicate) with a recording driver, which receives the string; and a `unique` text field, where `42` and `"42"` reach the driver as one value (what driver-memory's unique key compares). The `validate()` dry run calls the same function; its answer carries no values, so it is not pinned here. - `packages/rest/src/rest-data-text-scalar-value.test.ts` (8 tests): real `ObjectQL` + SQLite `SqlDriver` + `RestServer`, POST, PATCH and batch create, checked on the physical column, `findOne` and `GET …/:id`; a string control and a number-field control; and the unique field in both directions: `42` then `"42"` and `true` then `"true"` refused `409 UNIQUE_VIOLATION` (status and code asserted), `true` then `1` and `false` then `0` both landing as two strings. - **One forced test edit:** `packages/objectql/src/protocol-save-notice-answer.test.ts`. Its `refuses a number` case sent `say: 42` through a `text` field so the hook would call `ctx.notice(42)`. The hook now receives `"42"`, so the vehicle field `say` is `json`, which keeps the value as sent. The assertion is unchanged, and the file's other 21 tests are green on it. <details><summary>Red on base, green on head</summary> | Run | Base `a19759ac` | Head | |---|---|---| | rest file, first 4 tests | `Tests 3 failed, 1 passed (4)`: `expected '42.0' to be '42'`, `expected '0.0' to be '0'`, `expected '1.0' to be 'true'` | `Tests 4 passed (4)` | | rest file, the 4 unique tests | measured by the probe table under Why (same routes, same SQLite boot): 42/"42" and true/"true" both 201, true/1 and false/0 second 409 | `Tests 8 passed (8)` at `926fa801` | </details> <details><summary>Ablations (the fix, the three negative pins, and the unique pins)</summary> Each mutation was made with `scripts/ablation-replace.mjs` (anchor 1 → 0, blob changed) and restored to the HEAD blob `bdeb597b` with `git diff HEAD` empty. The objectql file resolves `src/` directly; the rest file reads objectql from `dist/`, so leg 1 rebuilt objectql and `ablation-dist-preflight.mjs` found the marker in 4 built files, then absent from all 14 after the restore rebuild. | Leg | Mutation | Red | |---|---|---| | 1 | the arm writes the raw value again | objectql `Tests 64 failed, 5 passed (69)`; rest `Tests 3 failed, 1 passed (4)` (`'42.0'`, `'0.0'`, `'1.0'`) | | 2 | the rewrite reaches every field type | `a json, number or boolean field keeps its number or boolean`; the engine-door test (its `amount: 7` number-field control) | | 3 | the validator-walk scope dropped | `a declared field the validator never judges is not rewritten` | | 4 | null / missing / object / array reach the rewrite | `a string, null, a missing key, an object and an array on a text field: the same row back` | | 1, round 2 (head `926fa801`) | the arm writes the raw value again (marker in 4 built files, then absent from 14; restored to blob `92fe1708`, `git diff HEAD` empty) | rest `Tests 7 failed, 1 passed (8)`: both 409 pins, both 201,201 pins and the three stored-form tests; objectql `a unique text field: 42 and "42" reach the driver as one value` | </details> ## Acceptance notes - **Stored rows are not rewritten.** A SQLite row written before this change keeps `"42.0"` and no longer equals a new `"42"`, nor collides with it on a unique field. driver-memory keeps rows for the process by default, but its `persistence` option (`file`, `local` or a custom adapter) is opt-in and keeps raw `42` / `true` across restarts the same way. A migration for such rows would be a separate card. - **The filter door** (measured, the same two runs, one row written as `42` and one as `"42"`, `engine.find` and `POST /data/:object/query` agreeing): base: `{ title: 42 }` matched only the row written as `42` on both drivers (driver-memory's raw `42`, SQLite's `"42.0"`), and `{ title: "42" }` only the other. Head: `{ title: "42" }` matches both rows, and `{ title: 42 }` matches none, on both drivers. So at head a number comparand on a text field does not answer differently by driver; it silently matches nothing, while the write door stores that number as its string. The seat ruled it (b), a gap that predates this PR, to be filed as its own card after landing; the changeset tells a caller to filter with the string. - **Other non-string values on a text field.** The string arm also judges a `Date` (or another class instance) by `String(value)` while the raw object reaches the driver. Out of this card's scope (numbers and booleans). Reported to the seat, not filed. - **A `before*` hook** that writes a number into a text field after the door is the hook's own value and is not rewritten, as on the numeric half. ## Verification Head `288190cd` (round 3: prose only — the changeset's filter sentence, and the record-validator docblock and objectql test header restated to the unique answer; no code or test line changed. Round 2 head `926fa801`: the unique pins and the declaration; round 1 head `34c30195` had merged `origin/main` with #29271, `merge-tree` clean). See the PR's check-runs for CI. <details><summary>Suites, typecheck, gates, lint, dogfood</summary> | Run | Head | Result | |---|---|---| | `pnpm --filter @objectstack/dogfood test` (closure built at this head) | `926fa801` | lock VERDICT command-exit 0 · Test Files 393 passed, 3 skipped (396) · Tests 3243 passed, 28 skipped (3271) | | objectql `record-validator.text-scalar-value.test.ts` | `926fa801` | Tests 70 passed (70) | | rest `rest-data-text-scalar-value.test.ts` | `926fa801` | Tests 8 passed (8) | | `dispatch-gates.mjs --commands` (61 families), each run | `926fa801` | 61 × exit 0; `--ran`: 61 derived, 61 run, 0 NOT-MEASURED | | `check-adr-0087-registration --base origin/main` | `288190cd` | exit 0: 1 declared-breaking changeset with its disposition, `not-required (no-migration-prescription)` | | eslint (`--no-inline-config`) over `packages/objectql` and `packages/rest` | `926fa801` | exit 0 (neither package has its own `lint` script) | | round 3 prose checks: `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check-empty-changeset`, `check-closing-keyword-parity`, eslint on `packages/objectql/src/validation` | `288190cd` | each exit 0 | | `pnpm --filter @objectstack/objectql test` | `34c30195` | Test Files 531 passed (531) · Tests 10345 passed (10345) | | `pnpm --filter @objectstack/objectql typecheck` | `34c30195` | exit 0 | | `pnpm --filter @objectstack/rest test` | `34c30195` | Test Files 371 passed (371) · Tests 7034 passed, 332 skipped (7366) | | `pnpm --filter @objectstack/rest typecheck` | `34c30195` | exit 0 | | `pnpm --filter @objectstack/runtime test` | `27920d0a` + log-only probe | Test Files 516 passed (516) · Tests 8367 passed, 19 skipped | | `pnpm --filter @objectstack/service-automation test` | `27920d0a` + log-only probe | Test Files 259 passed (259) · Tests 3335 passed | Round 2 (`34c30195` → `926fa801`) changed only the changeset and the two new test files, so the full objectql and rest suites were not re-run; their round 2 readings are the two edited files above, and Test Core in CI. The runtime and automation rows ran with the census probe in objectql's `dist/`: one uncommitted line in the new arm that appended a JSON line to a file when `OS_TEST_PROBE_29241` was set, and changed no value; it was removed and `ablation-dist-preflight` proved it absent from all 14 built files. The round 1 dogfood reading (`27920d0a` + probe: 391 files, 3223 tests) is superseded by the head reading above. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes on the touched packages. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief above is Chinese by repo rule). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6af3d29 commit 04e0c2c

5 files changed

Lines changed: 427 additions & 7 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a number or a boolean written to a text-family field is stored as the string the validator judged
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata key, value or type moves, and no export or type changes: the write door now stores the string the record validator already judged a number or boolean by. The narrowed face is a runtime answer on a unique text-family field (a second write that the stored value now collides with), not anything an author wrote, so there is nothing for objectstack migrate meta to rewrite and no tombstone. Rows already stored are not rewritten by this change, so no stored-row migration is prescribed either. -->
11+
12+
**BREAKING** for one case only, a `unique` text-family field. FROM: `{ code: 42 }` and then `{ code: "42" }` (or `true` and then `"true"`) both answered `201` and stored two rows, on driver-memory and on SQLite. TO: both writes store `"42"`, so the second one is refused `409 UNIQUE_VIOLATION`. The fix for a caller that hits it: the two writes are the same value; send the string once, or update the existing row. In the other direction, SQLite used to refuse `true` after `1` (and `false` after `0`) because both were stored as `"1.0"` (`"0.0"`); both now land as two different strings (`"true"` and `"1"`). driver-memory admitted both before and still does.
13+
14+
The record validator's string arm (`text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode`) judges a number or a boolean by its string reading: `42` is bounded and format-checked as `"42"`, `true` as `"true"`. The write used to carry the raw scalar to the driver, so the stored value depended on the driver: driver-memory stored and read back the number `42` and the boolean `true`, and SQLite stored the TEXT `"42.0"` for `42` and `"1.0"` for `true` (a `maxLength: 2` field admitted `42` and then held four characters). The write door now writes the judged string into the payload, beside the numeric-string rewrite on number fields, on insert, insertMany, update by id, update by predicate and the `validate()` dry run.
15+
16+
What you will see: a text-family column now stores and reads back `"42"` where driver-memory stored `42`, and `"42"` / `"true"` where SQLite stored `"42.0"` / `"1.0"`. A single write is answered as before: the same payloads are admitted and refused. What moves is the unique answer above, and hooks, formulas and filters now see the string. A filter that compares a text-family field to the number `42` (or to a boolean) no longer matches a row written as `42`, because that row is now stored as `"42"`: filter with the string. The fix on the filter side is tracked separately. Rows already stored are not rewritten, so an old SQLite `"42.0"` row, or a raw `42` in a driver-memory store with `persistence` enabled, does not collide with a new `"42"`. Unchanged: strings, `null`, missing keys, objects and arrays (refused), `json` and the structured types, and number and boolean fields.

‎packages/objectql/src/protocol-save-notice-answer.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ const DEAL = {
3636
label: 'Deal',
3737
fields: {
3838
body: { name: 'body', label: 'Body', type: 'text' },
39-
say: { name: 'say', label: 'Say', type: 'text' },
39+
// `json`, not `text`: the refusal cases below hand the hook a raw number,
40+
// and a text-family field stores (and hands its hooks) the string the
41+
// validator judges a number by (#29241).
42+
say: { name: 'say', label: 'Say', type: 'json' },
4043
say_after: { name: 'say_after', label: 'Say after', type: 'text' },
4144
},
4245
};
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29241] A number or a boolean written to a string-typed field
5+
* (`BOUNDED_STRING_FIELD_TYPES`) is stored as the string the record
6+
* validator's string arm judged it by.
7+
*
8+
* Measured on `origin/main` @ `a19759ac` before the change: the arm judged
9+
* `String(value)` (`42` as `'42'`, `true` as `'true'`) while the write carried
10+
* the raw scalar, so the driver's `create()` / `update()` received the number
11+
* or the boolean as sent. The door now writes the judged string into the
12+
* payload (`normalizeNumericStringValues`, beside the number arm's #20309
13+
* rewrite), on every engine door: insert, insertMany, update by id and update
14+
* by predicate. (The `validate()` dry run calls the same function at the same
15+
* point; its answer carries no values, so the arm's verdict there is unchanged.)
16+
*
17+
* A single write is admitted or refused exactly as before. The unique-key
18+
* answer does move (Clause-② yes, narrowing): `42` after `"42"` on a `unique`
19+
* text field now collides, pinned below at the driver door and against SQLite
20+
* in `rest-data-text-scalar-value.test.ts`. Untouched, and pinned
21+
* here: a string, `null`, a missing key, a plain object or an array (refused,
22+
* #29133), a `json` field, a number field, a boolean field, and a declared
23+
* field the validator never judges.
24+
*/
25+
26+
import { describe, it, expect } from 'vitest';
27+
import { BOUNDED_STRING_FIELD_TYPES } from '@objectstack/spec/data';
28+
import { normalizeNumericStringValues, validateRecord, ValidationError } from './record-validator.js';
29+
import { ObjectQL } from '../engine.js';
30+
31+
const STRING_TYPES = [...BOUNDED_STRING_FIELD_TYPES];
32+
33+
const SCALARS: ReadonlyArray<readonly [unknown, string]> = [
34+
[42, '42'],
35+
[0, '0'],
36+
[-1.5, '-1.5'],
37+
[true, 'true'],
38+
[false, 'false'],
39+
];
40+
41+
describe('#29241 — the door writes the string the arm judged', () => {
42+
it('reads the spec set, which still holds the twelve members this pin was written against', () => {
43+
expect(STRING_TYPES).toHaveLength(12);
44+
});
45+
46+
for (const type of STRING_TYPES) {
47+
for (const [input, stored] of SCALARS) {
48+
it(`${type}: ${JSON.stringify(input)} is written as ${JSON.stringify(stored)}`, () => {
49+
const out = normalizeNumericStringValues({ fields: { f: { type } } }, { f: input });
50+
expect(out.f).toBe(stored);
51+
});
52+
}
53+
}
54+
55+
it('the written string is the one the arm judges: a bound reads its length', () => {
56+
const schema = { fields: { code: { type: 'text', label: 'Code', maxLength: 2 } } };
57+
const row = normalizeNumericStringValues(schema, { code: 123 });
58+
expect(row).toEqual({ code: '123' });
59+
let err: unknown = null;
60+
try {
61+
validateRecord(schema, row, 'insert');
62+
} catch (e) {
63+
err = e;
64+
}
65+
expect(err).toBeInstanceOf(ValidationError);
66+
expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['code', 'max_length']]);
67+
});
68+
69+
it('a readonly field is written too: its value is judged on every write', () => {
70+
const schema = { fields: { stamp: { type: 'text', readonly: true } } };
71+
expect(normalizeNumericStringValues(schema, { stamp: 7 })).toEqual({ stamp: '7' });
72+
});
73+
74+
it('a batch is rewritten per row, and the caller objects are never mutated', () => {
75+
const schema = { fields: { title: { type: 'text' } } };
76+
const a = { title: 1 };
77+
const b = { title: 'two' };
78+
const list = [a, b];
79+
const out = normalizeNumericStringValues(schema, list);
80+
expect(out).not.toBe(list);
81+
expect(out[0]).toEqual({ title: '1' });
82+
expect(out[1]).toBe(b);
83+
expect(a).toEqual({ title: 1 });
84+
});
85+
});
86+
87+
describe('#29241 — what the door leaves alone', () => {
88+
const schema = {
89+
fields: {
90+
title: { type: 'text' },
91+
payload: { type: 'json' },
92+
amount: { type: 'number' },
93+
done: { type: 'boolean' },
94+
},
95+
};
96+
97+
it('a string, null, a missing key, an object and an array on a text field: the same row back', () => {
98+
for (const row of [{ title: '42' }, { title: null }, { title: undefined }, {}, { title: { a: 1 } }, { title: ['a'] }]) {
99+
expect(normalizeNumericStringValues(schema, row), JSON.stringify(row)).toBe(row);
100+
}
101+
});
102+
103+
it('a json, number or boolean field keeps its number or boolean', () => {
104+
const row = { payload: 42, amount: 42, done: true };
105+
expect(normalizeNumericStringValues(schema, row)).toBe(row);
106+
const flags = { payload: false, amount: 0, done: false };
107+
expect(normalizeNumericStringValues(schema, flags)).toBe(flags);
108+
});
109+
110+
it('a declared field the validator never judges is not rewritten (a SKIP_FIELDS name, a system field)', () => {
111+
const own = { fields: { id: { type: 'text' }, tenant_tag: { type: 'text', system: true } } };
112+
const row = { id: 42, tenant_tag: 7 };
113+
expect(normalizeNumericStringValues(own, row)).toBe(row);
114+
});
115+
116+
it('an undeclared key is not rewritten', () => {
117+
const row = { nope: 42 };
118+
expect(normalizeNumericStringValues(schema, row)).toBe(row);
119+
});
120+
});
121+
122+
/** The driver-facing half: what each engine door hands the driver. */
123+
describe('#29241 — write path end to end: the driver receives the judged string', () => {
124+
async function boot() {
125+
const engine = new ObjectQL();
126+
const writes: unknown[] = [];
127+
const rows = new Map<string, Record<string, unknown>>([['rec_1', { id: 'rec_1', title: 'orig' }]]);
128+
engine.registerDriver({
129+
name: 'recording', version: '0.0.0', supports: {},
130+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
131+
async execute() { return null; },
132+
async find() { return Array.from(rows.values()); },
133+
async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; },
134+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id: 'x' }; },
135+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
136+
async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; },
137+
async delete() { return true; }, async count() { return rows.size; },
138+
async bulkCreate(_o: string, data: Record<string, unknown>[]) { writes.push(...data); return data.map((d, i) => ({ ...d, id: `b${i}` })); },
139+
async bulkUpdate() { return []; }, async bulkDelete() {},
140+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
141+
async commit() {}, async rollback() {},
142+
} as any, true);
143+
await engine.init();
144+
engine.registry.registerObject({
145+
name: 'scalar_task', label: 'Task',
146+
fields: {
147+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
148+
title: { name: 'title', label: 'Title', type: 'text' },
149+
mail: { name: 'mail', label: 'Mail', type: 'phone' },
150+
amount: { name: 'amount', label: 'Amount', type: 'number' },
151+
code: { name: 'code', label: 'Code', type: 'text', unique: true },
152+
},
153+
} as any);
154+
return { engine, writes };
155+
}
156+
157+
const titles = (writes: unknown[]) => writes.map((w) => (w as Record<string, unknown>).title);
158+
159+
it('insert, insertMany, update by id and update by predicate', async () => {
160+
const { engine, writes } = await boot();
161+
await engine.insert('scalar_task', { title: 42, mail: 5551234, amount: 7 } as any);
162+
await engine.insert('scalar_task', [{ title: true }, { title: 'kept' }] as any);
163+
await engine.update('scalar_task', { title: false } as any, { where: { id: 'rec_1' } } as any);
164+
await engine.update('scalar_task', { title: 0 } as any, { multi: true } as any);
165+
expect(titles(writes)).toEqual(['42', 'true', 'kept', 'false', '0']);
166+
for (const w of writes) {
167+
const title = (w as Record<string, unknown>).title;
168+
expect(typeof title, JSON.stringify(w)).toBe('string');
169+
}
170+
// A text-family format type is written the same way; a number field keeps its number.
171+
expect(writes[0]).toEqual(expect.objectContaining({ mail: '5551234', amount: 7 }));
172+
});
173+
174+
it('a unique text field: 42 and "42" reach the driver as one value, so its unique key sees one value', async () => {
175+
// What any driver's unique check compares (driver-memory keys on the stored
176+
// value): base handed it 42 and "42", two keys; now one.
177+
const { engine, writes } = await boot();
178+
await engine.insert('scalar_task', { code: 42 } as any);
179+
await engine.insert('scalar_task', { code: '42' } as any);
180+
expect(writes.map((w) => (w as Record<string, unknown>).code)).toEqual(['42', '42']);
181+
});
182+
});

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,30 @@ function isPlainObjectValue(value: unknown): boolean {
945945
* driver copy. A value a `before*` hook writes after the door is the hook's
946946
* own and is not rewritten; the arm still judges it by the same grammar.
947947
*
948+
* ## [#29241] The string arm's half: a number or a boolean on a text field
949+
*
950+
* The mirror case on the string-stored side. The record validator's string arm
951+
* (`BOUNDED_STRING_FIELD_TYPES`, the spec's set: text, textarea, email, url,
952+
* phone, password, markdown, html, richtext, code, signature, qrcode) judges a
953+
* number or a boolean by its string reading, `String(value)`: `42` is bounded,
954+
* domain-checked and format-checked as `'42'`, `true` as `'true'`. The write
955+
* carried the raw scalar, so driver-memory stored and read back the number `42`
956+
* while a TEXT column hands back a string: one write, two stored shapes, and
957+
* an equality filter or a sort that differs by driver. This writes the string
958+
* the arm judged, on the same fields the numeric half reads (the validator's
959+
* walk, `readonly` included). A single write is admitted or refused exactly as
960+
* before; what changes is the stored form, and with it every answer that reads
961+
* the stored value. A `unique` text field's key now compares the string, so
962+
* `42` after `"42"` is refused as a duplicate (and on SQLite, which stored
963+
* `true` and `1` both as `'1.0'`, those two no longer collide), and a filter
964+
* comparing the field to the number `42` no longer matches a row written as
965+
* `42`.
966+
*
967+
* ⛔ Not touched: a string (judged and written as itself), `null` and a missing
968+
* key, a plain object or an array (the arm refuses them, #29133), every other
969+
* non-scalar, and every field outside the set — `json` and the structured
970+
* types, the number and boolean fields, select and the reference types.
971+
*
948972
* Same contract as {@link normalizeBlankTypedValues}: one record or an array of
949973
* them, pure — the same reference comes back when nothing changed, else a
950974
* shallow copy (per row, and a copied array).
@@ -970,17 +994,33 @@ function normalizeNumericStringRow(fields: Record<string, FieldDef>, row: unknow
970994
if (!isPlainRecord(row)) return row;
971995
let out: Record<string, unknown> | undefined;
972996
for (const [name, value] of Object.entries(row)) {
973-
if (typeof value !== 'string') continue;
997+
const isString = typeof value === 'string';
998+
if (!isString && !isStringReadScalar(value)) continue;
974999
// Own-property: a field name may be `constructor` / `valueOf`.
9751000
const def = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined;
976-
if (!def || !isJudgedNumberType(def.type) || !isInReadonlyScope(name, def, 'include')) continue;
977-
const n = parseNumericString(value);
978-
if (n === undefined) continue;
979-
(out ??= { ...row })[name] = n;
1001+
if (!def || !isInReadonlyScope(name, def, 'include')) continue;
1002+
if (isString) {
1003+
if (!isJudgedNumberType(def.type)) continue;
1004+
const n = parseNumericString(value);
1005+
if (n === undefined) continue;
1006+
(out ??= { ...row })[name] = n;
1007+
} else if (BOUNDED_STRING_FIELD_TYPES.has(def.type)) {
1008+
// [#29241] The string arm's reading of a number or a boolean.
1009+
(out ??= { ...row })[name] = String(value);
1010+
}
9801011
}
9811012
return out ?? row;
9821013
}
9831014

1015+
/**
1016+
* [#29241] A number or a boolean: the scalars the record validator's string arm
1017+
* judges by their string reading (`String(value)`), so the door writes that
1018+
* string into a text-family field ({@link normalizeNumericStringValues}).
1019+
*/
1020+
function isStringReadScalar(value: unknown): value is number | boolean {
1021+
return typeof value === 'number' || typeof value === 'boolean';
1022+
}
1023+
9841024
/**
9851025
* Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`,
9861026
* or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a
@@ -1225,7 +1265,9 @@ function validateOne(
12251265
// stored value for this class and neither has a string reading the author
12261266
// meant, so the arm refuses it with the number arm's #20309 posture: never
12271267
// silently alter a value. It is a SHAPE test, so it runs ahead of
1228-
// `shapeOnly`. A number or a boolean keeps its string reading; a
1268+
// `shapeOnly`. A number or a boolean keeps its string reading, and
1269+
// [#29241] `normalizeNumericStringValues` has already written that string
1270+
// into the payload at the door, so the driver stores what was judged; a
12291271
// filter-operator object was already refused above, with its own sentence.
12301272
if (isPlainObjectValue(value) || Array.isArray(value)) {
12311273
return fail(

0 commit comments

Comments
 (0)