diff --git a/README.ko.md b/README.ko.md index 0a424cb..f70ff74 100644 --- a/README.ko.md +++ b/README.ko.md @@ -115,6 +115,7 @@ app.listen(3000) | `@IsTrue()` | 값이 `true` 여야 함 | `@IsTrue() acceptedTerms!: boolean` | | `@IsFalse()` | 값이 `false` 여야 함 | `@IsFalse() blocked!: boolean` | | `@OneOf(options: readonly any[])` | 값이 `options` 중 하나여야 함 | `@OneOf(['credit','debit'] as const) method!: 'credit' \| 'debit'` | +| `@ArrayContains(values: any[])` | 배열이 지정된 모든 값을 포함해야 함 | `@ArrayContains([1, 2]) nums!: number[]` | | `@Enum(enumObj: object, message?)` | 값이 `enumObj`의 멤버여야 함 | `@Enum(UserRole) role!: UserRole` | | `@Validate(validateFn, message?)` | 커스텀 검증 함수를 사용 | `@Validate(v => typeof v === 'string' && v.includes('@'), 'invalid email') email!: string` | | `@Regexp(pattern: RegExp, message?)` | 문자열이 주어진 정규식을 만족해야 함 | `@Regexp(/^[0-9]+$/, 'digits only') phone!: string` | diff --git a/README.md b/README.md index 986039d..62b2cca 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Full guide and API reference: | `@IsTrue()` | Value must be `true`. | `@IsTrue() acceptedTerms!: boolean` | | `@IsFalse()` | Value must be `false`. | `@IsFalse() blocked!: boolean` | | `@OneOf(options: readonly any[])` | Value must be one of `options`. | `@OneOf(['credit','debit'] as const) method!: 'credit' \| 'debit'` | +| `@ArrayContains(values: any[])` | Array must contain all specified values. | `@ArrayContains([1, 2]) nums!: number[]` | | `@Enum(enumObj: object, message?)` | Value must be a member of `enumObj`. | `@Enum(UserRole) role!: UserRole` | | `@Validate(validateFn, message?)` | Custom validation function. | `@Validate(v => typeof v === 'string' && v.includes('@'), 'invalid email') email!: string` | | `@Regexp(pattern: RegExp, message?)` | String must match the given regular expression. | `@Regexp(/^[0-9]+$/, 'digits only') phone!: string` | diff --git a/apps/docs/docs/decorators/validators.md b/apps/docs/docs/decorators/validators.md index 72d41f6..f22689c 100644 --- a/apps/docs/docs/decorators/validators.md +++ b/apps/docs/docs/decorators/validators.md @@ -85,6 +85,15 @@ Validates that the input value is one of the specified values. - **`values`**: The array of allowed values. +### `@ArrayContains(values: any[], message?: string)` + +Validates that the array contains all the specified values. Supports primitive values, objects, Date, and mixed types. + +- **`values`**: The values that must be present in the array. +- **`message`** (optional): The error message to display when validation fails. If omitted, a default message will be used. + +> **Warning**: Object comparison uses deep equality. Performance may degrade when `values` contains many objects or deeply nested structures. + ### `@Enum(enumObj: object, message?: string)` Validates that the input value matches one of the values in the specified enum object. diff --git a/apps/docs/i18n/de/docusaurus-plugin-content-docs/current/decorators/validators.md b/apps/docs/i18n/de/docusaurus-plugin-content-docs/current/decorators/validators.md index 0473626..cd2e57d 100644 --- a/apps/docs/i18n/de/docusaurus-plugin-content-docs/current/decorators/validators.md +++ b/apps/docs/i18n/de/docusaurus-plugin-content-docs/current/decorators/validators.md @@ -85,6 +85,15 @@ Validiert, dass der Eingabewert einer der angegebenen Werte ist. - **`values`**: Das Array der zulässigen Werte. +### `@ArrayContains(values: any[], message?: string)` + +Validiert, dass das Array alle angegebenen Werte enthält. Unterstützt primitive Werte, Objekte, Date und gemischte Typen. + +- **`values`**: Die Werte, die im Array vorhanden sein müssen. +- **`message`** (optional): Die Fehlermeldung, die angezeigt wird, wenn die Validierung fehlschlägt. Wenn weggelassen, wird eine Standardmeldung verwendet. + +> **Warnung**: Der Objektvergleich verwendet Tiefengleichheit. Die Leistung kann sich verschlechtern, wenn `values` viele Objekte oder tief verschachtelte Strukturen enthält. + ### `@Enum(enumObj: object, message?: string)` Validiert, dass der Eingabewert mit einem der Werte im angegebenen Enum-Objekt übereinstimmt. diff --git a/apps/docs/i18n/fr/docusaurus-plugin-content-docs/current/decorators/validators.md b/apps/docs/i18n/fr/docusaurus-plugin-content-docs/current/decorators/validators.md index 21953a6..f264570 100644 --- a/apps/docs/i18n/fr/docusaurus-plugin-content-docs/current/decorators/validators.md +++ b/apps/docs/i18n/fr/docusaurus-plugin-content-docs/current/decorators/validators.md @@ -85,6 +85,15 @@ Valide que la valeur d'entrée est l'une des valeurs spécifiées. - **`values`** : Le tableau des valeurs autorisées. +### `@ArrayContains(values: any[], message?: string)` + +Valide que le tableau contient toutes les valeurs spécifiées. Prend en charge les valeurs primitives, les objets, les Date et les types mixtes. + +- **`values`**: Les valeurs qui doivent être présentes dans le tableau. +- **`message`** (optionnel) : Le message d'erreur à afficher lorsque la validation échoue. S'il est omis, un message par défaut sera utilisé. + +> **Avertissement** : La comparaison d'objets utilise l'égalité profonde. Les performances peuvent se dégrader lorsque `values` contient de nombreux objets ou des structures profondément imbriquées. + ### `@Enum(enumObj: object, message?: string)` Valide que la valeur d'entrée correspond à l'une des valeurs de l'objet enum spécifié. diff --git a/apps/docs/i18n/ko/docusaurus-plugin-content-docs/current/decorators/validators.md b/apps/docs/i18n/ko/docusaurus-plugin-content-docs/current/decorators/validators.md index fb9c1bd..b203a2a 100644 --- a/apps/docs/i18n/ko/docusaurus-plugin-content-docs/current/decorators/validators.md +++ b/apps/docs/i18n/ko/docusaurus-plugin-content-docs/current/decorators/validators.md @@ -119,6 +119,15 @@ title: 유효성 검사 데코레이터 --- +### `@ArrayContains(values: any[], message?: string)` + +배열이 지정된 모든 값을 포함하고 있는지 검증합니다. 원시값, 객체, Date 등 복합 타입도 지원합니다. + +- **`values`**: 배열에 반드시 포함되어야 하는 값들입니다. +- **`message`** (선택 사항): 검증 실패 시 표시할 메시지. 생략하면 기본 메시지가 사용됩니다. + +> **주의**: 객체 비교는 깊은 비교(deep equality)를 사용합니다. `values`에 객체가 많거나 중첩이 깊을수록 성능 저하가 발생할 수 있습니다. + ### `@Enum(enumObj: object, message?: string)` 입력 값이 지정된 열거형 객체의 값 중 하나와 일치하는지 검증합니다. 또한 입력 값을 해당하는 열거형 값으로 자동으로 변환합니다. diff --git a/apps/docs/i18n/ru/docusaurus-plugin-content-docs/current/decorators/validators.md b/apps/docs/i18n/ru/docusaurus-plugin-content-docs/current/decorators/validators.md index 1e15878..9a99445 100644 --- a/apps/docs/i18n/ru/docusaurus-plugin-content-docs/current/decorators/validators.md +++ b/apps/docs/i18n/ru/docusaurus-plugin-content-docs/current/decorators/validators.md @@ -85,6 +85,15 @@ Express-Cargo использует декораторы для валидаци - **`values`**: Массив допустимых значений. +### `@ArrayContains(values: any[], message?: string)` + +Проверяет, что массив содержит все указанные значения. Поддерживает примитивные значения, объекты, Date и смешанные типы. + +- **`values`**: Значения, которые должны присутствовать в массиве. +- **`message`** (необязательно): Сообщение об ошибке, которое будет отображаться при сбое валидации. Если опущено, будет использоваться сообщение по умолчанию. + +> **Предупреждение**: Сравнение объектов использует глубокое равенство. Производительность может снизиться, если `values` содержит много объектов или глубоко вложенные структуры. + ### `@Enum(enumObj: object, message?: string)` Проверяет, что входное значение соответствует одному из значений в указанном объекте перечисления. diff --git a/apps/example/README.md b/apps/example/README.md index 7f9a61a..3e64ccc 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -649,6 +649,58 @@ curl -X POST 'http://localhost:3000/enum' \ --- +### @ArrayContains + +```typescript +class ArrayContainsNested { + @Body() + name!: string +} + +class ArrayContainsExample { + @Body() + @List('number') + @ArrayContains([1, 2]) + numbers!: number[] + + @Body() + @List(ArrayContainsNested) + @ArrayContains([{ name: 'test1' }]) + objects!: ArrayContainsNested[] + + @Body() + @List(Date) + @ArrayContains([new Date('2024-01-01')]) + dates!: Date[] + + @Body() + @Type(data => { + if (typeof data !== 'object' || data === null) return Number + else return ArrayContainsNested + }) + @ArrayContains([1, { name: 'test1' }]) + mixed!: (number | ArrayContainsNested)[] +} + +router.post('/array-contains', bindingCargo(ArrayContainsExample), (req, res) => { + const cargo = getCargo(req) + res.json(cargo) +}) +``` + +```shell +curl -X POST 'http://localhost:3000/array-contains' \ + -H 'Content-Type: application/json' \ + -d '{ + "numbers": [1, 2, 3], + "objects": [{ "name": "test1" }, { "name": "test2" }], + "dates": ["2024-01-01T00:00:00.000Z"], + "mixed": [1, { "name": "test1" }] + }' +``` + +--- + ### @Validate ```typescript diff --git a/apps/example/src/routers/validator.ts b/apps/example/src/routers/validator.ts index 73e2a95..24e3e5d 100644 --- a/apps/example/src/routers/validator.ts +++ b/apps/example/src/routers/validator.ts @@ -26,6 +26,7 @@ import { With, Without, Enum, + ArrayContains, Type, List, } from 'express-cargo' const router: Router = express.Router() @@ -315,4 +316,39 @@ router.post('/enum', bindingCargo(EnumExample), (req, res) => { res.json(cargo) }) +class ArrayContainsNested { + @Body() + name!: string +} + +class ArrayContainsExample { + @Body() + @List('number') + @ArrayContains([1, 2]) + numbers!: number[] + + @Body() + @List(ArrayContainsNested) + @ArrayContains([{ name: 'test1' }]) + objects!: ArrayContainsNested[] + + @Body() + @List(Date) + @ArrayContains([new Date('2024-01-01')]) + dates!: Date[] + + @Body() + @Type(data => { + if (typeof data !== 'object' || data === null) return Number + else return ArrayContainsNested + }) + @ArrayContains([1, { name: 'test1' }]) + mixed!: (number | ArrayContainsNested)[] +} + +router.post('/array-contains', bindingCargo(ArrayContainsExample), (req, res) => { + const cargo = getCargo(req) + res.json(cargo) +}) + export default router diff --git a/packages/express-cargo/src/utils.ts b/packages/express-cargo/src/utils.ts new file mode 100644 index 0000000..f328da8 --- /dev/null +++ b/packages/express-cargo/src/utils.ts @@ -0,0 +1,35 @@ +export function isDeepEqual(obj1: any, obj2: any): boolean { + if (obj1 === obj2) return true + + const stack: [any, any][] = [[obj1, obj2]] + while (stack.length > 0) { + const [a, b] = stack.pop()! + + if (a === b) continue + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { + return false + } + + // Handle Date objects specifically + if (a instanceof Date && b instanceof Date) { + if (a.getTime() !== b.getTime()) return false + continue + } + + // Check if both are arrays or neither (for accuracy and performance) + const isArrayA = Array.isArray(a) + const isArrayB = Array.isArray(b) + if (isArrayA !== isArrayB) return false + + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) return false + + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + stack.push([a[key], b[key]]) + } + } + + return true +} diff --git a/packages/express-cargo/src/validator.ts b/packages/express-cargo/src/validator.ts index 809e4d3..13de224 100644 --- a/packages/express-cargo/src/validator.ts +++ b/packages/express-cargo/src/validator.ts @@ -1,5 +1,6 @@ import { cargoErrorMessage, EachValidatorRule, TypedPropertyDecorator, UuidVersion, ValidatorRule } from './types' import { CargoClassMetadata } from './metadata' +import { isDeepEqual } from './utils' function addValidator(target: any, propertyKey: string | symbol, rule: ValidatorRule) { const classMeta = new CargoClassMetadata(target) @@ -441,6 +442,56 @@ export function Without(fieldName: string, message?: cargoErrorMessage): Propert } } +/** + * Checks if the array contains all the specified values. + * @param values - The values that must be present in the array. + * @param message - Optional custom error message. + */ +export function ArrayContains(values: any[], message?: cargoErrorMessage): TypedPropertyDecorator { + const expectedPrimitives = values.filter(v => v === null || typeof v !== 'object') + const expectedObjects = values.filter(v => v !== null && typeof v === 'object') + + return (target: Object, propertyKey: string | symbol): void => { + addValidator( + target, + propertyKey, + new ValidatorRule( + propertyKey, + 'arrayContains', + (value: unknown) => { + if (!Array.isArray(value)) { + return false + } + const actualPrimitiveSet = new Set() + const actualObjects: any[] = [] + + for (const item of value) { + if (item === null || typeof item !== 'object') { + actualPrimitiveSet.add(item) + } else { + actualObjects.push(item) + } + } + + // Verify all expected primitive values exist in the actual array + for (const req of expectedPrimitives) { + if (!actualPrimitiveSet.has(req)) return false + } + + // Verify all expected objects exist in the actual array using deep equality + for (const reqObj of expectedObjects) { + const found = actualObjects.some(actObj => isDeepEqual(actObj, reqObj)) + if (!found) return false + } + + return true + }, + message || `${String(propertyKey)} must contain all specified values`, + ), + ) + } +} + /** * Applies validation rules to each element of an array. * @param args - Validation decorators or functions to apply to each element. diff --git a/packages/express-cargo/tests/validator/arrayContains.test.ts b/packages/express-cargo/tests/validator/arrayContains.test.ts new file mode 100644 index 0000000..0ac246d --- /dev/null +++ b/packages/express-cargo/tests/validator/arrayContains.test.ts @@ -0,0 +1,120 @@ +import { ArrayContains, Body, CargoFieldError } from '../../src' +import { CargoClassMetadata } from '../../src/metadata' + +describe('ArrayContains Validator', () => { + class TestClass { + @Body() + @ArrayContains([1, 2]) + numbers!: number[] + + @Body() + @ArrayContains([{ id: 1 }, { id: 2 }]) + objects!: object[] + + @Body() + @ArrayContains([new Date('2024-01-01'), new Date('2024-06-01')]) + dates!: Date[] + + @ArrayContains([1, { id: 1 }, new Date('2024-01-01')]) + mixed!: any[] + + @Body() + noValidation!: number[] + } + + const classMeta = new CargoClassMetadata(TestClass.prototype) + + it('should have arrayContains validator metadata', () => { + const meta = classMeta.getFieldMetadata('numbers') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule).toBeDefined() + }) + + it('should pass validation when array contains all required primitive values', () => { + const meta = classMeta.getFieldMetadata('numbers') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([1, 2, 3])).toBeNull() + expect(rule?.validate([1, 2])).toBeNull() + expect(rule?.validate([2, 1])).toBeNull() + }) + + it('should fail validation when array is missing required primitive values', () => { + const meta = classMeta.getFieldMetadata('numbers') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([1])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([2])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([3, 4])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([])).toBeInstanceOf(CargoFieldError) + }) + + it('should pass validation when array contains all required objects', () => { + const meta = classMeta.getFieldMetadata('objects') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([{ id: 1 }, { id: 2 }])).toBeNull() + expect(rule?.validate([{ id: 1 }, { id: 2 }, { id: 3 }])).toBeNull() + }) + + it('should fail validation when array is missing required objects', () => { + const meta = classMeta.getFieldMetadata('objects') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([{ id: 1 }])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([{ id: 3 }])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([])).toBeInstanceOf(CargoFieldError) + }) + + it('should pass validation when array contains all required Date values', () => { + const meta = classMeta.getFieldMetadata('dates') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([new Date('2024-01-01'), new Date('2024-06-01')])).toBeNull() + expect(rule?.validate([new Date('2024-01-01'), new Date('2024-06-01'), new Date('2024-12-01')])).toBeNull() + }) + + it('should fail validation when array is missing required Date values', () => { + const meta = classMeta.getFieldMetadata('dates') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([new Date('2024-01-01')])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([new Date('2099-01-01')])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([])).toBeInstanceOf(CargoFieldError) + }) + + it('should fail validation when value is not an array', () => { + const meta = classMeta.getFieldMetadata('numbers') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate(1)).toBeInstanceOf(CargoFieldError) + expect(rule?.validate('string')).toBeInstanceOf(CargoFieldError) + expect(rule?.validate(null)).toBeInstanceOf(CargoFieldError) + expect(rule?.validate(undefined)).toBeInstanceOf(CargoFieldError) + }) + + it('should pass validation when array contains all required mixed type values', () => { + const meta = classMeta.getFieldMetadata('mixed') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([1, { id: 1 }, new Date('2024-01-01')])).toBeNull() + expect(rule?.validate([1, { id: 1 }, new Date('2024-01-01'), 'extra'])).toBeNull() + }) + + it('should fail validation when array is missing any of the required mixed type values', () => { + const meta = classMeta.getFieldMetadata('mixed') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule?.validate([{ id: 1 }, new Date('2024-01-01')])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([1, new Date('2024-01-01')])).toBeInstanceOf(CargoFieldError) + expect(rule?.validate([1, { id: 1 }])).toBeInstanceOf(CargoFieldError) + }) + + it('should not have arrayContains validator metadata on undecorated field', () => { + const meta = classMeta.getFieldMetadata('noValidation') + const rule = meta.getValidators()?.find(v => v.type === 'arrayContains') + + expect(rule).toBeUndefined() + }) +})