Skip to content

Commit b391feb

Browse files
fix(objectql)!: an option field stores the option code string a number or boolean was judged by, and refuses any object (#29562) (#29638)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VCmqM8tUaCACEK5ub61zmo)_ Fixes #29562 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none. No file under `skills/**` or `content/docs/**` teaches writing a number, a boolean or a non-plain object into an option field; a `git grep` of both trees for option fields shows option codes written as strings only. Measured on `origin/main` @ `d3175d59` (base) and on this branch (head), the engine and REST doors, SQLite and driver-memory (table under Acceptance notes): a `unique` option field now refuses `3` after `"3"` (and `true` after `"true"`), and a `Date`, `Map`, `Set` or class instance on an option field is now refused. Those are the narrowing. On SQLite, `true` after `1` on a unique option field used to be refused (both stored `"1.0"`) and now lands: the widening half. The metadata protocol is untouched (`none`): no key, value or metadata type an author writes moves. ## 维护者速读 - **改了什么**:选项类字段(单选 select / radio,多选 multiselect / checkboxes / tags,及 `multiple: true` 的 select / radio)收到数字或布尔值时,改为存储校验器判定时用的字符串(`3` 存为 `"3"`,`true` 存为 `"true"`,多选逐个元素同理);`Date`、`Map`、类实例等非普通对象一律以 `invalid_type` 拒绝。 - **为什么改**:规范规定选项字段的值是字符串选项码,但写入时把原始数字直接交给驱动:内存驱动存成数字 `3`,SQLite 存成 `"3.0"`,与存 `"3"` 的行分裂成两个值,筛选、分组、唯一约束因驱动而异。这是 #29241 文本类字段修复的选项类孪生。 - **风险与回滚**:同一次写入的接受/拒绝结果不变;变化在于唯一选项字段上 `3` 与 `"3"` 现在冲突(409),非普通对象被拒,以及用数字 `3` 作筛选值不再命中新写入的行(筛选侧由 #29486 决定,本 PR 不动)。旧行不改写。回滚即 revert 本 PR。 - **先看**:① `packages/objectql/src/validation/record-validator.ts` 的 `normalizeNumericStringRow`(选项分支)与 `isObjectValue`;② 单选分支与多选元素分支的形状判定;③ `packages/rest/src/rest-data-option-scalar-value.test.ts`(真实 SQLite)。 - **两分钟验证**:在已构建的工作区跑 `pnpm --filter @objectstack/rest exec vitest run src/rest-data-option-scalar-value.test.ts`,预期 10/10 通过:`POST { stage: 3 }` 的物理列为 `"3"`,唯一字段先 `3` 后 `"3"` 第二次为 409。 - **需要你做的**:无,席位落地 ## What changes - **The door writes the judged string** (`normalizeNumericStringRow` in `packages/objectql/src/validation/record-validator.ts`, the function the #29241 text-family half lives in, reusing its `isStringReadScalar` and `String(value)`). On an option-family field (`SINGLE_OPTION_TYPES` ∪ `MULTI_OPTION_TYPES`, the spec's two sets, read and not re-listed), a number or a boolean is written as `String(value)`. On a multi-value option field, each number or boolean element is written the same way, and a lone scalar is written as its string before the engine wraps it. It runs where the text half runs: insert, insertMany, update by id, update by predicate and the `validate()` dry run, `readonly` values included (their shape is judged on every write). Not touched: strings, `null`, objects (refused), an array on a single-value field (refused), and non-scalar elements. - **The option arms refuse any object** (`isObjectValue`). The single-value select / radio arm and the multi-value element test used `isPlainObjectValue || Array.isArray`, which is prototype-strict, so a `Date`, `Map`, `Set` or class instance fell through to membership. With no `options` it was stored as sent; with `options` it was refused `invalid_option` by the accident of its string reading. Now each is refused `invalid_type` with the #29244 value-shape sentence ("expected one option value, received an object", or "… an element that is an object"). It is never stringified, per the seat ruling 6587559938. The text arm keeps its own posture; this rule is on option-family fields only. - **Header realigned** (should-fix S3 of review 6585290571): the "Rules applied" list now follows `validateOne`'s actual order (required, then the operator rule, then the string arm with its #29133 / #29241 shape rules, numbers, boolean, temporal, select, multi-value, value shape) and names the #29244 and #29562 select rules. - **Changeset** `.changeset/29562-option-field-scalar-stored-string.md`: `@objectstack/objectql` patch, `fix(objectql)!:`, the two declaration lines, ADR-0087 `not-required`, a BREAKING banner with FROM → TO and the caller fix, the `#29241` shape. - No `packages/spec/**`, no new error code, no filter-door change, no `protocol.ts`. ## Tests - `packages/objectql/src/validation/record-validator.option-scalar-value.test.ts` (new): the door on all five option types and on `multiple: true` select / radio, per scalar (`3`, `0`, `-1.5`, `true`, `false`) and per element; membership on the written string (a declared code `"3"` admits `3`, codes a/b still refuse it); readonly fields written too; the untouched cases; the object refusal (Date, Map, Set, class instance) on single-value and element, insert and update, readonly; the engine doors end to end with a recording driver (insert, insertMany, update by id, update by predicate), the non-plain refusal with no driver write, and the unique key seeing one value. - `packages/rest/src/rest-data-option-scalar-value.test.ts` (new): REST POST / PATCH on a real SQLite `SqlDriver`, physical column read past every engine coercion: single, radio, declared code, multi-value elements and a lone scalar; `invalid_option` still answered with nothing written; the unique narrowing (`3`/`"3"`, `"3"`/`3`, `true`/`"true"` → 409) and SQLite's `true`/`1` widening; and a **filter-face pin that records today's answer only** (`{ stage: 3 }` matches no row once both rows hold `"3"`), naming #29486. - **Forced test edit (one):** `record-validator.select-shape.test.ts`, the `{ free: 3 }` control in `what the rule leaves alone`. It pinned the number admission as present behaviour and named this card. It is restated as the new rule: still admitted by the validator, and written by the door as `{ free: "3" }`. - driver-memory: no new test consumer (its test consumers are a closed census, `check:driver-memory-census`). The memory column was measured (table below), and the recording-driver pin shows what every driver receives. ## Acceptance notes 1. **Step 0 table**, base `d3175d59` vs head, each row on engine insert / update by id / update by predicate and REST POST / PATCH, both drivers (1100 cells per side; the two drivers agree on every answer): | field · value | base memory | base SQLite | head (both) | |:--|:--|:--|:--| | free select / radio · `3` / `true` | `3` / `true` | `"3.0"` / `"1.0"` | `"3"` / `"true"` | | select, codes `"3"`,`"true"` · `3` / `true` | admitted, `3` / `true` | admitted, `"3.0"` / `"1.0"` | admitted, `"3"` / `"true"` | | select, codes a/b · `3` | `invalid_option` | `invalid_option` | `invalid_option` | | multi option (5 types) · `[3]` or lone `3` | `[3]` | `[3]` (JSON) | `["3"]` | | free select / radio · Date / Map / instance (engine) | stored as sent | epoch / `"{}"` / JSON text | `invalid_type` | | declared select / multiselect · Date / Map / instance | `invalid_option` | `invalid_option` | `invalid_type` | | free multi option · `[Date]` / `[Map]` / `[instance]` | ISO text / `{}` / JSON | same | `invalid_type` | | unique select · `3` then `"3"` (and reverse; `true` then `"true"`) | 201, 201 | 201, 201 | 201, 409 | | unique select · `true` then `1` | 201, 201 | 201, 409 | 201, 201 | A non-plain object cannot reach REST (a JSON body carries none); it reaches the engine doors (flows, hooks, server code). 2. **Writer census: 0 product writers.** A log-only probe at the door (every option-family field receiving a number, a boolean, a non-plain object, or an array holding one) in a second worktree at base `d3175d59`, objectql rebuilt there, then each suite with the probe's env set: objectql (all), rest (all), service-automation (all), every package under `packages/plugins/**`, runtime (local tier) and the full dogfood battery (397 files, 3277 tests). Hits: 0 in every leg. Positive controls hit: the Step 0 probe script against that tree's `dist` (658 lines) and a throwaway vitest file against its `src` (1 line). So no product writer and no existing test sends a number or boolean to an option field through the engine, and the seat's ruling A needs no re-rule. 3. **Filter face, measured and not changed** (#29486's decision box). A row written `9` and a row written `"9"`: base `{ free: 9 }` answered the `9` row only, and `{ free: "9" }` the `"9"` row only; head `{ free: 9 }` answers no row and `{ free: "9" }` both, on both drivers. Named in open_questions. 4. **Stored rows** are not rewritten: an old raw `3` (driver-memory with persistence) or SQLite `"3.0"` keeps reading as it is and does not collide with a new `"3"`. Whether `os migrate value-shapes` should carry option fields is an open question, not built. 5. **Out of scope, noted:** the text arm still admits a non-plain object (a `Date` on a `text` field passes as its string reading and is stored as sent); this card's ruling is option-family only. carrier: none. ## Verification Head `6f164722`: the fix commits `271c2665` (code) and `ca133d57` (tests, changeset) plus a merge of `origin/main` @ `0ab4a09e` (no conflict; main's objectql moves are in `engine.ts` / `engine-reference-name-visible.ts`, none in `record-validator.ts`). Every run below is on `6f164722` after a full `turbo run build --filter='./packages/**' --filter='./examples/**'` under `os-verify-lock` (VERDICT command-exit 0), unless marked. <details> <summary>Suites on the head (each under the lock, VERDICT command-exit 0)</summary> ```text objectql vitest run (all) Test Files 542 passed (542) · Tests 10577 passed · typecheck + check:test-typecheck OK rest vitest run (all) Test Files 384 passed (384) · Tests 7405 passed | 352 skipped · typecheck OK driver-memory test Test Files 74 passed (74) · Tests 1767 passed driver-sql test Test Files 236 passed | 12 skipped (248) · Tests 3847 passed | 214 skipped runtime vitest --project local Test Files 527 passed (527) · Tests 8503 passed | 19 skipped · typecheck OK dogfood pnpm --filter @objectstack/dogfood test Test Files 398 passed | 3 skipped (401) · Tests 3280 passed | 28 skipped ``` </details> <details> <summary>Ablations (each restored: blob == HEAD, `git diff HEAD` empty)</summary> ```text A single-value arm: isObjectValue(value) -> isPlainObjectValue(value) || Array.isArray(value) objectql option-scalar-value.test.ts Tests 35 failed | 49 passed (84) e.g. "free: refuses a Date on insert as invalid_type": expected [] to deeply equal [['free','invalid_type']] B element arm: value.find(isObjectValue) -> value.find((v) => isPlainObjectValue(v) || Array.isArray(v)) objectql option-scalar-value.test.ts Tests 21 failed | 63 passed (84) e.g. "free_multi: refuses an element that is a Date as invalid_type" C door: isOptionType -> false (marker __ablation_29562__), objectql rebuilt, dist preflight found the marker objectql option-scalar-value + select-shape Tests 25 failed | 102 passed (127) rest rest-data-option-scalar-value.test.ts Tests 8 failed | 2 passed (10) "3, then \"3\"": the second row landed holding "3" beside "3.0", expected 201 to be 409 "\"3\", then 3": the second row landed holding "3.0", expected 201 to be 409 "true, then 1": 409 UNIQUE_VIOLATION, expected 201 (SQLite's base "1.0" collision) restored, objectql rebuilt, ablation-dist-preflight --absent: marker absent from all 14 built files, tree clean ``` </details> <details> <summary>Gates and lint (after the last commit, on 6f16472)</summary> ```text node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 61 derived each run, exit captured before any pipe; --ran reconciliation: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN two first-run exit 3 (prerequisite, not a finding), re-run exit 0: check-plugin-teardown-shape --self-test (fixture commit 621a487 fetched) check:type-check-debt (first run overlapped a closure build; re-measure OK, 0 raw tsc errors above ledger) eslint packages/objectql packages/rest --no-inline-config exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSLV8FibVzx9B1ZHNn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14df37f commit b391feb

5 files changed

Lines changed: 626 additions & 33 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 select, radio, multiselect, checkboxes or tags field is stored as the option code string the validator judged, and any object value is refused
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 on an option field, and the option arms refuse every object rather than only a plain one. The narrowed faces are runtime answers to a data write (a second write that the stored value now collides with on a unique option field, and a Date, Map or class instance now refused), 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 two cases. (1) A `unique` option field. FROM: `{ code: 3 }` and then `{ code: "3" }` (or `true` and then `"true"`) both answered `201` and stored two rows, on driver-memory and on SQLite. TO: both writes store `"3"`, so the second one is refused `409`. The fix for a caller that hits it: the two writes are the same option; send the code once, or update the existing row. In the other direction, SQLite used to refuse `true` after `1` (both were stored as `"1.0"`); both now land as two different strings (`"true"` and `"1"`). (2) An object that is not a plain object. FROM: a `Date`, a `Map`, a `Set` or a class instance written to a single-value `select` / `radio` with no `options` was stored as sent (SQLite as its epoch or JSON text), and as an element of a `multiple: true` select, `multiselect`, `checkboxes` or `tags` array it was stored as its JSON form (a `Date` as its ISO text, a `Map` as `{}`). TO: each is refused `400 VALIDATION_FAILED` with the field's `invalid_type` ("expected one option value, received an object", or "expected an array of option values, received an element that is an object"), the sentence a plain object already got. On a select whose declared `options` do not include it, such an object used to answer `invalid_option` and now answers `invalid_type`, because its shape is wrong before its membership is. The fix: send the option code string itself.
13+
14+
The spec's stored value for an option field is an option code, a string, or an array of them, and the record validator judges a number or a boolean there by its string reading: `3` is checked against the declared options as `"3"`, `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 `3` and the boolean `true`, SQLite stored the TEXT `"3.0"` and `"1.0"`, and a multi-value field stored `[3]` on both. The write door now writes the judged string into the payload, beside the same rewrite on text-family fields: on insert, insertMany, update by id, update by predicate and the `validate()` dry run, and per element on a multi-value option field (a lone `3` there is stored as `["3"]`).
15+
16+
What you will see: an option column now stores and reads back `"3"` and `"true"` where it held `3`, `true`, `"3.0"` or `"1.0"`, and `["3"]` where it held `[3]`. A number or boolean write is answered as before: the same payloads are admitted and refused, and a declared code `"3"` still admits `3`. Hooks, formulas and flows now see the string. A filter that compares an option field to the number `3` (or to a boolean) no longer matches a row written as `3`, because that row is now stored as `"3"`: filter with the string. The filter side is tracked separately. Rows already stored are not rewritten, so an old SQLite `"3.0"` row or a raw `3` in a driver-memory store with `persistence` enabled keeps reading as it is and does not collide with a new `"3"`. Unchanged: strings, `null`, missing keys, plain objects and arrays on a single-value field (refused), option membership, and every field outside the option and text families.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29562] An option-family field (`select` / `radio`, `multiselect` /
5+
* `checkboxes` / `tags`, and a `multiple: true` select / radio) stores an
6+
* option code: a string, or an array of them (the spec's `valueSchemaFor`).
7+
*
8+
* Measured on `origin/main` @ `d3175d59` before the change, on driver-memory
9+
* and on SQLite, through the engine (insert, update by id, update by
10+
* predicate) and REST `POST` / `PATCH`:
11+
*
12+
* field · value base memory base SQLite head (both)
13+
* free select · 3 3 "3.0" "3"
14+
* free select · true true "1.0" "true"
15+
* select, code "3" · 3 3 "3.0" "3"
16+
* select, codes a/b · 3 invalid_option invalid_option invalid_option
17+
* multiselect / checkboxes / tags · [3] [3] [3] (JSON) ["3"]
18+
* free select · new Date(…) the Date its epoch, as TEXT invalid_type
19+
* free select · new Map(), a class inst. as sent "{}", its JSON invalid_type
20+
* multiselect · [new Date(…)] its ISO text its ISO text invalid_type
21+
* multiselect · [new Map()] [{}] [{}] invalid_type
22+
*
23+
* The option arms judged a number or a boolean by `String(value)` while the
24+
* write carried the raw scalar; the door now writes that string
25+
* (`normalizeNumericStringValues`, the #29241 text-family half's sibling), on
26+
* insert, insertMany, update by id and update by predicate. The arms refuse
27+
* any object, not only a plain one (`isPlainObjectValue` is prototype-strict,
28+
* so a `Date`, a `Map` or a class instance fell through to membership).
29+
*
30+
* A number or boolean write is admitted or refused exactly as before. What
31+
* moves (Clause-② yes, narrowing): a `unique` option field's key compares the
32+
* string, so `3` after `"3"` collides (pinned at the driver door below and
33+
* against SQLite in `rest-data-option-scalar-value.test.ts`), and an object
34+
* that is not plain is refused.
35+
*/
36+
37+
import { describe, it, expect } from 'vitest';
38+
import { SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES } from '@objectstack/spec/data';
39+
import { normalizeNumericStringValues, validateRecord, validateRecordInScope, ValidationError } from './record-validator.js';
40+
import { ObjectQL } from '../engine.js';
41+
42+
const SCALARS: ReadonlyArray<readonly [unknown, string]> = [
43+
[3, '3'],
44+
[0, '0'],
45+
[-1.5, '-1.5'],
46+
[true, 'true'],
47+
[false, 'false'],
48+
];
49+
50+
class Widget {
51+
k = 1;
52+
}
53+
54+
const NON_PLAIN: ReadonlyArray<readonly [string, () => unknown]> = [
55+
['a Date', () => new Date('2026-01-02T00:00:00Z')],
56+
['a Map', () => new Map([['a', 1]])],
57+
['a Set', () => new Set(['a'])],
58+
['a class instance', () => new Widget()],
59+
];
60+
61+
const schema = {
62+
fields: {
63+
free: { type: 'select', label: 'Free' },
64+
free_radio: { type: 'radio', label: 'Free radio' },
65+
decl3: { type: 'select', label: 'Score', options: [{ value: '3' }, { value: 'true' }, { value: 'a' }] },
66+
declx: { type: 'select', label: 'Stage', options: [{ value: 'a' }, { value: 'b' }] },
67+
free_multi: { type: 'select', label: 'Free multi', multiple: true },
68+
ms: { type: 'multiselect', label: 'MS' },
69+
msd: { type: 'multiselect', label: 'Scores', options: [{ value: '3' }, { value: 'true' }] },
70+
tags: { type: 'tags', label: 'Tags' },
71+
boxes: { type: 'checkboxes', label: 'Boxes' },
72+
},
73+
} as const;
74+
75+
function errorsOf(data: Record<string, unknown>, mode: 'insert' | 'update' = 'insert') {
76+
try {
77+
validateRecord(schema as any, data, mode);
78+
} catch (e) {
79+
if (e instanceof ValidationError) return e.fields;
80+
throw e;
81+
}
82+
return [];
83+
}
84+
85+
describe('#29562 — the door writes the string an option arm judged', () => {
86+
it('reads the spec sets, which still hold the five types this pin was written against', () => {
87+
expect([...SINGLE_OPTION_TYPES, ...MULTI_OPTION_TYPES].sort()).toEqual(
88+
['checkboxes', 'multiselect', 'radio', 'select', 'tags'],
89+
);
90+
});
91+
92+
for (const type of SINGLE_OPTION_TYPES) {
93+
for (const [input, stored] of SCALARS) {
94+
it(`${type}: ${JSON.stringify(input)} is written as ${JSON.stringify(stored)}`, () => {
95+
const out = normalizeNumericStringValues({ fields: { f: { type } } }, { f: input });
96+
expect(out.f).toBe(stored);
97+
});
98+
}
99+
}
100+
101+
for (const def of [
102+
...[...MULTI_OPTION_TYPES].map((type) => ({ type })),
103+
{ type: 'select', multiple: true },
104+
{ type: 'radio', multiple: true },
105+
]) {
106+
const label = def.type + ('multiple' in def ? ' multiple' : '');
107+
it(`${label}: each number or boolean element is written as its string`, () => {
108+
const input = [3, 'a', true, null, -1.5, false];
109+
const out = normalizeNumericStringValues({ fields: { f: def } }, { f: input });
110+
expect(out.f).toEqual(['3', 'a', 'true', null, '-1.5', 'false']);
111+
expect(input).toEqual([3, 'a', true, null, -1.5, false]);
112+
});
113+
it(`${label}: a lone number is written as its string (the engine wraps it)`, () => {
114+
expect(normalizeNumericStringValues({ fields: { f: def } }, { f: 3 }).f).toBe('3');
115+
});
116+
}
117+
118+
it('membership is judged on that string: a declared code "3" admits 3, and codes a/b still refuse it', () => {
119+
const row = normalizeNumericStringValues(schema as any, { decl3: 3, msd: [3, true] });
120+
expect(row).toEqual({ decl3: '3', msd: ['3', 'true'] });
121+
expect(errorsOf(row)).toEqual([]);
122+
const refused = errorsOf(normalizeNumericStringValues(schema as any, { declx: 3 }));
123+
expect(refused.map((e) => [e.field, e.code])).toEqual([['declx', 'invalid_option']]);
124+
});
125+
126+
it('a readonly option field is written too: its value is judged on every write', () => {
127+
const ro = { fields: { kind: { type: 'select', readonly: true }, kinds: { type: 'tags', readonly: true } } };
128+
expect(normalizeNumericStringValues(ro, { kind: 7, kinds: [7] })).toEqual({ kind: '7', kinds: ['7'] });
129+
});
130+
});
131+
132+
describe('#29562 — what the door leaves alone on an option field', () => {
133+
it('a string, null, a missing key, an object and an array on a single select: the same row back', () => {
134+
for (const row of [{ free: '3' }, { free: null }, { free: undefined }, {}, { free: { a: 1 } }, { free: [3] }, { free: new Date(0) }]) {
135+
expect(normalizeNumericStringValues(schema as any, row), String(row.free)).toBe(row);
136+
}
137+
});
138+
139+
it('an array of strings on a multi-value option field: the same row, and the same array, back', () => {
140+
const row = { ms: ['a', 'b'], tags: [] };
141+
expect(normalizeNumericStringValues(schema as any, row)).toBe(row);
142+
});
143+
144+
it('an array on a multi-value lookup is not an option field: left as sent', () => {
145+
const own = { fields: { owners: { type: 'lookup', reference: 'user', multiple: true } } };
146+
const row = { owners: [3] };
147+
expect(normalizeNumericStringValues(own, row)).toBe(row);
148+
});
149+
150+
it('a field outside the option and text sets keeps its number: json, number, boolean', () => {
151+
const own = { fields: { payload: { type: 'json' }, amount: { type: 'number' }, done: { type: 'boolean' } } };
152+
const row = { payload: [3], amount: 3, done: true };
153+
expect(normalizeNumericStringValues(own, row)).toBe(row);
154+
});
155+
});
156+
157+
describe('#29562 — an option arm refuses any object, not only a plain one', () => {
158+
for (const field of ['free', 'free_radio', 'decl3', 'declx'] as const) {
159+
for (const [shape, make] of NON_PLAIN) {
160+
for (const mode of ['insert', 'update'] as const) {
161+
it(`${field}: refuses ${shape} on ${mode} as invalid_type`, () => {
162+
const errs = errorsOf({ [field]: make() }, mode);
163+
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
164+
expect(errs[0].constraint).toMatchObject({
165+
type: schema.fields[field].type,
166+
detail: 'expected one option value, received an object',
167+
});
168+
});
169+
}
170+
}
171+
}
172+
173+
for (const field of ['free_multi', 'ms', 'msd', 'tags', 'boxes'] as const) {
174+
for (const [shape, make] of NON_PLAIN) {
175+
it(`${field}: refuses an element that is ${shape} as invalid_type`, () => {
176+
const errs = errorsOf({ [field]: ['3', make()] });
177+
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
178+
expect(errs[0].constraint).toMatchObject({
179+
detail: 'expected an array of option values, received an element that is an object',
180+
});
181+
});
182+
}
183+
}
184+
185+
it('says the field and the received shape in the message', () => {
186+
const errs = errorsOf({ free: new Date(0) });
187+
expect(errs[0].message).toContain('Free');
188+
expect(errs[0].message).toContain('expected one option value, received an object');
189+
});
190+
191+
it('refuses it on a readonly select too (a shape arm, reached under the readonly scope)', () => {
192+
const ro = { fields: { kind: { type: 'select', label: 'Kind', readonly: true } } };
193+
let err: unknown = null;
194+
try {
195+
validateRecordInScope(ro, { kind: new Date(0) }, 'insert', 'include');
196+
} catch (e) {
197+
err = e;
198+
}
199+
expect(err).toBeInstanceOf(ValidationError);
200+
expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['kind', 'invalid_type']]);
201+
});
202+
});
203+
204+
/** The driver-facing half: what each engine door hands the driver. */
205+
describe('#29562 — write path end to end: the driver receives the judged string', () => {
206+
async function boot() {
207+
const engine = new ObjectQL();
208+
const writes: Record<string, unknown>[] = [];
209+
const rows = new Map<string, Record<string, unknown>>([['rec_1', { id: 'rec_1', stage: 'orig' }]]);
210+
engine.registerDriver({
211+
name: 'recording', version: '0.0.0', supports: {},
212+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
213+
async execute() { return null; },
214+
async find() { return Array.from(rows.values()); },
215+
async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; },
216+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id: 'x' }; },
217+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
218+
async updateMany(_o: unknown, _a: unknown, data: Record<string, unknown>) { writes.push(data); return 0; },
219+
async delete() { return true; }, async count() { return rows.size; },
220+
async bulkCreate(_o: string, data: Record<string, unknown>[]) { writes.push(...data); return data.map((d, i) => ({ ...d, id: `b${i}` })); },
221+
async bulkUpdate() { return []; }, async bulkDelete() {},
222+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
223+
async commit() {}, async rollback() {},
224+
} as any, true);
225+
await engine.init();
226+
engine.registry.registerObject({
227+
name: 'option_scalar', label: 'Option scalar',
228+
fields: {
229+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
230+
stage: { name: 'stage', label: 'Stage', type: 'select' },
231+
mood: { name: 'mood', label: 'Mood', type: 'radio' },
232+
labels: { name: 'labels', label: 'Labels', type: 'multiselect' },
233+
picks: { name: 'picks', label: 'Picks', type: 'select', multiple: true },
234+
code: { name: 'code', label: 'Code', type: 'select', unique: true },
235+
},
236+
} as any);
237+
return { engine, writes, rows };
238+
}
239+
240+
it('insert, insertMany, update by id and update by predicate', async () => {
241+
const { engine, writes } = await boot();
242+
await engine.insert('option_scalar', { stage: 3, mood: true, labels: [3, 'x'], picks: 7 } as any);
243+
await engine.insert('option_scalar', [{ stage: false }, { stage: 'kept' }] as any);
244+
await engine.update('option_scalar', { stage: 1.5, labels: [false] } as any, { where: { id: 'rec_1' } } as any);
245+
await engine.update('option_scalar', { stage: 0, picks: [true, 2] } as any, { multi: true } as any);
246+
expect(writes.map((w) => w.stage)).toEqual(['3', 'false', 'kept', '1.5', '0']);
247+
expect(writes[0]).toEqual(expect.objectContaining({ mood: 'true', labels: ['3', 'x'], picks: ['7'] }));
248+
expect(writes[3]).toEqual(expect.objectContaining({ labels: ['false'] }));
249+
expect(writes[4]).toEqual(expect.objectContaining({ picks: ['true', '2'] }));
250+
});
251+
252+
it('a non-plain object is refused on insert, update and multi-update, and the driver sees no write', async () => {
253+
const { engine, writes, rows } = await boot();
254+
for (const [field, run] of [
255+
['stage', () => engine.insert('option_scalar', { stage: new Date(0) } as any)],
256+
['stage', () => engine.update('option_scalar', { stage: new Map() } as any, { where: { id: 'rec_1' } } as any)],
257+
['stage', () => engine.update('option_scalar', { stage: new Widget() } as any, { multi: true } as any)],
258+
['labels', () => engine.insert('option_scalar', { labels: [new Date(0)] } as any)],
259+
['picks', () => engine.update('option_scalar', { picks: ['a', new Map()] } as any, { where: { id: 'rec_1' } } as any)],
260+
] as const) {
261+
const err = await run().then(() => null, (e: unknown) => e as ValidationError);
262+
expect(err).toBeInstanceOf(ValidationError);
263+
expect(err!.code).toBe('VALIDATION_FAILED');
264+
expect(err!.fields.map((f) => [f.field, f.code])).toEqual([[field, 'invalid_type']]);
265+
}
266+
expect(writes).toEqual([]);
267+
expect(rows.get('rec_1')).toEqual({ id: 'rec_1', stage: 'orig' });
268+
});
269+
270+
it('a unique select: 3 and "3" reach the driver as one value, so its unique key sees one value', async () => {
271+
// What any driver's unique check compares (driver-memory keys on the stored
272+
// value): base handed it 3 and "3", two keys; now one.
273+
const { engine, writes } = await boot();
274+
await engine.insert('option_scalar', { code: 3 } as any);
275+
await engine.insert('option_scalar', { code: '3' } as any);
276+
expect(writes.map((w) => w.code)).toEqual(['3', '3']);
277+
});
278+
});

‎packages/objectql/src/validation/record-validator.select-shape.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525

2626
import { describe, it, expect } from 'vitest';
27-
import { validateRecord, validateRecordInScope, ValidationError } from './record-validator.js';
27+
import { validateRecord, validateRecordInScope, normalizeNumericStringValues, ValidationError } from './record-validator.js';
2828
import { ObjectQL } from '../engine.js';
2929

3030
const schema = {
@@ -117,10 +117,11 @@ describe('#29244 — a multi-value option field refuses an element that is a pla
117117
describe('#29244 — what the rule leaves alone', () => {
118118
it('a scalar on a single-value select is judged as before', () => {
119119
expect(errorsOf({ free: 'anything', free_radio: 'x', decl: 'a' })).toEqual([]);
120-
// Present behaviour, not the contract: the spec's stored element for a
121-
// free-form select is a string, and #29562 is meant to move this pin (a
122-
// number or boolean on a single-value select).
120+
// [#29562] A number is judged by its string reading, and the write door
121+
// stores that string: `3` is admitted on a free select as the code `"3"`
122+
// (the door half is pinned in `record-validator.option-scalar-value.test.ts`).
123123
expect(errorsOf({ free: 3 })).toEqual([]);
124+
expect(normalizeNumericStringValues(schema, { free: 3 })).toEqual({ free: '3' });
124125
expect(errorsOf({ decl: 'z' }).map((e) => e.code)).toEqual(['invalid_option']);
125126
});
126127

0 commit comments

Comments
 (0)