diff --git a/src/decorator/decorators.ts b/src/decorator/decorators.ts index 3662e973b5..20519d31ae 100644 --- a/src/decorator/decorators.ts +++ b/src/decorator/decorators.ts @@ -149,3 +149,4 @@ export * from './array/ArrayUnique'; export * from './object/IsNotEmptyObject'; export * from './object/IsInstance'; +export * from './object/AtLeastOneField'; diff --git a/src/decorator/object/AtLeastOneField.spec.ts b/src/decorator/object/AtLeastOneField.spec.ts new file mode 100644 index 0000000000..8e26ea060c --- /dev/null +++ b/src/decorator/object/AtLeastOneField.spec.ts @@ -0,0 +1,67 @@ +import { validate } from '../../index'; +import { IsString } from '../typechecker/IsString'; +import { IsEmail } from '../string/IsEmail'; +import { IsOptional } from '../common/IsOptional'; +import { IsInt } from '../typechecker/IsInt'; +import { AtLeastOneField, AtLeastOneFieldConstraint } from './AtLeastOneField'; + +// ───────────────────────────────────────────── +// Test DTO +// ───────────────────────────────────────────── + +@AtLeastOneField() +class UpdateUserDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsInt() + age?: number; +} + +const createDto = (data: Partial): UpdateUserDto => Object.assign(new UpdateUserDto(), data); + +// ───────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────── + +describe('@AtLeastOneField decorator implementation', () => { + const constraint = new AtLeastOneFieldConstraint(); + + describe('validate', () => { + it('should return true when at least one field has a value', () => { + expect(constraint.validate(null, { object: { name: 'Alice' } } as any)).toBe(true); + expect(constraint.validate(null, { object: { email: 'alice@test.com' } } as any)).toBe(true); + expect(constraint.validate(null, { object: { age: 25 } } as any)).toBe(true); + expect(constraint.validate(null, { object: { name: 'Alice', email: 'alice@test.com' } } as any)).toBe(true); + }); + + it('should return false when all fields are empty', () => { + expect(constraint.validate(null, { object: {} } as any)).toBe(false); + expect(constraint.validate(null, { object: { name: '' } } as any)).toBe(false); + expect(constraint.validate(null, { object: { name: null } } as any)).toBe(false); + expect(constraint.validate(null, { object: { name: undefined } } as any)).toBe(false); + }); + }); + + describe('defaultMessage', () => { + it('should return the correct error message', async () => { + const errors = await validate(createDto({})); + const message = Object.values(errors[0].constraints ?? {}).join(''); + expect(message).toBe('At least one of the following fields must be provided: [name, email, age]'); + }); + + it('should not have empty fields in the list', async () => { + const errors = await validate(createDto({})); + const message = Object.values(errors[0].constraints ?? {}).join(''); + expect(message).not.toContain('[,'); // no leading comma + expect(message).not.toContain(', ]'); // no trailing comma + expect(message).not.toContain('[]'); // no empty list + }); + }); +}); diff --git a/src/decorator/object/AtLeastOneField.ts b/src/decorator/object/AtLeastOneField.ts new file mode 100644 index 0000000000..4bc2c14966 --- /dev/null +++ b/src/decorator/object/AtLeastOneField.ts @@ -0,0 +1,51 @@ +import { getMetadataStorage } from '../../metadata/MetadataStorage'; +import { registerDecorator } from '../../register-decorator'; +import { ValidationArguments } from '../../validation/ValidationArguments'; +import { ValidatorConstraintInterface } from '../../validation/ValidatorConstraintInterface'; +import { ValidationOptions } from '../ValidationOptions'; +import { ValidatorConstraint } from '../common/Validate'; + +const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; + +const getClassFields = (target: Function): string[] => { + const storage = getMetadataStorage(); + + const metadata = storage.getTargetValidationMetadatas(target, '', false, false); + + return [ + ...new Set( + metadata.map(m => m.propertyName).filter(field => field !== '' && field !== null && field !== undefined) + ), + ]; +}; + +// ───────────────────────────────────────────── +// Constraint +// ───────────────────────────────────────────── + +@ValidatorConstraint({ name: 'AtLeastOneField', async: false }) +export class AtLeastOneFieldConstraint implements ValidatorConstraintInterface { + validate(_: unknown, { object }: ValidationArguments): boolean { + return Object.values(object as Record).some(hasValue); + } + + defaultMessage({ object }: ValidationArguments): string { + const fields = getClassFields(object.constructor as Function); + return `At least one of the following fields must be provided: [${fields.join(', ')}]`; + } +} + +// ───────────────────────────────────────────── +// Decorator +// ───────────────────────────────────────────── + +export function AtLeastOneField(validationOptions?: ValidationOptions) { + return (target: Function) => { + registerDecorator({ + target, + propertyName: '', + options: validationOptions, + validator: AtLeastOneFieldConstraint, + }); + }; +}