Skip to content

Commit e832ddb

Browse files
fix(objectql)!: a select refuses a plain object or an array as its value, and as a multi-value element (#29244) (#29540)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9BsrKH5NsKzU1otmTa7SMs8)_ Fixes #29244 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs/**` teaches writing an object or an array into a `select` (a `git grep` of `skills/**` for select values shows only option-code examples); the data skill already authors a select's value as an option `value`. Clause-② measured on `origin/main` @ `04e0c2c0` (base) and on this branch (head): writes the base admitted are now refused (table under Why). Metadata-protocol stays `none`: no key, value or metadata type an author writes moves; the narrowed face is a data write's answer. > **BREAKING** for a data write that sends an object or an array where a select expects an option value. FROM: `{ stage: { a: 1 } }` or `{ stage: ["x"] }` on a single-value `select` / `radio` with no `options` answered `201` / `200` and stored the object or the array (SQLite as its JSON text); so did `{ stage: ["a"] }` on a select whose options include `a`, and `{ labels: [{ a: 1 }] }` or `{ labels: [["a"]] }` on a `multiple: true` select, `multiselect`, `checkboxes` or `tags` field. TO: each is refused `400 VALIDATION_FAILED` with the field's `invalid_type`, and nothing is stored. The fix for a caller: send the option code itself (`{ stage: "x" }`), or an array of codes on a multi-value field; structured data belongs in a `json` field. **Landing hold.** Seat 3's PR #29464 (same file, the blank path) lands first, per ruling #29346 B execution step 3. It is still a draft as this PR opens, so this PR is a draft too; it merges `origin/main` after #29464 lands and checks `merge-tree` is clean then. The two do not overlap: this diff does not touch `normalizeBlankTypedValues`, the blank/`''` arm or the `BOUNDED_STRING_FIELD_TYPES` string arm. ## 维护者速读 - **改了什么**:`select` / `radio` 单值字段收到对象或数组时,服务端写入口改为拒收(400 `VALIDATION_FAILED`,字段码 `invalid_type`),有无 `options` 都一样;多值选项字段(`multiple: true` 的 select、multiselect、checkboxes、tags)数组里夹着对象或嵌套数组的元素也拒收。 - **为什么改**:没声明 `options` 的自由选择字段原样把 `{a:1}`、`["x"]` 交给驱动存下,有选项的字段也因为 `String(["a"])` 等于 `"a"` 而把数组存进去;列表、筛选(筛选入口本来就拒收这种比较值)和报表都读不了这种值。 - **风险与回滚**:以前写得进去的对象/数组写入现在被拒;普查(objectql、rest、runtime、自动化流程、dogfood 全套,含示例应用种子数据)没有一个产品写入方这样写。已存的旧值不改写、不迁移。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/validation/record-validator.ts` 的 select/radio 单值分支与多值选项分支新增的形状检查;`packages/objectql/src/validation/record-validator.select-shape.test.ts`;`.changeset/29244-select-object-array-value.md`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/rest-data-select-shape.test.ts`,预期 7 个测试全绿(主干上 6 个红:POST 201、PATCH 200 并把对象/数组存进库)。 - **需要你做的**:无,席位落地(须等 #29464 先合并)。 ## What changes - **Single-value arm** (`select` / `radio` when not multi-valued, `record-validator.ts`): a plain object or an array is refused `invalid_type` with the ADR-0104 `invalid_value_shape` sentence ("expected one option value, received an object" / "an array"). It is a shape test, so it runs ahead of `shapeOnly` (a readonly value is reached, as the string arm's #29133 rule is) and ahead of option membership. The #5922 operator-object check still runs first and keeps its own sentence. - **Multi-value option branch** (`multiple: true` select/radio, `multiselect`, `checkboxes`, `tags`): an element that is a plain object or an array is refused `invalid_type` ("expected an array of option values, received an element that is an object" / "an array"), again ahead of `shapeOnly` and membership. An array of strings, numbers or booleans is judged as before; a value that is not an array keeps its existing `invalid_type_array` sentence; a lone scalar is still wrapped by the engine. - **The spec's contract.** `valueSchemaFor` (`packages/spec/src/data/field-value.zod.ts`) already says an option field's stored element is a declared code or, with no `options`, a string. This makes the write door agree for the two shapes the card names; it does not touch `packages/spec`. - **No new code or sentence.** `invalid_type` and the `invalid_value_shape` catalog entry are the ones the string arm and the operator rule already use. - **Scope note (declared, bounded in-place).** The card names the free-form single select and asks for a decision on `multiple: true`. The same arm admitted `["a"]` on a select with declared options (by its string reading), and the same multi-value branch admitted object elements on `multiselect` / `checkboxes` / `tags`. Both are the same defect class in the same branch, closed by the same mechanical rule with the same gates, so they are fixed here rather than as a type-by-type split (one rule per branch, not per type). Raised in the report for the seat. ## Why Measured with a recording driver behind the real engine (insert, update by id, update by predicate: all three doors answer the same in every row) and through the real REST routes on SQLite and driver-memory (POST create, PATCH update): | Field | Value | base | head | |---|---|---|---| | `select`, no options (and `radio`) | `{ a: 1 }` | ADMIT, driver got `{"a":1}`; REST 201/200, read back the object (SQLite: its JSON text) | 400 `invalid_type` | | `select`, no options (and `radio`) | `["x"]`, `[]` | ADMIT, driver got the array | 400 `invalid_type` | | `select`, options `a`/`b` | `["a"]` | ADMIT, driver got `["a"]`; REST 201, read back `["a"]` | 400 `invalid_type` | | `select`, options `a`/`b` | `{ a: 1 }` | 400 `invalid_option` | 400 `invalid_type` (code moves) | | `select` `multiple: true`, no options | `[{ a: 1 }]`, `[["x"]]` | ADMIT, stored | 400 `invalid_type` | | `select` `multiple: true`, options `a`/`b` | `[["a"]]` | ADMIT, stored | 400 `invalid_type` | | `multiselect` / `tags` / `checkboxes`, free | `[{ a: 1 }]`, `[["x"]]` | ADMIT | 400 `invalid_type` | | controls | `"x"`, `3`, `"a"`, `["x","y"]`, lone `"x"` on multi | ADMIT (as before) | ADMIT (as before) | **Writer census** (who writes an object or an array into a select today). A log-only probe in the select arms of `origin/main` @ `04e0c2c0`, built into objectql's `dist/`, recorded every object/array value while these suites ran: `@objectstack/objectql`, `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/service-automation` (flow nodes, including `create_record` / `update_record` and the screen paths) and the dogfood battery (which boots the example apps, `packages/apps`, the platform objects and their seeds). Positive control: the probe recorded the 36 writes of a one-off REST probe that reaches objectql through `dist/`, and the objectql probe's own writes. | Writer | Hits | |---|---| | Seeds and data in `examples/`, `packages/apps`, platform objects (dogfood, runtime) | 0 | | Flow / screen paths (`service-automation`, dogfood flow fixtures) | 0 | | Dogfood fixtures | 0 | | Tests | 2: the two #5922 pins re-homed below | Not exhaustive: plugin suites and other service packages were not run under the probe. **`unique` and filters.** A `unique` free-form select (REST, SQLite and driver-memory): base admitted `{a:1}` then `{a:1}` as 201, 409 and `{a:1}` then `{a:2}` as 201, 201; head refuses each write 400 before the unique check. `"x"` then `"x"` is 201, 409 on both. Filters do not move: the filter door already refused an object or array comparand on a select (`engine.find` `INVALID_FILTER`, `GET` 400) on base and head, and `{ free: "x" }` / `{ free: { $in: ["x"] } }` answer the same rows on both. ## Tests New files: - `packages/objectql/src/validation/record-validator.select-shape.test.ts`: single-value `select` (free and declared) and `radio` refuse object, `{}`, array and `[]` on insert and update with the shape detail; the message names field, type and shape; a readonly select is reached; an operator object keeps its own sentence; every multi-value option type refuses an object element and an array element; controls (scalars, arrays of scalars, membership, non-array sentence, null); end to end through the engine's insert, update by id and update by predicate with a recording driver that sees no write, and a scalar / array / wrapped lone scalar that still land. - `packages/rest/src/rest-data-select-shape.test.ts` (7 tests): real `ObjectQL` + SQLite `SqlDriver` + `RestServer`: POST and PATCH with `{a:1}`, `["x"]` on a free select and `[{a:1}]` on a `multiple: true` select answer 400 `VALIDATION_FAILED` / `invalid_type`, no row is written and the PATCHed row is unchanged (physical row read); control stores a string and an array of strings. Forced edits to existing tests: - `packages/objectql/src/validation/operator-object-write-value.test.ts`: `admits an UNDECLARED $-spelling` and `admits a plain non-operator object` (on `stage_free`, moved there by #29223) rested on exactly this admission. Restated as refusals that assert the SHAPE sentence and that the message does not name a filter operator: that is what the pins prove (the operator rule did not claim `$inn` or `{a:1}`). Moving them to `json` would pin nothing, because a `json` column skips the operator rule entirely, and no scalar-valued type is left that admits a plain object. <details><summary>Ablations</summary> Made with `scripts/ablation-replace.mjs` (anchor hit once, blob moved), objectql rebuilt with `--force`, marker proven in dist by `scripts/ablation-dist-preflight.mjs` ("marker present in 4 built files"), each restored by the tool (blob == HEAD, `git diff HEAD` empty), then a final rebuild with the marker absent from all 14 built files and both files green (69/69, 7/7). | Ablation | Red | |---|---| | A: the single-value shape test off | unit `Tests 29 failed \| 40 passed (69)` (every single-value refusal, both restated #5922 pins); REST `Tests 4 failed \| 3 passed (7)` (the four `stage` cases) | | B: the element test off | unit `Tests 11 failed \| 58 passed (69)` (every element case, the end-to-end refusal); REST `Tests 2 failed \| 5 passed (7)` (the two `labels` cases) | </details> ## Acceptance notes - **A number on a single-value select** (`{ stage: 3 }`) is still admitted and stored as the number, although the spec's stored element is a string. Out of this card (the card names objects and arrays); the same normalisation #29241 did for the text family would be its own card. - **Rows already stored** with an object or array value are not rewritten or re-judged; the `os migrate value-shapes` scan does not cover option fields. - **Not measured:** PostgreSQL, MySQL (same knex path), MongoDB (no daemon in the container). ## Verification Head `3ffa21c9`, branched from `origin/main` @ `04e0c2c0`. `origin/main` has moved since (to `390bd2ae`, no commit touching the five files here, `git merge-tree` clean); per the card delta this branch merges `origin/main` once #29464 lands, and the suites are re-run on that head then. CI runs the rest: see the PR's check-runs. <details><summary>Suites, typecheck, lint, gates</summary> | Run (head `3ffa21c9`, full-declaration build of the rest + dogfood closure) | Result | |---|---| | `pnpm --filter @objectstack/objectql typecheck` | exit 0 | | objectql full suite (`vitest run --maxWorkers=2`) | Test Files 536 passed (536) · Tests 10432 passed (10432) | | `pnpm --filter @objectstack/rest typecheck` | exit 0 | | rest full suite | Test Files 379 passed (379) · Tests 7352 passed, 352 skipped (7704) | | dogfood battery (`pnpm --filter @objectstack/dogfood test`, a narrowing) | Test Files 397 passed, 3 skipped (400) · Tests 3277 passed, 28 skipped (3305) | | `eslint --no-inline-config packages/objectql packages/rest` | exit 0 | | `dispatch-gates --commands` (61 derived), reconciled with `--ran` | 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN; all exit 0 | `check:dual-build-cjs-loads` and `check:type-check-debt` first answered exit 3 (PREREQUISITE NOT MET: seven packages outside the closure had no `dist/`, and the dogfood closure build was in flight); after building those seven, both exit 0. The plugin-teardown self-test needed `git fetch --depth 1 origin 621a487…`. The census runs (base + probe) built with `OS_SKIP_DTS=1`, so their dogfood leg also showed 6 reds in `rest-route-generation-tombstones.test.ts` (TS7016, no `.d.ts` for `@objectstack/spec/api`): a build-mode artifact of that run only; the head battery above, on a full-declaration build, is green. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce12ad2 commit e832ddb

5 files changed

Lines changed: 403 additions & 17 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a select refuses a plain object or an array as its value, and as an element of a multi-value select's array
6+
7+
Clause-②: no (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 record validator now refuses a write value whose shape is not the spec's stored shape for an option field (one option code, or an array of them). The narrowed face is a runtime answer to a data write, not anything an author wrote, so there is nothing for objectstack migrate meta to rewrite and no tombstone. Rows already stored are not rewritten or re-judged by this change, so no stored-row migration is prescribed either. -->
11+
12+
**BREAKING** for a data write that sends an object or an array where a select expects an option value. FROM: `{ stage: { a: 1 } }` or `{ stage: ["x"] }` on a single-value `select` / `radio` with no `options` answered `201` / `200` and stored the object or the array as sent (SQLite as its JSON text); so did `{ stage: ["a"] }` on a select whose options include `a`, and `{ labels: [{ a: 1 }] }` or `{ labels: [["a"]] }` on a `multiple: true` select, `multiselect`, `checkboxes` or `tags` field. 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 array"), on create, update by id, update by filter and the `validate()` dry run, and nothing is stored. The fix for a caller that hits it: send the option code itself (`{ stage: "x" }`), or an array of codes on a multi-value field (`{ labels: ["a"] }`); a value that really is structured data belongs in a `json` field.
13+
14+
One refusal changes its code: an object on a select whose declared `options` do not include it used to answer `invalid_option` and now answers `invalid_type`, because its shape is wrong before its membership is. Unchanged: strings and numbers on a single-value select, arrays of scalars on a multi-value one, option membership, a lone scalar wrapped on a multi-value field, `null`, and a filter-operator object (still refused with its own sentence). Rows already stored with an object or array value are not rewritten.

‎packages/objectql/src/validation/operator-object-write-value.test.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,20 +132,28 @@ describe('#5922 — what the rule deliberately leaves alone', () => {
132132
expect(errs[0].message).toMatch(/array/i);
133133
});
134134

135-
it('admits an UNDECLARED $-spelling — not a filter any backend executes', () => {
136-
// The rule is derived from the spec's operator vocabulary, not from the
137-
// `$` sigil. `{ $inn: … }` is out of scope by construction; judging every
138-
// plain object on a scalar field is #5922's option A, which this is not.
139-
//
140-
// [#29133] Pinned on a free-form `select`, not on `text`: the text family
141-
// now refuses every plain object by its own shape rule (see
142-
// `record-validator.text-shape.test.ts`), which would make this pin pass
143-
// for a reason other than the one it states.
144-
expect(() => validateRecord(schema, { stage_free: { $inn: ['a'] } }, 'update')).not.toThrow();
135+
// The rule is derived from the spec's operator vocabulary, not from the `$`
136+
// sigil: `{ $inn: … }` is out of its scope by construction, and judging every
137+
// plain object on a scalar field is #5922's option A, which this is not.
138+
//
139+
// [#29244] Restated as refusals. Every scalar-valued type now refuses a plain
140+
// object by its own SHAPE rule (the text family since #29133, the
141+
// single-value select since #29244), so no scalar field is left that admits
142+
// one, and moving the pins to `json` would pin nothing: that column skips
143+
// this rule entirely. What the pins still prove is that THIS rule did not
144+
// claim the object. The shape sentence answers, never the operator sentence.
145+
it('an UNDECLARED $-spelling is not named as a filter operator (the shape rule refuses it)', () => {
146+
const errs = errorsOf({ stage_free: { $inn: ['a'] } });
147+
expect(errs.map((e) => [e.field, e.code])).toEqual([['stage_free', 'invalid_type']]);
148+
expect(errs[0].constraint).toMatchObject({ detail: 'expected one option value, received an object' });
149+
expect(errs[0].message).not.toContain('filter operator');
145150
});
146151

147-
it('admits a plain non-operator object (same reason)', () => {
148-
expect(() => validateRecord(schema, { stage_free: { a: 1 } }, 'update')).not.toThrow();
152+
it('a plain non-operator object is not named as a filter operator (same reason)', () => {
153+
const errs = errorsOf({ stage_free: { a: 1 } });
154+
expect(errs.map((e) => [e.field, e.code])).toEqual([['stage_free', 'invalid_type']]);
155+
expect(errs[0].constraint).toMatchObject({ detail: 'expected one option value, received an object' });
156+
expect(errs[0].message).not.toContain('filter operator');
149157
});
150158
});
151159

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29244] A select's value is an option code: one for a single-value
5+
* `select` / `radio`, an array of them for a multi-value one. A plain object or
6+
* an array is refused as the single value, and as an element of the array.
7+
*
8+
* Measured on `origin/main` @ `04e0c2c0` before the change, with a recording
9+
* driver behind the real engine, on insert, update by id and update by
10+
* predicate alike:
11+
*
12+
* field value before after
13+
* select, no options { a: 1 } ADMIT, driver got { a: 1 } invalid_type
14+
* select, no options (and radio) ['x'] ADMIT, driver got ['x'] invalid_type
15+
* select, options a/b ['a'] ADMIT, driver got ['a'] invalid_type
16+
* select, options a/b { a: 1 } invalid_option invalid_type
17+
* select multiple, no options [{ a: 1 }] ADMIT invalid_type
18+
* select multiple, no options [['x']] ADMIT invalid_type
19+
* select multiple, options a/b [['a']] ADMIT, driver got [['a']] invalid_type
20+
* multiselect / tags / checkboxes, free [{ a: 1 }] ADMIT invalid_type
21+
*
22+
* A string, a number, an array of scalars on a multi-value field, and option
23+
* membership are unchanged.
24+
*/
25+
26+
import { describe, it, expect } from 'vitest';
27+
import { validateRecord, validateRecordInScope, ValidationError } from './record-validator.js';
28+
import { ObjectQL } from '../engine.js';
29+
30+
const schema = {
31+
fields: {
32+
free: { type: 'select', label: 'Free' },
33+
free_radio: { type: 'radio', label: 'Free radio' },
34+
decl: { type: 'select', label: 'Stage', options: [{ value: 'a' }, { value: 'b' }] },
35+
free_multi: { type: 'select', label: 'Free multi', multiple: true },
36+
decl_multi: { type: 'select', label: 'Stages', multiple: true, options: [{ value: 'a' }, { value: 'b' }] },
37+
ms: { type: 'multiselect', label: 'MS' },
38+
tags: { type: 'tags', label: 'Tags' },
39+
boxes: { type: 'checkboxes', label: 'Boxes' },
40+
},
41+
};
42+
43+
function errorsOf(data: Record<string, unknown>, mode: 'insert' | 'update' = 'insert') {
44+
try {
45+
validateRecord(schema, data, mode);
46+
} catch (e) {
47+
if (e instanceof ValidationError) return e.fields;
48+
throw e;
49+
}
50+
return [];
51+
}
52+
53+
describe('#29244 — a single-value select refuses a plain object or an array', () => {
54+
for (const field of ['free', 'free_radio', 'decl'] as const) {
55+
for (const [shape, value, received] of [
56+
['object', { a: 1 }, 'an object'],
57+
['empty object', {}, 'an object'],
58+
['array', ['a'], 'an array'],
59+
['empty array', [], 'an array'],
60+
] as const) {
61+
for (const mode of ['insert', 'update'] as const) {
62+
it(`${field}: refuses ${shape} on ${mode} as invalid_type`, () => {
63+
const errs = errorsOf({ [field]: value }, mode);
64+
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
65+
expect(errs[0].constraint).toMatchObject({
66+
type: schema.fields[field].type,
67+
detail: `expected one option value, received ${received}`,
68+
});
69+
});
70+
}
71+
}
72+
}
73+
74+
it('says the field, the type and the received shape in the message', () => {
75+
const errs = errorsOf({ free: { a: 1 } });
76+
expect(errs[0].message).toContain('Free');
77+
expect(errs[0].message).toContain('select');
78+
expect(errs[0].message).toContain('expected one option value, received an object');
79+
});
80+
81+
it('refuses the shape on a readonly select too (a shape arm, reached under the readonly scope)', () => {
82+
const ro = { fields: { kind: { type: 'select', label: 'Kind', readonly: true } } };
83+
let err: unknown = null;
84+
try {
85+
validateRecordInScope(ro, { kind: ['x'] }, 'insert', 'include');
86+
} catch (e) {
87+
err = e;
88+
}
89+
expect(err).toBeInstanceOf(ValidationError);
90+
expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['kind', 'invalid_type']]);
91+
});
92+
93+
it('a filter-operator object keeps its own, earlier sentence', () => {
94+
const errs = errorsOf({ free: { $in: ['a'] } }, 'update');
95+
expect(errs).toHaveLength(1);
96+
expect(errs[0].message).toContain('$in is a filter operator, not a value');
97+
});
98+
});
99+
100+
describe('#29244 — a multi-value option field refuses an element that is a plain object or an array', () => {
101+
for (const field of ['free_multi', 'decl_multi', 'ms', 'tags', 'boxes'] as const) {
102+
for (const [shape, value, received] of [
103+
['object element', ['a', { a: 1 }], 'an object'],
104+
['array element', [['a']], 'an array'],
105+
] as const) {
106+
it(`${field}: refuses an ${shape} as invalid_type`, () => {
107+
const errs = errorsOf({ [field]: value });
108+
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
109+
expect(errs[0].constraint).toMatchObject({
110+
detail: `expected an array of option values, received an element that is ${received}`,
111+
});
112+
});
113+
}
114+
}
115+
});
116+
117+
describe('#29244 — what the rule leaves alone', () => {
118+
it('a scalar on a single-value select is judged as before', () => {
119+
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).
123+
expect(errorsOf({ free: 3 })).toEqual([]);
124+
expect(errorsOf({ decl: 'z' }).map((e) => e.code)).toEqual(['invalid_option']);
125+
});
126+
127+
it('an array of scalars on a multi-value field is judged as before', () => {
128+
expect(errorsOf({ free_multi: ['x', 'y'], decl_multi: ['a', 'b'], ms: ['x'], tags: ['t'], boxes: [] })).toEqual([]);
129+
expect(errorsOf({ decl_multi: ['z'] }).map((e) => e.code)).toEqual(['invalid_option']);
130+
});
131+
132+
it('a value that is not an array on a multi-value select keeps its existing invalid_type_array sentence', () => {
133+
// (The engine wraps a lone scalar before this validator runs; the write
134+
// path test below lands one.)
135+
for (const value of ['x', { a: 1 }]) {
136+
const errs = errorsOf({ free_multi: value });
137+
expect(errs.map((e) => e.code)).toEqual(['invalid_type']);
138+
expect(errs[0].message).toMatch(/must be an array/i);
139+
expect(errs[0].message).not.toContain('option values');
140+
}
141+
});
142+
143+
it('null and undefined are unchanged', () => {
144+
expect(errorsOf({ free: null, free_multi: null }, 'update')).toEqual([]);
145+
expect(errorsOf({ free: undefined }, 'insert')).toEqual([]);
146+
});
147+
});
148+
149+
/**
150+
* The end-to-end half: the engine's create, update-by-id and update-by-predicate
151+
* doors reach the rule, answer the VALIDATION_FAILED envelope, and the driver
152+
* is never written.
153+
*/
154+
describe('#29244 — write path end to end: no object or array reaches the driver', () => {
155+
async function boot() {
156+
const engine = new ObjectQL();
157+
const writes: unknown[] = [];
158+
const rows = new Map<string, Record<string, unknown>>([['rec_1', { id: 'rec_1', stage: 'orig' }]]);
159+
engine.registerDriver({
160+
name: 'recording', version: '0.0.0', supports: {},
161+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
162+
async execute() { return null; },
163+
async find() { return Array.from(rows.values()); },
164+
async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; },
165+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id: 'x' }; },
166+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
167+
async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; },
168+
async delete() { return true; }, async count() { return rows.size; },
169+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
170+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
171+
async commit() {}, async rollback() {},
172+
} as any, true);
173+
await engine.init();
174+
engine.registry.registerObject({
175+
name: 'shape_deal', label: 'Deal',
176+
fields: {
177+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
178+
stage: { name: 'stage', label: 'Stage', type: 'select' },
179+
labels: { name: 'labels', label: 'Labels', type: 'select', multiple: true },
180+
},
181+
} as any);
182+
return { engine, writes, rows };
183+
}
184+
185+
it('refuses on insert, update and multi-update, and the driver sees no write', async () => {
186+
const { engine, writes, rows } = await boot();
187+
for (const [field, run] of [
188+
['stage', () => engine.insert('shape_deal', { stage: { a: 1 } } as any)],
189+
['stage', () => engine.update('shape_deal', { stage: ['x'] } as any, { where: { id: 'rec_1' } } as any)],
190+
['stage', () => engine.update('shape_deal', { stage: { a: 1 } } as any, { multi: true } as any)],
191+
['labels', () => engine.insert('shape_deal', { labels: [{ a: 1 }] } as any)],
192+
['labels', () => engine.update('shape_deal', { labels: [['x']] } as any, { where: { id: 'rec_1' } } as any)],
193+
] as const) {
194+
const err = await run().then(() => null, (e: unknown) => e as ValidationError);
195+
expect(err).toBeInstanceOf(ValidationError);
196+
expect(err!.code).toBe('VALIDATION_FAILED');
197+
expect(err!.fields.map((f) => [f.field, f.code])).toEqual([[field, 'invalid_type']]);
198+
}
199+
expect(writes).toEqual([]);
200+
expect(rows.get('rec_1')).toEqual({ id: 'rec_1', stage: 'orig' });
201+
});
202+
203+
it('a scalar, an array of scalars and a lone scalar (wrapped) on the same path still land', async () => {
204+
const { engine, writes } = await boot();
205+
await engine.update('shape_deal', { stage: 'won', labels: ['x', 'y'] } as any, { where: { id: 'rec_1' } } as any);
206+
await engine.insert('shape_deal', { stage: 'open', labels: 'x' } as any);
207+
expect(writes).toEqual([
208+
expect.objectContaining({ stage: 'won', labels: ['x', 'y'] }),
209+
expect.objectContaining({ stage: 'open', labels: ['x'] }),
210+
]);
211+
});
212+
});

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@
5454
* - format email / url / phone (lightweight RFC-aware regex)
5555
* - select / multiselect: value must appear in `options`, or be one the write
5656
* names for the field in `keptOptionValues` (an import's kept
57-
* cell, #22183)
57+
* cell, #22183); a single value that is a plain object or an
58+
* array, or an array element that is one, is `invalid_type`
59+
* whether or not `options` are declared (#29244)
5860
* - boolean / toggle: must coerce to boolean
5961
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999
6062
* for a `date` and from 1000 to 9999 for a `datetime` (#20280);
@@ -677,8 +679,8 @@ export function normalizeMultiValueFields(
677679
* the declared type at all", with the same wire code and the same sentence a
678680
* non-readonly field gets: a `date` / `datetime` / `time` the platform reads,
679681
* a number for a number-typed field, a boolean, an array for a multi-value
680-
* field, no plain object or array on a string-typed field, no filter-operator
681-
* object, and the ADR-0104 reference / media / structured-JSON shape under
682+
* field, no plain object or array on a string-typed field or as a select's
683+
* value or option element, no filter-operator object, and the ADR-0104 reference / media / structured-JSON shape under
682684
* the object's own posture (warn-first until the deployment's evidence says
683685
* otherwise). It is never REQUIRED.
684686
*
@@ -1654,8 +1656,26 @@ function validateOne(
16541656
// question from two authorities that happen to agree today, which is the
16551657
// drift the one-definition ruling (#17469) closes.
16561658
if ((t === 'select' || t === 'radio') && !isMultiValueField(def)) {
1657-
// Option membership is the whole arm, and it is a constraint: ⛔ never on
1658-
// a readonly value (the open-vocabulary ruling, see ReadonlyValueScope).
1659+
// [#29244] A single-value select stores ONE option code (the spec's
1660+
// `valueSchemaFor`: a declared code, or a string when no `options` are
1661+
// declared). A plain object or an array is refused as a shape, never
1662+
// judged by its string reading: with no `options` the arm returned before
1663+
// looking at the value, so `{ a: 1 }` and `['x']` reached the driver as
1664+
// sent; with `options`, `['a']` passed membership as `String(['a'])` and
1665+
// the driver stored the array. Neither the list nor the filter door (which
1666+
// refuses an object or array comparand on this field) can read either.
1667+
// Same posture and sentence as the string arm's #29133 rule. A SHAPE test,
1668+
// so it runs ahead of `shapeOnly` and of option membership; a filter
1669+
// operator object was already refused above, with its own sentence.
1670+
if (isPlainObjectValue(value) || Array.isArray(value)) {
1671+
return fail(
1672+
'invalid_type',
1673+
{ type: t, detail: `expected one option value, received ${Array.isArray(value) ? 'an array' : 'an object'}` },
1674+
'invalid_value_shape',
1675+
);
1676+
}
1677+
// Option membership is the rest of the arm, and it is a constraint: ⛔ never
1678+
// on a readonly value (the open-vocabulary ruling, see ReadonlyValueScope).
16591679
if (shapeOnly) return null;
16601680
const allowed = optionValues(def.options);
16611681
if (picklist !== undefined && allowed.length === 0) return picklistUnresolved();
@@ -1695,6 +1715,20 @@ function validateOne(
16951715
// including a system write. Whether a referenced id EXISTS is still
16961716
// `assertReferencesResolve`'s question, with its own `isSystem` latitude.
16971717
if (!isValueShapeJudgedType(t)) {
1718+
// [#29244] Each element is one option code, as for a single value. An
1719+
// element that is a plain object or an array is refused as a shape: with
1720+
// no `options` this branch admitted any array, and with `options` a
1721+
// nested `['a']` passed membership as `String(['a'])`. A shape test, so
1722+
// ahead of `shapeOnly`; an array of scalars is judged as before.
1723+
const nested = value.find((v) => isPlainObjectValue(v) || Array.isArray(v));
1724+
if (nested !== undefined) {
1725+
const received = Array.isArray(nested) ? 'an array' : 'an object';
1726+
return fail(
1727+
'invalid_type',
1728+
{ type: t, detail: `expected an array of option values, received an element that is ${received}` },
1729+
'invalid_value_shape',
1730+
);
1731+
}
16981732
if (shapeOnly) return null;
16991733
const allowed = optionValues(def.options);
17001734
if (allowed.length === 0) {

0 commit comments

Comments
 (0)