Skip to content
Closed
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@
"typescript-validator"
],
"dependencies": {
"lodash": "^4.17.4",
"validator": "9.2.0"
},
"devDependencies": {
"@types/chai": "^3.4.35",
"@types/chai-as-promised": "0.0.30",
"@types/gulp": "^4.0.2",
"@types/jest": "^22.0.1",
"@types/lodash": "^4.14.93",
"@types/mocha": "^2.2.40",
"@types/node": "^8.10.11",
"@types/sinon": "^1.16.36",
Expand Down
182 changes: 182 additions & 0 deletions src/decorator/inherit-validation/inherit-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as _ from "lodash";
import {getFromContainer} from "../../container";
import {validate} from "../../index";
import {MetadataStorage} from "../../metadata/MetadataStorage";
import {ValidationMetadata} from "../../metadata/ValidationMetadata";
import {IsString, MaxLength, IsNumber, Max, IsEmail, IsOptional} from "../decorators";
import InheritValidation from "./inherit-validation";

/**
* Used as a base for validation, in order for partial classes
* to pick validation metadatas, property by property.
*/
class Dto {
@IsNumber()
@Max(999)
readonly id: number;

@IsString()
@MaxLength(10)
readonly name: number;

@IsEmail()
@IsOptional()
readonly email: string;
}
const validationsCount = 6;

describe("@InheritValidation", () => {
let dtoMetaDatas: ValidationMetadata[];

beforeEach(() => {
dtoMetaDatas = getMetadatasFrom(Dto);
expect(dtoMetaDatas).toHaveLength(validationsCount);
});

it("does not modify metadatas of the source class", () => {
const dtoMetadatasNow = getMetadatasFrom(Dto);
expect(dtoMetaDatas).toEqual(dtoMetadatasNow);
});

it("does a deep copy of validation metadatas", () => {
class SubDto {
@InheritValidation(Dto, "name")
readonly name: string;
}

const dtoMetadatas = getMetadatasFrom(Dto, "name");
const subMetadatas = getMetadatasFrom(SubDto, "name");
const areEqual = areMetadatasEqual(
[dtoMetadatas, subMetadatas],
// `propertyName` did not change ("name" => "name"), but `target` did: remove it
["target"],
);
// with `target` removed, this is a perfect copy
expect(areEqual).toBe(true);
});

it("uses destination property name as a source property name if none is given", () => {
class SubDto {
@InheritValidation(Dto)
readonly name: string;
}

const dtoMetadatas = getMetadatasFrom(Dto, "name");
const subMetadatas = getMetadatasFrom(SubDto, "name");
const areEqual = areMetadatasEqual(
[dtoMetadatas, subMetadatas],
["target"],
);
expect(areEqual).toBe(true);
});

it("allows inheriting validation metadatas with a different property name", () => {
class SubDto {
@InheritValidation(Dto, "name")
readonly nickname: string;
}

const dtoMetadatas = getMetadatasFrom(Dto, "name");
const subMetadatas = getMetadatasFrom(SubDto, "nickname");
const areEqual = areMetadatasEqual(
[dtoMetadatas, subMetadatas],
// `propertyName` and `target` changed: remove them before checking equality
["propertyName", "target"],
);
expect(areEqual).toBe(true);
});

it("can be used on multiple properties", () => {
class SubDto {
@InheritValidation(Dto)
readonly id: number;

@InheritValidation(Dto)
readonly name: string;
}

const dtoMetadatas = _.concat(
// only get metadatas from fields used by SubDto
getMetadatasFrom(Dto, "id"),
getMetadatasFrom(Dto, "name"),
);
const subMetadatas = getMetadatasFrom(SubDto);
const areEqual = areMetadatasEqual(
[dtoMetadatas, subMetadatas],
["target"],
);
expect(areEqual).toBe(true);
});

it("uses the inherited metadatas for objects validation", async () => {
class SubDto {
constructor(name: string) {
this.name = name;
}

@InheritValidation(Dto)
readonly name: string;
}

const validSubDto = new SubDto("Mike");
expect(await validate(validSubDto)).toHaveLength(0);

const invalidSubDto = new SubDto("way_too_long_name");
const errors = await validate(invalidSubDto);
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty("maxLength");
});
});

/**
* Use `class-validator`"s `MetadataStorage` to get the `ValidationMetadata`s
* of a given class, or (more specific) one of its property.
*
* @param fromClass Class to get `ValidationMetadata`s from.
* @param property Source property (if none is given, get metadatas from all properties).
*
* @return {ValidationMetadata[]} Target metadatas.
*/
function getMetadatasFrom(
fromClass: new () => object,
property?: string,
): ValidationMetadata[] {
const metadataStorage = getFromContainer(MetadataStorage);
const metadatas = _.cloneDeep(metadataStorage.getTargetValidationMetadatas(
fromClass,
undefined,
));

if (!property) {
return metadatas;
}

return metadatas.filter((vm) => vm.propertyName === property);
}

/**
* Determine whether two collections of `ValidationMetadata`s are
* the same, eventually after having removed a few fields (which are
* known to have been changed by design).
*
* @param metaDataCollections Array of 2 `ValidationMetadata[]` to be compared.
* @param withoutFields Fields to be removed from the metadatas before comparing them.
*
* @return {boolean} `true` if both collections are equal.
*/
function areMetadatasEqual(
metaDataCollections: ValidationMetadata[][],
withoutFields: string[],
): boolean {
if (metaDataCollections.length !== 2) {
throw new TypeError("Misuse of metadatasAreEqual");
}

_.each(withoutFields, (field) => {
_.each(metaDataCollections, (metadatas) => {
_.each(metadatas, (md) => _.unset(md, field));
});
});

return _.isEqual(metaDataCollections[0], metaDataCollections[1]);
}
69 changes: 69 additions & 0 deletions src/decorator/inherit-validation/inherit-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as _ from "lodash";
import {getFromContainer} from "../../container";
import {MetadataStorage} from "../../metadata/MetadataStorage";
import {ValidationMetadata} from "../../metadata/ValidationMetadata";

/**
* Allow copying validation metadatas set by `class-validator` from
* a given Class property to an other. Copied `ValidationMetadata`s
* will have their `target` and `propertyName` changed according to
* the decorated class and property.
*
* @param fromClass Class to inherit validation metadatas from.
* @param fromProperty Name of the target property (default to decorated property).
*
* @return {PropertyDecorator} Responsible for copying and registering `ValidationMetada`s.
*
* @example
* class SubDto {
* @InheritValidation(Dto)
* readonly id: number;
*
* @InheritValidation(Dto, 'name')
* readonly firstName: string;
*
* @InheritValidation(Dto, 'name')
* readonly lastName: string;
* }
*/
function InheritValidation(
fromClass: new () => object,
fromProperty?: string,
): PropertyDecorator {
const metadataStorage = getFromContainer(MetadataStorage);
const validationMetadatas = metadataStorage.getTargetValidationMetadatas(
fromClass,
typeof fromClass,
);

/**
* Change the `target` and `propertyName` of each `ValidationMetaData`
* and add it to `MetadataStorage`. Thus, `class-validator` uses it
* during validation.
*
* @param toClass Class owning the decorated property.
* @param toProperty Name of the decorated property.
*/
return (toClass: object, toProperty: string | symbol) => {
const toPropertyName: string = toProperty instanceof Symbol ?
typeof toProperty :
toProperty;

const sourceProperty = fromProperty || toProperty;

const metadatasCopy = _.cloneDeep(
validationMetadatas.filter((vm) =>
vm.target === fromClass &&
vm.propertyName === sourceProperty,
),
);

metadatasCopy.forEach((vm: ValidationMetadata) => {
vm.target = toClass.constructor;
vm.propertyName = toPropertyName;
metadataStorage.addValidationMetadata(vm);
});
};
}

export default InheritValidation;