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
22 changes: 22 additions & 0 deletions .changeset/validation-error-human-message.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/objectql/src/engine-multivalue-normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,15 @@ 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);
});

it('insert: invalid option inside a wrapped scalar still 400s', async () => {
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);
});
Expand Down
60 changes: 51 additions & 9 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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' },
]);
});
});
11 changes: 9 additions & 2 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/validation/rule-null-omitted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down