diff --git a/.changeset/21509-verify-select-multiple.md b/.changeset/21509-verify-select-multiple.md new file mode 100644 index 0000000000..73a6befb8f --- /dev/null +++ b/.changeset/21509-verify-select-multiple.md @@ -0,0 +1,12 @@ +--- +"@objectstack/verify": patch +--- + +`os verify` writes each derived sample in the shape the engine stores it: a `select` declared `multiple: true` is written as a list and compared as a set + +Clause-②: no + +- The CRUD round-trip derivation now asks `@objectstack/spec`'s `isMultiValueField` whether a field is multi-valued, the same predicate the engine stores by. Before, the `select` / `radio` sample was one scalar option code compared `equal` whatever the field declared, so a multi-valued `select` read back as a one-element list and was reported as a fidelity gap the engine does not have. The shipped `examples/app-todo` (`todo_task.tags`) failed `os verify` with exit 1 on exactly that, and now passes. +- A single-valued `select` or `radio` keeps its scalar sample and its `equal` comparison. `multiselect` and `checkboxes` are unchanged. +- A relational field's `multiple` is answered by the same predicate. A `lookup` declared `multiple: true` still receives a list of ids. A `master_detail` or `tree` field carrying `multiple: true` now receives one id, which is how the engine stores those types. The spec already refuses `multiple` on those types at parse, so only an unparsed config could reach this. +- No export, type or accept-set change. diff --git a/packages/verify/src/derive.test.ts b/packages/verify/src/derive.test.ts index 00f821edd6..9cf64a187d 100644 --- a/packages/verify/src/derive.test.ts +++ b/packages/verify/src/derive.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { Field, FieldType, isMultiValueField } from '@objectstack/spec/data'; // `.js` extension, deliberately: under `moduleResolution: NodeNext` a relative // import without it does not RESOLVE, so every symbol it names becomes `any` — // and the type-layer ratchet reads this file (`TEST_DEBT`, via the package's @@ -221,3 +222,88 @@ describe('deriveCrudCases — an UNREADABLE `reference` carrier is refused (#185 expect(blocked?.blocked).not.toMatch(/rejected alias/i); }); }); + +/** + * [#21509] A sample is written in the shape the field is STORED in, and that + * shape has exactly one answer: `@objectstack/spec`'s `isMultiValueField`, the + * predicate the engine, `driver-sql` and `os generate migration` all store by. + * + * The `select` / `radio` arm used to write one scalar option code and compare + * it `equal` whatever the field declared. A `select` declared `multiple: true` + * is stored and served as a LIST, so it read back `[code]` and `os verify` + * reported a fidelity gap the engine does not have — the shipped + * `examples/app-todo` (`todo_task.tags`) failed on exactly that. + */ +describe('deriveCrudCases — a sample takes the shape the field is STORED in (#21509)', () => { + const OPTIONS = [ + { label: 'Important', value: 'important' }, + { label: 'Quick Win', value: 'quick_win' }, + ]; + const caseFor = (field: Record) => + deriveCrudCases({ + objects: [ + { name: 'company', fields: { title: { type: 'text' } } }, + { name: 'probe', fields: { f: field } }, + ], + }).find((x) => x.object === 'probe'); + + it('a `select` declared `multiple: true` is written as a list and compared as a set', () => { + // Built with the spec's own builder — the way `examples/app-todo` authors + // `todo_task.tags` — rather than a hand-written literal. + const c = caseFor(Field.select({ label: 'Tags', multiple: true, options: OPTIONS })); + expect(c?.body?.f).toEqual(['important']); + expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: ['important'], kind: 'set' }]); + }); + + it.each([ + ['omitted', {}], + ['false', { multiple: false }], + ])('control: a single-valued `select` (`multiple` %s) keeps one scalar code and `equal`', (_label, flag) => { + const c = caseFor(Field.select({ label: 'Priority', options: OPTIONS, ...flag })); + expect(c?.body?.f).toBe('important'); + expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: 'important', kind: 'equal' }]); + }); + + /** + * The claim's enumeration, made mechanical: over EVERY `FieldType` the spec + * declares, with the flag and without it, whatever the derivation writes is + * a list exactly when `isMultiValueField` says the field is stored as one — + * scalar samples and relational refs alike. The loop is driven by the spec's + * own enum, so a type added there is judged here without an edit. + */ + it('over every FieldType × `multiple`, the written shape IS `isMultiValueField`', () => { + const judged: string[] = []; + const listWritten = new Set(); + for (const type of FieldType.options) { + for (const multiple of [true, false]) { + const at = `${type} multiple=${multiple}`; + const stored = isMultiValueField({ type, multiple }); + const c = caseFor({ type, multiple, options: OPTIONS, reference: 'company' }); + expect(c?.blocked, at).toBeFalsy(); + + const ref = c?.relationalRefs?.find((r) => r.field === 'f'); + if (ref) { + expect(ref.multiple, at).toBe(stored); + } else if (c?.body && 'f' in c.body) { + expect(Array.isArray(c.body.f), at).toBe(stored); + const a = c.asserts?.find((x) => x.field === 'f'); + if (a) expect(a.kind === 'set', at).toBe(stored); + } else { + continue; // not written at all (computed / structured / media / no synth) — no shape to judge + } + judged.push(at); + if (stored) listWritten.add(type); + } + } + // Non-vacuity: the loop must actually have judged the multi-valued writers, + // or every assertion above could pass over a derivation that writes nothing. + expect([...listWritten]).toEqual( + expect.arrayContaining(['select', 'radio', 'multiselect', 'checkboxes', 'tags', 'lookup']), + ); + // …and the single-valued side: a flag the predicate does not honour (a + // `text`, a `master_detail`) is judged as the scalar it is stored as. + expect(judged).toEqual( + expect.arrayContaining(['select multiple=false', 'text multiple=true', 'master_detail multiple=true']), + ); + }); +}); diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index 552dcb621f..ba277d29a3 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -19,7 +19,7 @@ // is reported `blocked` with a precise reason — the gate stays honest. -import { referenceCarrierOf } from '@objectstack/spec/data'; +import { isMultiValueField, referenceCarrierOf } from '@objectstack/spec/data'; import { declaredCollection } from './artifact-collections.js'; const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']); @@ -65,6 +65,29 @@ function clampNum(f: any, fallback: number): number { return v; } +/** + * [#21509] Is this field's value MULTI-VALUED — the one question, asked of the + * one predicate `@objectstack/spec` publishes (`isMultiValueField`), and the + * single seam every site in this file goes through: the option arm of + * {@link synth} and the relational ref in {@link deriveCrudCases}. + * + * The engine stores by that predicate (objectql's `declaredMultiValued`, and + * `driver-sql` and `os generate migration` ask it too), so a sample shaped by + * any other answer is written in a form the engine never stores and reads back + * as a fidelity gap the engine does not have. The option arm used to answer + * per TYPE: a `select` declared `multiple: true` was written as one scalar code + * and compared `equal`, then read back as `[code]`. + * + * ⛔ The predicate is called, never re-spelled. A raw `f.multiple` read, or + * `MULTI_CAPABLE_TYPES` / `MULTI_OPTION_TYPES` membership written out here, + * would be a second answer to a question the spec answers once — a raw read + * says `true` on types the predicate stores as a scalar (`master_detail`, + * `tree`, `text` …), and `false` on the inherently-multi option types. + */ +function declaredMultiValued(type: string, f: any): boolean { + return isMultiValueField({ type, multiple: f?.multiple === true }); +} + /** Synthesize a valid value for a field type, or null if not synthesizable. */ function synth(type: string, f: any): { value: unknown; kind: AssertKind } | null { switch (type) { @@ -85,13 +108,14 @@ function synth(type: string, f: any): { value: unknown; kind: AssertKind } | nul case 'datetime': return { value: '2024-03-15T08:30:00.000Z', kind: 'equal' }; case 'time': return { value: '14:30:00', kind: 'equal' }; case 'json': return { value: { sample: true }, kind: 'equal' }; - case 'select': case 'radio': { - const opt = f.options?.[0]?.value; - return opt != null ? { value: opt, kind: 'equal' } : null; - } - case 'multiselect': case 'checkboxes': { + // One declared option code, in the shape the field is STORED in: a list, + // compared as a set, exactly when {@link declaredMultiValued} says so — + // always for `multiselect` / `checkboxes`, and for a `select` / `radio` + // declared `multiple: true`; otherwise the scalar code, compared `equal`. + case 'select': case 'radio': case 'multiselect': case 'checkboxes': { const opt = f.options?.[0]?.value; - return opt != null ? { value: [opt], kind: 'set' } : null; + if (opt == null) return null; + return declaredMultiValued(type, f) ? { value: [opt], kind: 'set' } : { value: opt, kind: 'equal' }; } case 'tags': return { value: ['alpha', 'beta'], kind: 'set' }; // Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted). @@ -258,7 +282,7 @@ export function deriveCrudCases(config: any): CrudCase[] { d.skippedFields.push({ name, type, reason: `relation-target-external:${target}` }); continue; } - d.relationalRefs.push({ field: name, target, required: isRequired, multiple: !!(f as any)?.multiple }); + d.relationalRefs.push({ field: name, target, required: isRequired, multiple: declaredMultiValued(type, f) }); if (isRequired) d.requiredTargets.push(target); continue; }