Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/29244-select-object-array-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/objectql': patch
---

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

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- 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. -->

**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.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,28 @@ describe('#5922 — what the rule deliberately leaves alone', () => {
expect(errs[0].message).toMatch(/array/i);
});

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

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

Expand Down
212 changes: 212 additions & 0 deletions packages/objectql/src/validation/record-validator.select-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29244] A select's value is an option code: one for a single-value
* `select` / `radio`, an array of them for a multi-value one. A plain object or
* an array is refused as the single value, and as an element of the array.
*
* Measured on `origin/main` @ `04e0c2c0` before the change, with a recording
* driver behind the real engine, on insert, update by id and update by
* predicate alike:
*
* field value before after
* select, no options { a: 1 } ADMIT, driver got { a: 1 } invalid_type
* select, no options (and radio) ['x'] ADMIT, driver got ['x'] invalid_type
* select, options a/b ['a'] ADMIT, driver got ['a'] invalid_type
* select, options a/b { a: 1 } invalid_option invalid_type
* select multiple, no options [{ a: 1 }] ADMIT invalid_type
* select multiple, no options [['x']] ADMIT invalid_type
* select multiple, options a/b [['a']] ADMIT, driver got [['a']] invalid_type
* multiselect / tags / checkboxes, free [{ a: 1 }] ADMIT invalid_type
*
* A string, a number, an array of scalars on a multi-value field, and option
* membership are unchanged.
*/

import { describe, it, expect } from 'vitest';
import { validateRecord, validateRecordInScope, ValidationError } from './record-validator.js';
import { ObjectQL } from '../engine.js';

const schema = {
fields: {
free: { type: 'select', label: 'Free' },
free_radio: { type: 'radio', label: 'Free radio' },
decl: { type: 'select', label: 'Stage', options: [{ value: 'a' }, { value: 'b' }] },
free_multi: { type: 'select', label: 'Free multi', multiple: true },
decl_multi: { type: 'select', label: 'Stages', multiple: true, options: [{ value: 'a' }, { value: 'b' }] },
ms: { type: 'multiselect', label: 'MS' },
tags: { type: 'tags', label: 'Tags' },
boxes: { type: 'checkboxes', label: 'Boxes' },
},
};

function errorsOf(data: Record<string, unknown>, mode: 'insert' | 'update' = 'insert') {
try {
validateRecord(schema, data, mode);
} catch (e) {
if (e instanceof ValidationError) return e.fields;
throw e;
}
return [];
}

describe('#29244 — a single-value select refuses a plain object or an array', () => {
for (const field of ['free', 'free_radio', 'decl'] as const) {
for (const [shape, value, received] of [
['object', { a: 1 }, 'an object'],
['empty object', {}, 'an object'],
['array', ['a'], 'an array'],
['empty array', [], 'an array'],
] as const) {
for (const mode of ['insert', 'update'] as const) {
it(`${field}: refuses ${shape} on ${mode} as invalid_type`, () => {
const errs = errorsOf({ [field]: value }, mode);
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
expect(errs[0].constraint).toMatchObject({
type: schema.fields[field].type,
detail: `expected one option value, received ${received}`,
});
});
}
}
}

it('says the field, the type and the received shape in the message', () => {
const errs = errorsOf({ free: { a: 1 } });
expect(errs[0].message).toContain('Free');
expect(errs[0].message).toContain('select');
expect(errs[0].message).toContain('expected one option value, received an object');
});

it('refuses the shape on a readonly select too (a shape arm, reached under the readonly scope)', () => {
const ro = { fields: { kind: { type: 'select', label: 'Kind', readonly: true } } };
let err: unknown = null;
try {
validateRecordInScope(ro, { kind: ['x'] }, 'insert', 'include');
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(ValidationError);
expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['kind', 'invalid_type']]);
});

it('a filter-operator object keeps its own, earlier sentence', () => {
const errs = errorsOf({ free: { $in: ['a'] } }, 'update');
expect(errs).toHaveLength(1);
expect(errs[0].message).toContain('$in is a filter operator, not a value');
});
});

describe('#29244 — a multi-value option field refuses an element that is a plain object or an array', () => {
for (const field of ['free_multi', 'decl_multi', 'ms', 'tags', 'boxes'] as const) {
for (const [shape, value, received] of [
['object element', ['a', { a: 1 }], 'an object'],
['array element', [['a']], 'an array'],
] as const) {
it(`${field}: refuses an ${shape} as invalid_type`, () => {
const errs = errorsOf({ [field]: value });
expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]);
expect(errs[0].constraint).toMatchObject({
detail: `expected an array of option values, received an element that is ${received}`,
});
});
}
}
});

describe('#29244 — what the rule leaves alone', () => {
it('a scalar on a single-value select is judged as before', () => {
expect(errorsOf({ free: 'anything', free_radio: 'x', decl: 'a' })).toEqual([]);
// Present behaviour, not the contract: the spec's stored element for a
// free-form select is a string, and #29562 is meant to move this pin (a
// number or boolean on a single-value select).
expect(errorsOf({ free: 3 })).toEqual([]);
expect(errorsOf({ decl: 'z' }).map((e) => e.code)).toEqual(['invalid_option']);
});

it('an array of scalars on a multi-value field is judged as before', () => {
expect(errorsOf({ free_multi: ['x', 'y'], decl_multi: ['a', 'b'], ms: ['x'], tags: ['t'], boxes: [] })).toEqual([]);
expect(errorsOf({ decl_multi: ['z'] }).map((e) => e.code)).toEqual(['invalid_option']);
});

it('a value that is not an array on a multi-value select keeps its existing invalid_type_array sentence', () => {
// (The engine wraps a lone scalar before this validator runs; the write
// path test below lands one.)
for (const value of ['x', { a: 1 }]) {
const errs = errorsOf({ free_multi: value });
expect(errs.map((e) => e.code)).toEqual(['invalid_type']);
expect(errs[0].message).toMatch(/must be an array/i);
expect(errs[0].message).not.toContain('option values');
}
});

it('null and undefined are unchanged', () => {
expect(errorsOf({ free: null, free_multi: null }, 'update')).toEqual([]);
expect(errorsOf({ free: undefined }, 'insert')).toEqual([]);
});
});

/**
* The end-to-end half: the engine's create, update-by-id and update-by-predicate
* doors reach the rule, answer the VALIDATION_FAILED envelope, and the driver
* is never written.
*/
describe('#29244 — write path end to end: no object or array reaches the driver', () => {
async function boot() {
const engine = new ObjectQL();
const writes: unknown[] = [];
const rows = new Map<string, Record<string, unknown>>([['rec_1', { id: 'rec_1', stage: 'orig' }]]);
engine.registerDriver({
name: 'recording', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; },
async find() { return Array.from(rows.values()); },
async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; },
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id: 'x' }; },
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; },
async delete() { return true; }, async count() { return rows.size; },
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
} as any, true);
await engine.init();
engine.registry.registerObject({
name: 'shape_deal', label: 'Deal',
fields: {
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
stage: { name: 'stage', label: 'Stage', type: 'select' },
labels: { name: 'labels', label: 'Labels', type: 'select', multiple: true },
},
} as any);
return { engine, writes, rows };
}

it('refuses on insert, update and multi-update, and the driver sees no write', async () => {
const { engine, writes, rows } = await boot();
for (const [field, run] of [
['stage', () => engine.insert('shape_deal', { stage: { a: 1 } } as any)],
['stage', () => engine.update('shape_deal', { stage: ['x'] } as any, { where: { id: 'rec_1' } } as any)],
['stage', () => engine.update('shape_deal', { stage: { a: 1 } } as any, { multi: true } as any)],
['labels', () => engine.insert('shape_deal', { labels: [{ a: 1 }] } as any)],
['labels', () => engine.update('shape_deal', { labels: [['x']] } as any, { where: { id: 'rec_1' } } as any)],
] as const) {
const err = await run().then(() => null, (e: unknown) => e as ValidationError);
expect(err).toBeInstanceOf(ValidationError);
expect(err!.code).toBe('VALIDATION_FAILED');
expect(err!.fields.map((f) => [f.field, f.code])).toEqual([[field, 'invalid_type']]);
}
expect(writes).toEqual([]);
expect(rows.get('rec_1')).toEqual({ id: 'rec_1', stage: 'orig' });
});

it('a scalar, an array of scalars and a lone scalar (wrapped) on the same path still land', async () => {
const { engine, writes } = await boot();
await engine.update('shape_deal', { stage: 'won', labels: ['x', 'y'] } as any, { where: { id: 'rec_1' } } as any);
await engine.insert('shape_deal', { stage: 'open', labels: 'x' } as any);
expect(writes).toEqual([
expect.objectContaining({ stage: 'won', labels: ['x', 'y'] }),
expect.objectContaining({ stage: 'open', labels: ['x'] }),
]);
});
});
44 changes: 39 additions & 5 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
* - format email / url / phone (lightweight RFC-aware regex)
* - select / multiselect: value must appear in `options`, or be one the write
* names for the field in `keptOptionValues` (an import's kept
* cell, #22183)
* cell, #22183); a single value that is a plain object or an
* array, or an array element that is one, is `invalid_type`
* whether or not `options` are declared (#29244)
* - boolean / toggle: must coerce to boolean
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999
* for a `date` and from 1000 to 9999 for a `datetime` (#20280);
Expand Down Expand Up @@ -677,8 +679,8 @@ export function normalizeMultiValueFields(
* the declared type at all", with the same wire code and the same sentence a
* non-readonly field gets: a `date` / `datetime` / `time` the platform reads,
* a number for a number-typed field, a boolean, an array for a multi-value
* field, no plain object or array on a string-typed field, no filter-operator
* object, and the ADR-0104 reference / media / structured-JSON shape under
* field, no plain object or array on a string-typed field or as a select's
* value or option element, no filter-operator object, and the ADR-0104 reference / media / structured-JSON shape under
* the object's own posture (warn-first until the deployment's evidence says
* otherwise). It is never REQUIRED.
*
Expand Down Expand Up @@ -1654,8 +1656,26 @@ function validateOne(
// question from two authorities that happen to agree today, which is the
// drift the one-definition ruling (#17469) closes.
if ((t === 'select' || t === 'radio') && !isMultiValueField(def)) {
// Option membership is the whole arm, and it is a constraint: ⛔ never on
// a readonly value (the open-vocabulary ruling, see ReadonlyValueScope).
// [#29244] A single-value select stores ONE option code (the spec's
// `valueSchemaFor`: a declared code, or a string when no `options` are
// declared). A plain object or an array is refused as a shape, never
// judged by its string reading: with no `options` the arm returned before
// looking at the value, so `{ a: 1 }` and `['x']` reached the driver as
// sent; with `options`, `['a']` passed membership as `String(['a'])` and
// the driver stored the array. Neither the list nor the filter door (which
// refuses an object or array comparand on this field) can read either.
// Same posture and sentence as the string arm's #29133 rule. A SHAPE test,
// so it runs ahead of `shapeOnly` and of option membership; a filter
// operator object was already refused above, with its own sentence.
if (isPlainObjectValue(value) || Array.isArray(value)) {
return fail(
'invalid_type',
{ type: t, detail: `expected one option value, received ${Array.isArray(value) ? 'an array' : 'an object'}` },
'invalid_value_shape',
);
}
// Option membership is the rest of the arm, and it is a constraint: ⛔ never
// on a readonly value (the open-vocabulary ruling, see ReadonlyValueScope).
if (shapeOnly) return null;
const allowed = optionValues(def.options);
if (picklist !== undefined && allowed.length === 0) return picklistUnresolved();
Expand Down Expand Up @@ -1695,6 +1715,20 @@ function validateOne(
// including a system write. Whether a referenced id EXISTS is still
// `assertReferencesResolve`'s question, with its own `isSystem` latitude.
if (!isValueShapeJudgedType(t)) {
// [#29244] Each element is one option code, as for a single value. An
// element that is a plain object or an array is refused as a shape: with
// no `options` this branch admitted any array, and with `options` a
// nested `['a']` passed membership as `String(['a'])`. A shape test, so
// ahead of `shapeOnly`; an array of scalars is judged as before.
const nested = value.find((v) => isPlainObjectValue(v) || Array.isArray(v));
if (nested !== undefined) {
const received = Array.isArray(nested) ? 'an array' : 'an object';
return fail(
'invalid_type',
{ type: t, detail: `expected an array of option values, received an element that is ${received}` },
'invalid_value_shape',
);
}
if (shapeOnly) return null;
const allowed = optionValues(def.options);
if (allowed.length === 0) {
Expand Down
Loading
Loading