diff --git a/.changeset/validation-error-human-message.md b/.changeset/validation-error-human-message.md new file mode 100644 index 0000000000..c6eb600993 --- /dev/null +++ b/.changeset/validation-error-human-message.md @@ -0,0 +1,22 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): surface the human validation message in `ValidationError.message`, not a `field (code)` digest + +When an object-level validation rule (ADR-0020 `validations[]`) rejected a +save, the console toast showed the generic English string +`Validation failed for 1 field(s): _record (rule_violation)` instead of the +rule author's own `message` (often localized, e.g. 最小水深不能大于最大水深。). + +The author's message was always transported in `ValidationError.fields[].message` +through the whole chain (rule-validator → REST envelope `fields[]` → client SDK +`error.details`), but every generic UI surface displays the top-level +`Error.message`, which only contained the `field (code)` pairs. + +Fix at the single choke point — the `ValidationError` constructor now builds its +top-level message from the per-field human messages (joined with `; `), falling +back to `field (code)` only when a field error has no message. Machine-readable +`code` and `fields[]` are unchanged, so programmatic consumers and the REST +envelope shape are unaffected; every client (console toast, CLI, SDK callers) +now sees the author-written message with no client-side change needed. diff --git a/packages/objectql/src/engine-multivalue-normalize.test.ts b/packages/objectql/src/engine-multivalue-normalize.test.ts index aded294b3a..f1f226a3aa 100644 --- a/packages/objectql/src/engine-multivalue-normalize.test.ts +++ b/packages/objectql/src/engine-multivalue-normalize.test.ts @@ -149,7 +149,7 @@ describe('engine write pipeline — multi-value scalar normalization (#2552)', ( const ql = await makeEngine(driver); await expect( ql.update('project', { id: 'r1', labels: { nested: true } }), - ).rejects.toThrow(/invalid_type/i); + ).rejects.toThrow(/must be an array/i); expect(updated).toHaveLength(0); }); @@ -157,7 +157,7 @@ describe('engine write pipeline — multi-value scalar normalization (#2552)', ( const { driver, created } = makeDriver(); const ql = await makeEngine(driver); await expect(ql.insert('project', { name: 'P2', labels: 'nope' })).rejects.toThrow( - /invalid_option/i, + /is not one of/i, ); expect(created).toHaveLength(0); }); diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index feecb8fe26..7e4f4b0e60 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { validateRecord, normalizeMultiValueFields } from './record-validator.js'; +import { validateRecord, normalizeMultiValueFields, ValidationError } from './record-validator.js'; /** * Required-field validation, with the autonumber exemption (#1603). @@ -55,14 +55,14 @@ describe('validateRecord — time field accepts time-of-day', () => { for (const v of ['25:00', '14:60', 'not-a-time', '14']) { it(`rejects ${v}`, () => { - expect(() => validateRecord(schema, { at: v }, 'insert')).toThrow(/invalid_time/i); + expect(() => validateRecord(schema, { at: v }, 'insert')).toThrow(/must be a valid time/i); }); } it('does NOT regress date/datetime (still ISO-parsed)', () => { const ds = { fields: { d: { type: 'date' }, dt: { type: 'datetime' } } }; expect(() => validateRecord(ds, { d: '2026-06-17', dt: '2026-06-17T10:00:00Z' }, 'insert')).not.toThrow(); - expect(() => validateRecord(ds, { d: 'not-a-date' }, 'insert')).toThrow(/invalid_date/i); + expect(() => validateRecord(ds, { d: 'not-a-date' }, 'insert')).toThrow(/must be a valid date/i); }); }); @@ -160,13 +160,13 @@ describe('validateRecord — multi-value fields must be arrays', () => { { watchers: 'user-1' }, { attachments: 'file-key-1' }, ]) { - expect(() => validateRecord(schema, payload, 'update')).toThrow(/invalid_type/i); + expect(() => validateRecord(schema, payload, 'update')).toThrow(/must be an array/i); } }); it('rejects a plain-object shape with invalid_type', () => { - expect(() => validateRecord(schema, { labels: { nested: true } }, 'update')).toThrow(/invalid_type/i); - expect(() => validateRecord(schema, { team_members: { id: 'u1' } }, 'update')).toThrow(/invalid_type/i); + expect(() => validateRecord(schema, { labels: { nested: true } }, 'update')).toThrow(/must be an array/i); + expect(() => validateRecord(schema, { team_members: { id: 'u1' } }, 'update')).toThrow(/must be an array/i); }); it('accepts arrays (including for select+multiple, previously mis-rejected)', () => { @@ -180,12 +180,54 @@ describe('validateRecord — multi-value fields must be arrays', () => { }); it('still validates array ELEMENTS against options', () => { - expect(() => validateRecord(schema, { labels: ['nope'] }, 'update')).toThrow(/invalid_option/i); - expect(() => validateRecord(schema, { channels: ['fax'] }, 'update')).toThrow(/invalid_option/i); + expect(() => validateRecord(schema, { labels: ['nope'] }, 'update')).toThrow(/is not one of/i); + expect(() => validateRecord(schema, { channels: ['fax'] }, 'update')).toThrow(/is not one of/i); }); it('does NOT regress single select / radio', () => { expect(() => validateRecord(schema, { status: 'active' }, 'update')).not.toThrow(); - expect(() => validateRecord(schema, { status: 'nope' }, 'update')).toThrow(/invalid_option/i); + expect(() => validateRecord(schema, { status: 'nope' }, 'update')).toThrow(/must be one of/i); + }); +}); + +/** + * The top-level `ValidationError.message` is what generic UI surfaces (the + * console's save-error toast, CLI output) display verbatim — it must carry + * the HUMAN per-field messages, not a `field (code)` digest. Regression for + * the rule-violation case: an author-written localized rule `message` + * ("最小水深不能大于最大水深。") used to be buried in `fields[]` while the + * toast showed "Validation failed for 1 field(s): _record (rule_violation)". + */ +describe('ValidationError — top-level message is human-readable', () => { + it('uses each field error message verbatim', () => { + const err = new ValidationError([ + { field: '_record', code: 'rule_violation', message: '最小水深不能大于最大水深。' }, + ]); + expect(err.message).toBe('最小水深不能大于最大水深。'); + }); + + it('joins multiple field messages', () => { + const err = new ValidationError([ + { field: 'title', code: 'required', message: 'title is required' }, + { field: '_record', code: 'rule_violation', message: '最小水深不能大于最大水深。' }, + ]); + expect(err.message).toBe('title is required; 最小水深不能大于最大水深。'); + }); + + it('falls back to `field (code)` when a message is blank', () => { + const err = new ValidationError([ + { field: '_record', code: 'rule_violation', message: '' }, + ]); + expect(err.message).toBe('_record (rule_violation)'); + }); + + it('still exposes machine-readable fields[] for programmatic handling', () => { + const err = new ValidationError([ + { field: '_record', code: 'rule_violation', message: 'boom' }, + ]); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toEqual([ + { field: '_record', code: 'rule_violation', message: 'boom' }, + ]); }); }); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 646025fa4c..56b5fa4795 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -83,9 +83,16 @@ export class ValidationError extends Error { readonly code = 'VALIDATION_FAILED'; readonly fields: FieldValidationError[]; constructor(fields: FieldValidationError[]) { + // The top-level message is what generic UI surfaces (toasts, CLI output) + // display verbatim, so it must carry the HUMAN messages — most notably a + // validation rule's author-written `message` (often localized), which used + // to be buried in `fields[]` while the toast showed only + // "Validation failed for 1 field(s): _record (rule_violation)". + // Machine-readable field/code pairs remain available on `.fields`. super( - `Validation failed for ${fields.length} field(s): ` + - fields.map((f) => `${f.field} (${f.code})`).join(', '), + fields + .map((f) => (f.message?.trim() ? f.message : `${f.field} (${f.code})`)) + .join('; ') || 'Validation failed', ); this.name = 'ValidationError'; this.fields = fields; diff --git a/packages/objectql/src/validation/rule-null-omitted.test.ts b/packages/objectql/src/validation/rule-null-omitted.test.ts index 6f19ed7edc..90b490c426 100644 --- a/packages/objectql/src/validation/rule-null-omitted.test.ts +++ b/packages/objectql/src/validation/rule-null-omitted.test.ts @@ -28,12 +28,12 @@ const schema = { describe('validation: `field == null` on insert with omitted field (#1871)', () => { it('fires when due_date is OMITTED from the insert payload', () => { expect(() => evaluateValidationRules(schema, { priority: 'urgent' }, 'insert', {})) - .toThrow(/rule_violation|_record|Validation failed/); + .toThrow(/Urgent tasks require a due date/); }); it('fires when due_date is explicitly null (already worked)', () => { expect(() => evaluateValidationRules(schema, { priority: 'urgent', due_date: null }, 'insert', {})) - .toThrow(/rule_violation|_record|Validation failed/); + .toThrow(/Urgent tasks require a due date/); }); it('does NOT fire when due_date is present', () => {