diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts index e997874e1..3a4cc486f 100644 --- a/lib/plugin/visitors/model-class.visitor.ts +++ b/lib/plugin/visitors/model-class.visitor.ts @@ -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)) { @@ -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, @@ -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; } @@ -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) + ) + ] : []; } @@ -549,8 +552,6 @@ export class ModelClassVisitor extends AbstractFileVisitor { return [factory.createPropertyAssignment(key, initializer)]; } - - createInitializerForArrayLiteralTypeNode( node: ts.ArrayTypeNode, factory: ts.NodeFactory, @@ -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 @@ -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; } } @@ -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; + } ); } @@ -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, diff --git a/test/plugin/fixtures/create-cat.dto.ts b/test/plugin/fixtures/create-cat.dto.ts index 700faeec7..2812f8ee0 100644 --- a/test/plugin/fixtures/create-cat.dto.ts +++ b/test/plugin/fixtures/create-cat.dto.ts @@ -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, @@ -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[]; @@ -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\"; @@ -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(); @@ -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() diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts index 371a8713d..68feb2e73 100644 --- a/test/services/schema-object-factory.spec.ts +++ b/test/services/schema-object-factory.spec.ts @@ -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 = {}; + + 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 = {}; @@ -819,4 +855,3 @@ describe('SchemaObjectFactory', () => { }); }); }); -