Skip to content
Open
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
137 changes: 91 additions & 46 deletions lib/plugin/visitors/model-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,36 +73,36 @@ export class ModelClassVisitor extends AbstractFileVisitor {

const propertyNodeVisitorFactory =
(metadata: ClassMetadata) =>
(node: ts.Node): ts.Node => {
const visit = () => {
if (ts.isPropertyDeclaration(node)) {
this.visitPropertyNodeDeclaration(
node,
ctx,
typeChecker,
options,
sourceFile,
metadata
);
} else if (
options.parameterProperties &&
ts.isConstructorDeclaration(node)
) {
this.visitConstructorDeclarationNode(
node,
typeChecker,
options,
sourceFile,
metadata
);
}
return node;
};
const visitedNode = visit();
if (!options.readonly) {
return visitedNode;
(node: ts.Node): ts.Node => {
const visit = () => {
if (ts.isPropertyDeclaration(node)) {
this.visitPropertyNodeDeclaration(
node,
ctx,
typeChecker,
options,
sourceFile,
metadata
);
} else if (
options.parameterProperties &&
ts.isConstructorDeclaration(node)
) {
this.visitConstructorDeclarationNode(
node,
typeChecker,
options,
sourceFile,
metadata
);
}
return node;
};
const visitedNode = visit();
if (!options.readonly) {
return visitedNode;
}
};

const visitClassNode = (node: ts.Node): ts.Node => {
if (ts.isClassDeclaration(node)) {
Expand Down Expand Up @@ -349,10 +349,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
const properties = [
...existingProperties,
!hasPropertyKey('required', existingProperties) &&
factory.createPropertyAssignment(
'required',
createBooleanLiteral(factory, isRequired)
),
factory.createPropertyAssignment(
'required',
createBooleanLiteral(factory, isRequired)
),
...this.createTypePropertyAssignments(
factory,
node.type,
Expand Down Expand Up @@ -472,7 +472,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
isEnumType = true;
} else {
// Handle auto-generated enum unions (string literal unions that represent an enum)
const maybeEnum = isAutoGeneratedEnumUnion(candidateType, typeChecker);
const maybeEnum = isAutoGeneratedEnumUnion(
candidateType,
typeChecker
);
if (maybeEnum) {
isEnumType = true;
}
Expand All @@ -485,11 +488,11 @@ export class ModelClassVisitor extends AbstractFileVisitor {
// The enum metadata will be added by createEnumPropertyAssignment().
return isNullable
? [
factory.createPropertyAssignment(
'nullable',
createBooleanLiteral(factory, true)
)
]
factory.createPropertyAssignment(
'nullable',
createBooleanLiteral(factory, true)
)
]
: [];
}

Expand Down Expand Up @@ -549,8 +552,6 @@ export class ModelClassVisitor extends AbstractFileVisitor {
return [factory.createPropertyAssignment(key, initializer)];
}



createInitializerForArrayLiteralTypeNode(
node: ts.ArrayTypeNode,
factory: ts.NodeFactory,
Expand Down Expand Up @@ -656,7 +657,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
let type: ts.Type | undefined;
try {
if ((node as any).type) {
type = typeChecker.getTypeFromTypeNode((node as any).type as ts.TypeNode);
type = typeChecker.getTypeFromTypeNode(
(node as any).type as ts.TypeNode
);
}
} catch (e) {
// fallthrough to getTypeAtLocation
Expand All @@ -682,9 +685,11 @@ export class ModelClassVisitor extends AbstractFileVisitor {
// (strict mode). In that case, pick the non-undefined member so enum detection works.
if (isAutoGeneratedTypeUnion(type)) {
const types = (type as ts.UnionOrIntersectionType).types;
const nonUndefined = types.find((t: any) => t.intrinsicName !== 'undefined');
const nonUndefined = types.find(
(t: any) => t.intrinsicName !== 'undefined'
);
if (nonUndefined) {
type = nonUndefined as ts.Type;
type = nonUndefined;
}
}

Expand Down Expand Up @@ -793,13 +798,35 @@ export class ModelClassVisitor extends AbstractFileVisitor {

if (!options.readonly) {
// @IsIn() annotation is not supported in readonly mode
this.addPropertyByValidationDecorator(
this.addPropertiesByValidationDecorator(
factory,
'IsIn',
'enum',
decorators,
assignments,
options
(decoratorRef: ts.Decorator) => {
const decoratorArguments = getDecoratorArguments(decoratorRef);
const result = [];

const argumentValue = head(decoratorArguments);
if (!canReferenceNode(argumentValue, options)) {
return result;
}

const assignment =
this.clonePrimitiveLiteral(factory, argumentValue) ?? argumentValue;
result.push(factory.createPropertyAssignment('enum', assignment));

if (this.isEachOptionEnabled(decoratorArguments[1])) {
result.push(
factory.createPropertyAssignment(
'isArray',
factory.createIdentifier('true')
)
);
}

return result;
}
);
}

Expand Down Expand Up @@ -972,6 +999,24 @@ export class ModelClassVisitor extends AbstractFileVisitor {
assignments.push(...addPropertyAssignments(decoratorRef));
}

private isEachOptionEnabled(optionsArgument: ts.Expression | undefined) {
if (!optionsArgument || !ts.isObjectLiteralExpression(optionsArgument)) {
return false;
}

return optionsArgument.properties.some((property) => {
if (
!ts.isPropertyAssignment(property) ||
!ts.isIdentifier(property.name) ||
property.name.text !== 'each'
) {
return false;
}

return property.initializer.kind === ts.SyntaxKind.TrueKeyword;
});
}

addClassMetadata(
node: ts.PropertyDeclaration,
objectLiteral: ts.ObjectLiteralExpression,
Expand Down
18 changes: 15 additions & 3 deletions test/plugin/fixtures/create-cat.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const createCatDtoText = `
import { UUID } from 'crypto';
import { IsInt, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
import { IsArray, IsInt, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';

enum Status {
ENABLED,
Expand Down Expand Up @@ -69,6 +69,11 @@ export class CreateCatDto {
@Contains('log_')
searchBy: string;
tags: string[];
@IsIn(['red', 'green'], { each: false })
favoriteColor: string;
@IsArray()
@IsIn(['black', 'white'], { each: true })
favoriteColors: string[];
status: Status = Status.ENABLED;
status2?: Status;
statusArr?: Status[];
Expand Down Expand Up @@ -102,7 +107,7 @@ export class CreateCatDto {

export const createCatDtoTextTranspiled = `var _CreateCatDto_privateProperty;
import * as openapi from "@nestjs/swagger";
import { IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
import { IsArray, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
var Status;
(function (Status) {
Status[Status[\"ENABLED\"] = 0] = \"ENABLED\";
Expand All @@ -126,7 +131,7 @@ export class CreateCatDto {
_CreateCatDto_privateProperty.set(this, void 0);
}
static _OPENAPI_METADATA_FACTORY() {
return { isIn: { required: true, type: () => String, enum: ['a', 'b'] }, pattern: { required: true, type: () => String, pattern: "/^[+]?abc$/" }, name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, positive: { required: true, type: () => Number, default: 5, minimum: 1 }, negative: { required: true, type: () => Number, default: -1, maximum: -1 }, lengthMin: { required: true, type: () => String, minLength: 2 }, lengthMinMax: { required: true, type: () => String, minLength: 3, maxLength: 5 }, names: { required: true, type: () => [String], minItems: 1, uniqueItems: true, maxItems: 10 }, employees: { required: true, type: () => [String], minItems: 1 }, nominator: { required: true, type: () => String, multipleOf: 2 }, encodedInfo: { required: true, type: () => String, format: "base64" }, creditCard: { required: true, type: () => String, format: "credit-card" }, currency: { required: true, type: () => String, format: "currency" }, email: { required: true, type: () => String, format: "email" }, response: { required: true, type: () => Object, format: "json" }, githubAccount: { required: true, type: () => String, format: "uri" }, transactionId: { required: true, type: () => String, format: "uuid" }, phoneNumber: { required: true, type: () => String, format: "mobile-phone" }, char: { required: true, type: () => String, pattern: "^[\\\\x00-\\\\x7F]+$" }, color: { required: true, type: () => String, pattern: "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$" }, hex: { required: true, type: () => String, pattern: "^(0x|0h)?[0-9A-F]+$" }, searchBy: { required: true, type: () => String, pattern: "log_" }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum, isArray: true }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date }, twoDimensionPrimitives: { required: true, type: () => [[String]] }, twoDimensionNodes: { required: true, type: () => [[OtherNode]] }, cryptoUUIDProperty: { required: true, type: () => String }, arrayOfUUIDs: { required: true, type: () => [String] } };
return { isIn: { required: true, type: () => String, enum: ['a', 'b'] }, pattern: { required: true, type: () => String, pattern: "/^[+]?abc$/" }, name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, positive: { required: true, type: () => Number, default: 5, minimum: 1 }, negative: { required: true, type: () => Number, default: -1, maximum: -1 }, lengthMin: { required: true, type: () => String, minLength: 2 }, lengthMinMax: { required: true, type: () => String, minLength: 3, maxLength: 5 }, names: { required: true, type: () => [String], minItems: 1, uniqueItems: true, maxItems: 10 }, employees: { required: true, type: () => [String], minItems: 1 }, nominator: { required: true, type: () => String, multipleOf: 2 }, encodedInfo: { required: true, type: () => String, format: "base64" }, creditCard: { required: true, type: () => String, format: "credit-card" }, currency: { required: true, type: () => String, format: "currency" }, email: { required: true, type: () => String, format: "email" }, response: { required: true, type: () => Object, format: "json" }, githubAccount: { required: true, type: () => String, format: "uri" }, transactionId: { required: true, type: () => String, format: "uuid" }, phoneNumber: { required: true, type: () => String, format: "mobile-phone" }, char: { required: true, type: () => String, pattern: "^[\\\\x00-\\\\x7F]+$" }, color: { required: true, type: () => String, pattern: "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$" }, hex: { required: true, type: () => String, pattern: "^(0x|0h)?[0-9A-F]+$" }, searchBy: { required: true, type: () => String, pattern: "log_" }, tags: { required: true, type: () => [String] }, favoriteColor: { required: true, type: () => String, enum: ['red', 'green'] }, favoriteColors: { required: true, type: () => [String], enum: ['black', 'white'], isArray: true }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum, isArray: true }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date }, twoDimensionPrimitives: { required: true, type: () => [[String]] }, twoDimensionNodes: { required: true, type: () => [[OtherNode]] }, cryptoUUIDProperty: { required: true, type: () => String }, arrayOfUUIDs: { required: true, type: () => [String] } };
}
}
_CreateCatDto_privateProperty = new WeakMap();
Expand Down Expand Up @@ -199,6 +204,13 @@ __decorate([
__decorate([
Contains('log_')
], CreateCatDto.prototype, \"searchBy\", void 0);
__decorate([
IsIn(['red', 'green'], { each: false })
], CreateCatDto.prototype, \"favoriteColor\", void 0);
__decorate([
IsArray(),
IsIn(['black', 'white'], { each: true })
], CreateCatDto.prototype, \"favoriteColors\", void 0);
__decorate([
ApiProperty({ description: "this is breed", type: String }),
IsString()
Expand Down
37 changes: 36 additions & 1 deletion test/services/schema-object-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,42 @@ describe('SchemaObjectFactory', () => {
});
});

it('should merge array enum metadata without keeping a top-level enum', () => {
class ValidationShimArrayEnumDto {
@ApiProperty({ enum: Role, isArray: true })
roles: Role[];

static _OPENAPI_METADATA_FACTORY() {
return {
roles: {
enum: Role,
isArray: true
}
};
}
}
const schemas: Record<string, SchemasObject> = {};

schemaObjectFactory.exploreModelSchema(
ValidationShimArrayEnumDto,
schemas
);

expect(schemas.ValidationShimArrayEnumDto).toEqual({
type: 'object',
properties: {
roles: {
type: 'array',
items: {
type: 'string',
enum: ['admin', 'user']
}
}
},
required: ['roles']
});
});

it('should log an error when detecting duplicate DTOs with different schemas', () => {
const loggerErrorSpy = jest.spyOn(Logger, 'error').mockImplementation();
const schemas: Record<string, SchemasObject> = {};
Expand Down Expand Up @@ -819,4 +855,3 @@ describe('SchemaObjectFactory', () => {
});
});
});