|
| 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 | +}); |
0 commit comments