diff --git a/.changeset/29645-text-field-refuses-any-object.md b/.changeset/29645-text-field-refuses-any-object.md new file mode 100644 index 00000000000..21c04cd8ff8 --- /dev/null +++ b/.changeset/29645-text-field-refuses-any-object.md @@ -0,0 +1,18 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql)!: a string-typed field refuses a Date, a Map, a Set, a class instance or a typed array as its value, as it already refused a plain object or an array (#29645) + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the record write door (`@objectstack/objectql`), on the engine's insert, insertMany, update by id, update by predicate and the `validate()` dry run. Those are the doors flows, hooks and server code write through; a JSON request body carries none of these values, so REST callers see no change. + +- **Before.** The string arm refused a plain object or an array, but its object test was prototype-strict, so any other object passed on its string reading. On a `text`, `textarea`, `password`, `markdown`, `html`, `richtext`, `code`, `signature` or `qrcode` field, driver-memory stored the object itself and SQLite stored a `Date` as its epoch number in TEXT (`"1767312000000.0"`), a `Map` or a `Set` as `"{}"`, and a class instance or a typed array as its JSON text. On `email`, `url` and `phone` the same write was refused, but only because that string failed the format check (`invalid_email`, `invalid_url`, `invalid_phone`). +- **Now.** Every object is refused `invalid_type` with the sentence `FIELD has an invalid TYPE value: expected a string, received an object`, on all twelve types, before any bound or format is judged, so the three format types now answer `invalid_type` for an object instead of their format code. The driver is never written. A readonly field's value is judged the same way, as a shape check. +- **Unchanged.** A string. A number and a boolean, which keep their string reading and are stored as that string. `null` and a missing value. A plain object and an array (already refused). A filter-operator object keeps its own earlier refusal sentence. A `json` field and the structured-JSON types still hold any object. + +**The remedy.** Write the string you mean: `value.toISOString()` for a `Date` on a text field (or declare the field `datetime`), `JSON.stringify(...)` when JSON text is really what the field should hold, or declare the field `json` when it holds structured data. diff --git a/packages/objectql/src/validation/record-validator.text-object-value.test.ts b/packages/objectql/src/validation/record-validator.text-object-value.test.ts new file mode 100644 index 00000000000..8c8035eff96 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.text-object-value.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29645] A string-typed field (`BOUNDED_STRING_FIELD_TYPES`) refuses ANY + * object as its value, not only a plain object or an array (#29133). + * + * Measured on `origin/main` @ `7dae3b3c` before the change, through the engine + * (insert, update by id, update by predicate), on SQLite (the physical column) + * and on driver-memory (the engine's read; it keeps what it is handed): + * + * value on text / textarea / markdown / base SQLite (stored) base memory (read) head + * html / richtext / code / signature / + * qrcode / password + * new Date(…) "1767312000000.0" the Date itself invalid_type + * new Map(…), new Set(…) "{}" the Map / the Set invalid_type + * a class instance { k: 1 } '{"k":1}' the instance invalid_type + * new Uint8Array([1, 2]) '{"0":1,"1":2}' the typed array invalid_type + * value on email / url / phone invalid_email / _url / _phone (by accident) invalid_type + * + * (`password` reads back masked, so its stored column is the only answer.) + * The arm's object test was `isPlainObjectValue`, which is prototype-strict, + * so these fell through to the string reading; on the three format types that + * reading failed the format regex, an accident of `String(value)`. The arm + * now asks the option arms' test (`isObjectValue`, #29562), so every object is + * refused with the same wire code and sentence as a plain one. A string, a + * number and a boolean keep #29241's rule: judged, and stored, as a string. + */ + +import { describe, it, expect } from 'vitest'; +import { BOUNDED_STRING_FIELD_TYPES } from '@objectstack/spec/data'; +import { normalizeNumericStringValues, validateRecord, validateRecordInScope, ValidationError } from './record-validator.js'; +import { ObjectQL } from '../engine.js'; + +const STRING_TYPES = [...BOUNDED_STRING_FIELD_TYPES]; + +class Widget { + k = 1; +} + +const NON_PLAIN: ReadonlyArray unknown]> = [ + ['a Date', () => new Date('2026-01-02T00:00:00Z')], + ['a Map', () => new Map([['a', 1]])], + ['a Set', () => new Set(['a'])], + ['a class instance', () => new Widget()], + ['a typed array', () => new Uint8Array([1, 2])], +]; + +/** A string each type's own format admits: the plain-string control. */ +const CONTROL: Record = { email: 'a@b.co', url: 'https://example.com', phone: '+15551234567' }; +const controlFor = (type: string) => CONTROL[type] ?? 'plain'; + +function errorsOf( + schema: { fields: Record }, + data: Record, + mode: 'insert' | 'update', +) { + try { + validateRecord(schema, data, mode); + } catch (e) { + if (e instanceof ValidationError) return e.fields; + throw e; + } + return []; +} + +describe('#29645 — a string-typed field refuses any object, not only a plain one', () => { + it('reads the spec set, which still holds the twelve members this pin was written against', () => { + expect(STRING_TYPES.sort()).toEqual( + ['code', 'email', 'html', 'markdown', 'password', 'phone', 'qrcode', 'richtext', 'signature', 'text', 'textarea', 'url'], + ); + }); + + for (const type of STRING_TYPES) { + const schema = { fields: { f: { type, label: 'F' } } }; + for (const [shape, make] of NON_PLAIN) { + for (const mode of ['insert', 'update'] as const) { + it(`${type}: refuses ${shape} on ${mode} as invalid_type`, () => { + const errs = errorsOf(schema, { f: make() }, mode); + expect(errs.map((e) => [e.field, e.code])).toEqual([['f', 'invalid_type']]); + expect(errs[0].constraint).toMatchObject({ type, detail: 'expected a string, received an object' }); + }); + } + } + + it(`${type}: control — a plain string still lands`, () => { + expect(errorsOf(schema, { f: controlFor(type) }, 'insert')).toEqual([]); + expect(errorsOf(schema, { f: controlFor(type) }, 'update')).toEqual([]); + }); + } + + it('says the field, the type and the received shape in the message', () => { + const errs = errorsOf({ fields: { title: { type: 'text', label: 'Title' } } }, { title: new Date(0) }, 'insert'); + expect(errs[0].message).toContain('Title'); + expect(errs[0].message).toContain('text'); + expect(errs[0].message).toContain('expected a string, received an object'); + }); + + it('refuses it on a readonly field too (a shape arm, reached under the readonly scope)', () => { + const schema = { fields: { stamp: { type: 'text', label: 'Stamp', readonly: true } } }; + let err: unknown = null; + try { + validateRecordInScope(schema, { stamp: new Date(0) }, 'insert', 'include'); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['stamp', 'invalid_type']]); + }); +}); + +describe('#29645 — what the rule leaves alone', () => { + const schema = { fields: { title: { type: 'text', label: 'Title', maxLength: 5 }, payload: { type: 'json', label: 'P' } } }; + + it('a number and a boolean keep #29241: written as their string, then judged as one', () => { + const row = normalizeNumericStringValues(schema, { title: 42, payload: 1 }); + expect(row).toEqual({ title: '42', payload: 1 }); + expect(errorsOf(schema, row, 'insert')).toEqual([]); + expect(normalizeNumericStringValues(schema, { title: true }).title).toBe('true'); + expect(errorsOf(schema, { title: 123456 }, 'insert')[0].code).toBe('max_length'); + }); + + it('the door leaves an object as sent: the arm, not the door, refuses it', () => { + const row = { title: new Date(0) }; + expect(normalizeNumericStringValues(schema, row)).toBe(row); + }); + + it('a json field still holds a Date or a Map (outside the set)', () => { + expect(errorsOf(schema, { payload: new Date(0) }, 'insert')).toEqual([]); + expect(errorsOf(schema, { payload: new Map() }, 'insert')).toEqual([]); + }); +}); + +/** The driver-facing half: every engine door reaches the rule and writes nothing. */ +describe('#29645 — write path end to end: the driver is never written', () => { + async function boot() { + const engine = new ObjectQL(); + const writes: unknown[] = []; + const rows = new Map>([['rec_1', { id: 'rec_1', title: '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: unknown) { writes.push(data); return { ...(data as object), id: 'x' }; }, + async update(_o: string, id: string, data: unknown) { writes.push(data); return { ...(data as object), 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(_o: string, data: unknown[]) { writes.push(...data); return data.map((d, i) => ({ ...(d as object), id: `b${i}` })); }, + 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: 'text_object', label: 'Text object', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + mail: { name: 'mail', label: 'Mail', type: 'email' }, + }, + } as any); + return { engine, writes, rows }; + } + + it('insert, insertMany, update by id and update by predicate refuse it VALIDATION_FAILED', async () => { + const { engine, writes, rows } = await boot(); + for (const [field, run] of [ + ['title', () => engine.insert('text_object', { title: new Date(0) } as any)], + ['title', () => engine.insert('text_object', [{ title: 'ok' }, { title: new Set(['a']) }] as any)], + ['title', () => engine.update('text_object', { title: new Map() } as any, { where: { id: 'rec_1' } } as any)], + ['title', () => engine.update('text_object', { title: new Widget() } as any, { multi: true } as any)], + ['mail', () => engine.insert('text_object', { mail: new Uint8Array([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', title: 'orig' }); + }); + + it('control: a plain string reaches the driver as sent', async () => { + const { engine, writes } = await boot(); + await engine.insert('text_object', { title: 'plain', mail: 'a@b.co' } as any); + await engine.update('text_object', { title: 'again' } as any, { where: { id: 'rec_1' } } as any); + expect(writes).toEqual([ + expect.objectContaining({ title: 'plain', mail: 'a@b.co' }), + expect.objectContaining({ title: 'again' }), + ]); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index d88c453b9a1..7b789eee19b 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -23,8 +23,9 @@ * is refused on every field whose declared value is a SCALAR * — a `where` clause pasted into the SET half of a write * (#5922). - * - string types (`BOUNDED_STRING_FIELD_TYPES`) a plain object or an array - * is `invalid_type`, never stringified (#29133); a number or + * - string types (`BOUNDED_STRING_FIELD_TYPES`) any object (a plain object, + * an array, a `Date`, a `Map`, a class instance) is + * `invalid_type`, never stringified (#29133, #29645); a number or * a boolean is judged, and stored, as its string reading * (#29241). Then, on that string: * - `maxLength` / `minLength` @@ -886,16 +887,17 @@ function isPlainObjectValue(value: unknown): boolean { /** * [#29562] Any object: a plain object, an array, a `Date`, a `Map`, a class - * instance. The option arms' shape test. An option code is a string, and no - * object has a string reading a caller meant as one (`String(new Date())` is - * not a code), so the option arms refuse every object rather than only the - * plain ones {@link isPlainObjectValue} names. + * instance. The option arms' shape test, and [#29645] the string arm's. An + * option code or a text value is a string, and no object has a string reading + * a caller meant as one (`String(new Date())` is neither a code nor a title), + * so those arms refuse every object rather than only the plain ones + * {@link isPlainObjectValue} names. */ function isObjectValue(value: unknown): value is object { return typeof value === 'object' && value !== null; } -/** [#29562] The received-shape word an option arm's refusal names. */ +/** [#29562] The received-shape word an option or string arm's refusal names. */ function receivedObjectShape(value: object): string { return Array.isArray(value) ? 'an array' : 'an object'; } @@ -969,8 +971,8 @@ function receivedObjectShape(value: object): string { * `42`. * * ⛔ Not touched: a string (judged and written as itself), `null` and a missing - * key, a plain object or an array (the arm refuses them, #29133), every other - * non-scalar, and every field outside the set — `json` and the structured + * key, an object of any kind (the arm refuses it, #29133 / #29645), every + * other non-scalar, and every field outside the set — `json` and the structured * types, the number and boolean fields and the reference types. The option * types have their own half, below. * @@ -1328,10 +1330,18 @@ function validateOne( // [#29241] `normalizeNumericStringValues` has already written that string // into the payload at the door, so the driver stores what was judged; a // filter-operator object was already refused above, with its own sentence. - if (isPlainObjectValue(value) || Array.isArray(value)) { + // + // [#29645] Every object, not only a plain one, by the option arms' test + // (#29562): a `Date`, a `Map`, a `Set`, a class instance or a typed array + // passed on its string reading, so driver-memory stored the object itself + // and SQLite stored a `Date` as its epoch number and the rest as their + // JSON text (`{}` for a `Map` or a `Set`). On `email`, `url` and `phone` + // the format check refused that string by accident; the shape is now + // refused first, on all twelve types alike. It is never stringified. + if (isObjectValue(value)) { return fail( 'invalid_type', - { type: t, detail: `expected a string, received ${Array.isArray(value) ? 'an array' : 'an object'}` }, + { type: t, detail: `expected a string, received ${receivedObjectShape(value)}` }, // The ADR-0104 shape sentence the operator refusal above also uses: // one catalog entry for "this value's SHAPE is wrong for this type". 'invalid_value_shape', diff --git a/packages/rest/src/engine-sqlite-text-object-value.test.ts b/packages/rest/src/engine-sqlite-text-object-value.test.ts new file mode 100644 index 00000000000..7986ff89e5a --- /dev/null +++ b/packages/rest/src/engine-sqlite-text-object-value.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29645] A `Date`, a `Map`, a `Set`, a class instance or a typed array + * written to a text-family field, through the engine's write doors (the ones + * flows, hooks and server code use; a JSON body carries none of these), on a + * real engine (`ObjectQL` + sqlite `SqlDriver`). The PHYSICAL column is read + * with the driver's own query builder, past every engine read coercion. + * + * Base (`origin/main` @ `7dae3b3c`) stored a `Date` as its epoch number in + * TEXT (`"1767312000000.0"`), a `Map` or a `Set` as `"{}"`, a class instance + * and a typed array as their JSON text, on insert, update by id and update by + * predicate alike. Each is now refused `VALIDATION_FAILED` with the field's + * `invalid_type`, and the row is not written. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; + +class Widget { + k = 1; +} + +const NON_PLAIN: ReadonlyArray unknown]> = [ + ['a Date', () => new Date('2026-01-02T00:00:00Z')], + ['a Map', () => new Map([['a', 1]])], + ['a Set', () => new Set(['a'])], + ['a class instance', () => new Widget()], + ['a typed array', () => new Uint8Array([1, 2])], +]; + +const OBJ = { + name: 'text_object_sql', label: 'Text object', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + body: { name: 'body', type: 'markdown' as const }, + }, +}; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(OBJ as any); + await engine.syncSchemas(); + const stored = async (id: string) => + (await (driver as any).knex('text_object_sql').where({ id }).first()) as Record | undefined; + const refusal = (run: () => Promise) => + run().then(() => null, (e: any) => ({ code: e?.code, fields: (e?.fields ?? []).map((f: any) => [f.field, f.code]) })); + return { engine, stored, refusal }; +} + +describe('engine write doors on SQLite: any object on a text-family field is refused, nothing stored (#29645)', () => { + let ctx: Awaited>; + beforeEach(async () => { + ctx = await boot(); + await ctx.engine.insert('text_object_sql', { id: 'u1', title: 'orig', body: 'orig' }); + }); + + for (const [shape, make] of NON_PLAIN) { + it(`${shape}: insert, update by id and update by predicate`, async () => { + expect(await ctx.refusal(() => ctx.engine.insert('text_object_sql', { id: 'n1', title: make() } as any))) + .toEqual({ code: 'VALIDATION_FAILED', fields: [['title', 'invalid_type']] }); + expect(await ctx.stored('n1')).toBeUndefined(); + + expect(await ctx.refusal(() => ctx.engine.update('text_object_sql', { body: make() } as any, { where: { id: 'u1' } } as any))) + .toEqual({ code: 'VALIDATION_FAILED', fields: [['body', 'invalid_type']] }); + expect(await ctx.refusal(() => ctx.engine.update('text_object_sql', { title: make() } as any, { where: { id: 'u1' }, multi: true } as any))) + .toEqual({ code: 'VALIDATION_FAILED', fields: [['title', 'invalid_type']] }); + expect(await ctx.stored('u1')).toEqual(expect.objectContaining({ title: 'orig', body: 'orig' })); + }); + } + + it('control: a plain string is stored as sent, and a number keeps its string reading (#29241)', async () => { + await ctx.engine.insert('text_object_sql', { id: 's1', title: 'plain', body: 42 } as any); + expect(await ctx.stored('s1')).toEqual(expect.objectContaining({ title: 'plain', body: '42' })); + }); +});