Skip to content

Commit

Permalink
fix: several bug fixes for the readonly visitor
Browse files Browse the repository at this point in the history
  • Loading branch information
kamilmysliwiec committed Jun 26, 2023
1 parent 0a2ad6a commit ec504c2
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 44 deletions.
17 changes: 13 additions & 4 deletions lib/plugin/utils/ast-utils.ts
Expand Up @@ -281,14 +281,23 @@ export function createBooleanLiteral(
return flag ? factory.createTrue() : factory.createFalse();
}

export function createPrimitiveLiteral(factory: ts.NodeFactory, item: unknown) {
const typeOfItem = typeof item;

export function createPrimitiveLiteral(
factory: ts.NodeFactory,
item: unknown,
typeOfItem = typeof item
) {
switch (typeOfItem) {
case 'boolean':
return createBooleanLiteral(factory, item as boolean);
case 'number':
case 'number': {
if ((item as number) < 0) {
return factory.createPrefixUnaryExpression(
SyntaxKind.MinusToken,
factory.createNumericLiteral(Math.abs(item as number))
);
}
return factory.createNumericLiteral(item as number);
}
case 'string':
return factory.createStringLiteral(item as string);
}
Expand Down
15 changes: 15 additions & 0 deletions lib/plugin/utils/plugin-utils.ts
Expand Up @@ -340,3 +340,18 @@ export function convertPath(windowsPath: string) {
.replace(/\\/g, '/')
.replace(/\/\/+/g, '/');
}

/**
* Checks if a node can be directly referenced.
* In the readonly mode, only literals can be referenced directly.
* Nodes like identifiers or call expressions are not available in the auto-generated code.
*/
export function canReferenceNode(node: ts.Node, options: PluginOptions) {
if (!options.readonly) {
return true;
}
if (ts.isIdentifier(node) || ts.isCallExpression(node)) {
return false;
}
return true;
}
130 changes: 94 additions & 36 deletions lib/plugin/visitors/model-class.visitor.ts
Expand Up @@ -16,6 +16,7 @@ import {
isEnum
} from '../utils/ast-utils';
import {
canReferenceNode,
convertPath,
extractTypeArgumentIfArray,
getDecoratorOrUndefinedByNames,
Expand Down Expand Up @@ -248,7 +249,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
): ts.ObjectLiteralExpression {
const isRequired = !node.questionToken;

let properties = [
const properties = [
...existingProperties,
!hasPropertyKey('required', existingProperties) &&
factory.createPropertyAssignment(
Expand All @@ -271,7 +272,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
options,
sourceFile
),
this.createDefaultPropertyAssignment(factory, node, existingProperties),
this.createDefaultPropertyAssignment(
factory,
node,
existingProperties,
options
),
this.createEnumPropertyAssignment(
factory,
node,
Expand All @@ -282,8 +288,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
)
];
if (options.classValidatorShim) {
properties = properties.concat(
this.createValidationPropertyAssignments(factory, node)
properties.push(
this.createValidationPropertyAssignments(factory, node, options)
);
}
return factory.createObjectLiteralExpression(compact(flatten(properties)));
Expand Down Expand Up @@ -471,7 +477,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
createDefaultPropertyAssignment(
factory: ts.NodeFactory,
node: ts.PropertyDeclaration | ts.PropertySignature,
existingProperties: ts.NodeArray<ts.PropertyAssignment>
existingProperties: ts.NodeArray<ts.PropertyAssignment>,
options: PluginOptions
) {
const key = 'default';
if (hasPropertyKey(key, existingProperties)) {
Expand All @@ -484,50 +491,65 @@ export class ModelClassVisitor extends AbstractFileVisitor {
if (ts.isAsExpression(initializer)) {
initializer = initializer.expression;
}
initializer =
this.clonePrimitiveLiteral(factory, initializer) ?? initializer;

if (!canReferenceNode(initializer, options)) {
return undefined;
}
return factory.createPropertyAssignment(key, initializer);
}

createValidationPropertyAssignments(
factory: ts.NodeFactory,
node: ts.PropertyDeclaration | ts.PropertySignature
node: ts.PropertyDeclaration | ts.PropertySignature,
options: PluginOptions
): ts.PropertyAssignment[] {
const assignments = [];
const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node);

this.addPropertyByValidationDecorator(
factory,
'IsIn',
'enum',
decorators,
assignments
);
if (!options.readonly) {
// @IsIn() annotation is not supported in readonly mode
this.addPropertyByValidationDecorator(
factory,
'IsIn',
'enum',
decorators,
assignments,
options
);
}
this.addPropertyByValidationDecorator(
factory,
'Min',
'minimum',
decorators,
assignments
assignments,
options
);
this.addPropertyByValidationDecorator(
factory,
'Max',
'maximum',
decorators,
assignments
assignments,
options
);
this.addPropertyByValidationDecorator(
factory,
'MinLength',
'minLength',
decorators,
assignments
assignments,
options
);
this.addPropertyByValidationDecorator(
factory,
'MaxLength',
'maxLength',
decorators,
assignments
assignments,
options
);
this.addPropertiesByValidationDecorator(
factory,
Expand Down Expand Up @@ -564,21 +586,36 @@ export class ModelClassVisitor extends AbstractFileVisitor {
assignments,
(decoratorRef: ts.Decorator) => {
const decoratorArguments = getDecoratorArguments(decoratorRef);

const result = [];
result.push(
factory.createPropertyAssignment(
'minLength',
head(decoratorArguments)
)
);

if (decoratorArguments.length > 1) {
const minLength = head(decoratorArguments);
if (!canReferenceNode(minLength, options)) {
return result;
}

const clonedMinLength = this.clonePrimitiveLiteral(factory, minLength);
if (clonedMinLength) {
result.push(
factory.createPropertyAssignment('maxLength', decoratorArguments[1])
factory.createPropertyAssignment('minLength', clonedMinLength)
);
}

if (decoratorArguments.length > 1) {
const maxLength = decoratorArguments[1];
if (!canReferenceNode(maxLength, options)) {
return result;
}
const clonedMaxLength = this.clonePrimitiveLiteral(
factory,
maxLength
);
if (clonedMaxLength) {
result.push(
factory.createPropertyAssignment('maxLength', clonedMaxLength)
);
}
}

return result;
}
);
Expand Down Expand Up @@ -606,7 +643,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
decoratorName: string,
propertyKey: string,
decorators: readonly ts.Decorator[],
assignments: ts.PropertyAssignment[]
assignments: ts.PropertyAssignment[],
options: PluginOptions
) {
this.addPropertiesByValidationDecorator(
factory,
Expand All @@ -617,16 +655,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
const argument: ts.Expression = head(
getDecoratorArguments(decoratorRef)
);
const assignment = ts.isNumericLiteral(argument)
? ts.factory.createNumericLiteral(argument.text)
: ts.isStringLiteral(argument)
? ts.factory.createStringLiteral(argument.text)
: argument;

if (assignment) {
return [factory.createPropertyAssignment(propertyKey, assignment)];
const assignment =
this.clonePrimitiveLiteral(factory, argument) ?? argument;
if (!canReferenceNode(assignment, options)) {
return [];
}
return [];
return [factory.createPropertyAssignment(propertyKey, assignment)];
}
);
}
Expand Down Expand Up @@ -774,4 +808,28 @@ export class ModelClassVisitor extends AbstractFileVisitor {
}
return typeRef;
}

private clonePrimitiveLiteral(factory: ts.NodeFactory, node: ts.Node) {
const primitiveTypeName = this.getInitializerPrimitiveTypeName(node);
if (!primitiveTypeName) {
return undefined;
}
return createPrimitiveLiteral(factory, node.getText(), primitiveTypeName);
}

private getInitializerPrimitiveTypeName(node: ts.Node) {
if (
ts.isIdentifier(node) &&
(node.text === 'true' || node.text === 'false')
) {
return 'boolean';
}
if (ts.isNumericLiteral(node) || ts.isPrefixUnaryExpression(node)) {
return 'number';
}
if (ts.isStringLiteral(node)) {
return 'string';
}
return undefined;
}
}
38 changes: 36 additions & 2 deletions test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
@@ -1,3 +1,13 @@
import {
IsIn,
IsNegative,
IsPositive,
Length,
Matches,
Max,
Min
} from 'class-validator';
import { randomUUID } from 'node:crypto';
import { ApiExtraModels, ApiProperty } from '../../../../lib';
import { ExtraModel } from './extra-model.dto';
import { LettersEnum } from './pagination-query.dto';
Expand All @@ -8,13 +18,37 @@ export enum CategoryState {
DEPRECATED = 'DEPRECATED'
}

const MAX_AGE = 200;

@ApiExtraModels(ExtraModel)
export class CreateCatDto {
@IsIn(['a', 'b'])
isIn: string;

@Matches(/^[+]?abc$/)
pattern: string;

@IsPositive()
positive: number = 5;

@IsNegative()
negative: number = -1;

@Length(2)
lengthMin: string;

@Length(3, 5)
lengthMinMax: string;

date = new Date();

@ApiProperty()
readonly name: string;
readonly name: string = randomUUID();

@Min(1)
@Max(MAX_AGE)
@ApiProperty({ minimum: 1, maximum: 200 })
readonly age: number;
readonly age: number = 14;

@ApiProperty({ name: '_breed', type: String })
readonly breed: string;
Expand Down
33 changes: 32 additions & 1 deletion test/plugin/fixtures/serialized-meta.fixture.ts
Expand Up @@ -84,8 +84,39 @@ export default async () => {
import('./cats/dto/create-cat.dto'),
{
CreateCatDto: {
isIn: { required: true, type: () => String },
pattern: {
required: true,
type: () => String,
pattern: '/^[+]?abc$/'
},
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
},
date: { required: true, type: () => Object, default: new Date() },
name: { required: true, type: () => String },
age: { required: true, type: () => Number },
age: {
required: true,
type: () => Number,
default: 14,
minimum: 1
},
breed: { required: true, type: () => String },
tags: { required: false, type: () => [String] },
createdAt: { required: true, type: () => Date },
Expand Down

0 comments on commit ec504c2

Please sign in to comment.