Ultimate CRUD operation implementation (Nest JS Series 12)

Deepak Mandal
Dev Genius
Published in
11 min readApr 26, 2024

--

Photo by The Lucky Neko on Unsplash

I am back with another article that will give an advanced boost to our application, and it is actually growing quite big. In later stages, we will just create a separate module for each functionality. In this or in this series of CRUD articles, I just want to give you guys taste of something ultimate that make yourself more advanced in Nest JS.

So what are our requirements that will define the whole code structure in CRUD. I am putting them in a list so we can work accordingly and create what we actually need.

  1. An entity (Post entity)
  2. a controller that will handle CRUD (Create Read Update Delete) operations
  3. a service to implement the CRUD operations logic
  4. a custom Pipe that will convert normal UUID to their respective entity dynamically.
  5. Pagination Query DTO
  6. Pagination Swagger response decorator
  7. Pagination Response creator
  8. paginate function for query builder in TypeORM
  9. Post Module that will get registered in App Module

Above are required things, not steps because we will actually more into those, like grouping of validation and overriding parameter for swagger. So let us get started with our requirements above that will complete our entire CRUD operation in this particular article. I will be more explanatory as it will get more into deep.

So let me start with a normal entity of posts.

// src/entities/post.entity.ts

import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { BaseEntity } from './base';
import { User } from './user.entity';

@Entity({ name: 'posts' })
export class Post extends BaseEntity {
@ApiProperty({ example: 'Here is my title.' })
@Column({ type: 'varchar', length: 255 })
title: string;

@ApiProperty({ example: 'My content' })
@Column({ type: 'text' })
content: string;

@ApiProperty()
@Column({ type: 'boolean', default: false })
is_published: boolean;

@ApiProperty({ type: () => User })
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'user_id' })
user: User;

@ApiProperty()
@Column({ type: 'uuid' })
user_id: string;
}

Above we a post with fields

  1. title
  2. content
  3. is_published
  4. user_id for relationship with user entity

In order to establish a connection between a user and a user entity through a relationship, we are also incorporating a relationship decorator from TypeORM.

@ApiProperty({ type: () => User })
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'user_id' })
user: User;

The same goes for user entity

// src/entities/user.entity.ts

...
import { Post } from './post.entity';

@Entity({ name: 'users' })
export class User extends BaseEntity {
...

@ApiHideProperty()
@OneToMany(() => Post, (post) => post.user)
posts: Post[];

...
}

Now to sync the DB with our entity we need to run npm run migration:generate and npm run migration:run. To incorporate the typescript path I have did changes in command in package.json file.

// updated with below script so typeorm can understand the path imports in ts
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm",
"migration:generate": "npm run typeorm -- migration:generate src/modules/database/migrations/migrations -d ormconfig.ts",
"migration:run": "npm run typeorm -- -d ormconfig.ts migration:run",
"migration:revert": "npm run typeorm -- -d ormconfig.ts migration:revert"

We are now moving towards controller that is going to handle endpoints but will be explaining everything after looking into whole controller at the end.

// src/modules/post/post.controller.ts

import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiParam,
ApiTags,
PickType,
} from '@nestjs/swagger';
import { ValidationGroup } from 'src/crud/validation-group';
import { UserParam } from 'src/decorators/user.decorator';
import { Post as PostEntity } from 'src/entities/post.entity';
import { User } from 'src/entities/user.entity';
import { ApiPaginatedResponse } from 'src/pagination/pagination.decorators';
import { PaginationQuery } from 'src/pagination/pagination.dto';
import { IsIDExistPipe } from 'src/pipe/IsIDExist.pipe';
import validationOptions from 'src/utils/validation-options';
import { PostService } from './post.service';
import { AuthGuard } from '@nestjs/passport';

@ApiTags('Post')
@Controller({
path: 'posts',
version: '1',
})
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
export class PostController {
constructor(private postService: PostService) {}

@Post()
@ApiBody({
type: PickType(PostEntity, ['content', 'title', 'is_published']),
})
@ApiCreatedResponse({
type: PostEntity,
})
create(
@Body(
new ValidationPipe({
...validationOptions,
groups: [ValidationGroup.CREATE],
}),
)
createDto: PostEntity,
@UserParam() user: User,
) {
return this.postService.create(createDto, user);
}

@Get()
@ApiPaginatedResponse({
type: PostEntity,
})
getAll(@Query() paginationDto: PaginationQuery, @UserParam() user: User) {
return this.postService.getAll(user, paginationDto);
}

@Get(':id')
@ApiCreatedResponse({
type: PostEntity,
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
getOne(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
) {
return post;
}

@Patch(':id')
@ApiCreatedResponse({
type: PostEntity,
})
@ApiBody({
type: PickType(PostEntity, ['content', 'title', 'is_published']),
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
partialUpdate(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
@Body(
new ValidationPipe({
...validationOptions,
groups: [ValidationGroup.UPDATE],
}),
)
updateDto: PostEntity,
@UserParam() user: User,
) {
return this.postService.update(post, user, updateDto);
}

@Delete(':id')
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
delete(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
@UserParam() user: User,
) {
return this.postService.delete(post, user);
}
}

The controller is a bit of overwhelming at first but when I will go through every part one by one. You will get idea why are we being putting things like this and what important they have in this entire CRUD operation.

So we are not going to use any extra DTOs that will handle the validation but the Entity. But entity can not handle multiple validation rules for multiple endpoints, That is why I have introduced groups enum. CREATE group will get handled in create endpoint of the CRUD and UPDATE group will get handled in update endpoint of the CRUD. The question is how?

Class validator provide us a grouping mechanism, So in validation options we can define any group we want to get validated, and other groups will get ignore except always. If always have been true in any validation decorator, that will get executed anyhow for all the groups.

To do something like that in entity, we have to define first our enum that will hold the validation groups.

// src/crud/validation-group.ts

export enum ValidationGroup {
CREATE = 'CREATE',
UPDATE = 'UPDATE',
}

I am creating a separate folder for CRUD because in future that can be helpful to separate any other concerns. Now to use an entity as a validator DTO we need to define the decorator in the entity.

// src/entities/post.entity.ts

import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { BaseEntity } from './base';
import { User } from './user.entity';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { ValidationGroup } from 'src/crud/validation-group';

@Entity({ name: 'posts' })
export class Post extends BaseEntity {
@ApiProperty({ example: 'Here is my title.' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@IsString({ always: true })
@MaxLength(255, { always: true })
@Column({ type: 'varchar', length: 255 })
title: string;

@ApiProperty({ example: 'My content' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@IsString({ always: true })
@Column({ type: 'text' })
content: string;

@IsOptional({ groups: [ValidationGroup.UPDATE] })
@ApiProperty()
@IsBoolean({ always: true })
@Column({ type: 'boolean', default: false })
is_published: boolean;

@ApiProperty({ type: () => User })
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'user_id' })
user: User;

@ApiProperty()
@Column({ type: 'uuid' })
user_id: string;
}

So updated entity have different grouping as update will have fields as optional but create will not have any optional fields. Also, we don’t need any user_id or user to be as field in DTO. Same implementation you can see in the above controller, I have attached a snippet of two for update and create endpoint below.

// src/modules/post/post.controller.ts

// create endpoint function

@Post()
@ApiBody({
type: PickType(PostEntity, ['content', 'title', 'is_published']),
})
@ApiCreatedResponse({
type: PostEntity,
})
create(
// we are using Post entity as DTO and
// defined our required validation group
@Body(
new ValidationPipe({
...validationOptions,
groups: [ValidationGroup.CREATE],
}),
)
createDto: PostEntity,
@UserParam() user: User,
) {
return this.postService.create(createDto, user);
}

@Patch(':id')
@ApiCreatedResponse({
type: PostEntity,
})
@ApiBody({
type: PickType(PostEntity, ['content', 'title', 'is_published']),
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
partialUpdate(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
// we are using Post entity as DTO and
// defined our required validation group
@Body(
new ValidationPipe({
...validationOptions,
groups: [ValidationGroup.UPDATE],
}),
)
updateDto: PostEntity,
@UserParam() user: User,
) {
return this.postService.update(post, user, updateDto);
}

In above, In @Body decorator you can just see our validation pipe with groups detail according to requirement if they are creating or updating the details.

Now to adjust things in swagger we need to use PickType the helper function provided by Nest JS. by using it I can define what we require in body dynamically. Below you can see one example. We also have many helper functions for Swagger You can just see like OmitType etc.

@ApiBody({
type: PickType(PostEntity, ['content', 'title', 'is_published']),
})

Most important helper Pipe we require is that transform normal ID to entity and also checks if they exist or not, if not then just throws not found error. I have created one very useful as pipes are also injectable that is why I am just able to insert DataSource. That means we are able to do any of the things with DB.

// src/pipes/IsIDExist.pipe.ts

import { Injectable, NotFoundException, PipeTransform } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { DataSource, FindOptionsRelations } from 'typeorm';

type IsIDExistPipeType = (options: {
entity: EntityClassOrSchema;
filterField?: string;
relations?: FindOptionsRelations<any>;
}) => any;

// To solve mixin issue of class returned by function you refer below link
// https://github.com/microsoft/TypeScript/issues/30355#issuecomment-839834550
// for now we are just going with any
export const IsIDExistPipe: IsIDExistPipeType = ({
entity,
filterField = 'id',
relations,
}) => {
@Injectable()
class IsIDExistMixinPipe implements PipeTransform {
protected exceptionFactory: (error: string) => any;

constructor(@InjectDataSource() private dataSource: DataSource) {}

async transform(value: string) {
const repository = this.dataSource.getRepository(entity);

const instance = await repository.findOne({
where: { [filterField]: value },
relations,
});
if (!instance) {
throw new NotFoundException(
`${filterField} ${value.toString()} of ${(entity as any).name} does not exists.`,
);
}
return instance;
}
}
return IsIDExistMixinPipe;
};

Above, our function actually returns with the pipe class that holds all the functionality, but the function does get all the required parameters. That pipe can be used anywhere and already been used in controller.

@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity

To compensate with automatic swagger generation of parameters according to PostEntity we are overriding with

@ApiParam({ name: 'id', type: 'string', format: 'uuid' })

As we have completed explanation for create and update, for complete operation we will look into service later. Now to tackle other endpoints, I think getOne or delete are very clear.

// src/modules/post/post.controller.ts

// get one endpoint function
@Get(':id')
@ApiCreatedResponse({
type: PostEntity,
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
getOne(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
) {
return post;
}


// delete one endpoint function
@Delete(':id')
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
delete(
@Param(
'id',
ParseUUIDPipe,
IsIDExistPipe({ entity: PostEntity, relations: { user: true } }),
)
post: PostEntity,
@UserParam() user: User,
) {
return this.postService.delete(post, user);
}

So after this we can move to getAll that actually needs some extra things to be in place. First thing we can define is pagination query DTO.

// src/pagination/pagination.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, Max, Min } from 'class-validator';

export class PaginationQuery {
@ApiProperty({
minimum: 1,
title: 'Page',
exclusiveMaximum: true,
exclusiveMinimum: true,
default: 1,
type: 'integer',
required: false,
})
@IsNotEmpty()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;

@ApiProperty({
minimum: 10,
maximum: 50,
title: 'Limit',
default: 10,
type: 'integer',
required: false,
})
@IsNotEmpty()
@Type(() => Number)
@Transform(({ value }) => (value > 50 ? 50 : value))
@Transform(({ value }) => (value < 10 ? 10 : value))
@IsInt()
@Min(10)
@Max(50)
limit = 10;
}

It handles page and limit that are going to implement in pagination and also validates values should not be less than 10 or more than 50 for limit. Also takes care of page. For Api response with swagger we need below that takes care of things for us.

// src/pagination/pagination.decorator.ts

import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PaginationResponse } from './pagination-response';

export interface IApiPaginatedResponse {
description?: string;
type: Type<any>;
}
export const ApiPaginatedResponse = ({
description,
type,
}: IApiPaginatedResponse) => {
return applyDecorators(
ApiExtraModels(PaginationResponse),
ApiExtraModels(type),
ApiOkResponse({
description: description || 'Successfully received model list',
schema: {
allOf: [
{ $ref: getSchemaPath(PaginationResponse) },
{
properties: {
rows: {
type: 'array',
items: { $ref: getSchemaPath(type) },
},
},
},
],
},
}),
);
};

Both above PaginationQuery and ApiPaginatedResponse are used in post controller for getAll pagination functionality. You can see in the below snippet.

@Get()
@ApiPaginatedResponse({
type: PostEntity,
})
getAll(@Query() paginationDto: PaginationQuery, @UserParam() user: User) {
return this.postService.getAll(user, paginationDto);
}

Now we can complete our service part of post module, post service is the easy part that only does implement some small validations and DB queries.

// src/modules/post/post.service.ts

import { ForbiddenException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Post } from 'src/entities/post.entity';
import { User } from 'src/entities/user.entity';
import { paginate } from 'src/pagination/paginate';
import { PaginationResponse } from 'src/pagination/pagination-response';
import { PaginationQuery } from 'src/pagination/pagination.dto';
import { Repository } from 'typeorm';

@Injectable()
export class PostService {
constructor(
@InjectRepository(Post) private postRepository: Repository<Post>,
) {}
async getAll(user: User, paginationDto: PaginationQuery) {
const queryBuilder = this.postRepository.createQueryBuilder('post');
queryBuilder.where('post.user_id = :user_id', { user_id: user.id });
paginate(queryBuilder, paginationDto);

const [posts, total] = await queryBuilder.getManyAndCount();

return new PaginationResponse(posts, total, paginationDto);
}

create(createDto: Post, user: User) {
return this.postRepository
.create({
...createDto,
user_id: user.id,
})
.save();
}

async update(post: Post, user: User, updateDto: Post) {
if (post.user_id !== user.id) {
throw new ForbiddenException('You are now allowed to edit this post.');
}
await this.postRepository.update({ id: post.id }, updateDto);

return {
...post,
...updateDto,
};
}

async delete(post: Post, user: User) {
if (post.user_id !== user.id) {
throw new ForbiddenException('You are now allowed to edit this post.');
}
await this.postRepository.delete(post.id);
}
}

Above you can see we have used two helpers, one is to paginate and another to create pagination response.

// src/pagination/paginate.ts

import { SelectQueryBuilder } from 'typeorm';
import { PaginationQuery } from './pagination.dto';

export const paginate = <T>(
query: SelectQueryBuilder<T>,
paginationQuery: PaginationQuery,
) => {
const { page, limit } = paginationQuery;
return query.take(limit).skip((page - 1) * limit);
};

Above handles pagination from provided query builder. and We have another one that handles all the things in response and calculations for us in below.

// src/pagination/pagination-response.ts

import { ApiProperty } from '@nestjs/swagger';
import { PaginationQuery } from './pagination.dto';

export class PaginationResponse<T> {
@ApiProperty({
title: 'Data',
isArray: true,
})
readonly rows: T[];

@ApiProperty({
title: 'Total',
})
readonly count: number = 0;

@ApiProperty({
title: 'Page',
})
readonly page: number = 1;

@ApiProperty({
title: 'Limit',
})
readonly limit: number = 10;

@ApiProperty({
title: 'Has Previous Page',
})
readonly hasPreviousPage: boolean = false;

@ApiProperty({
title: 'Has Next Page',
})
readonly hasNextPage: boolean = false;

constructor(data: T[], total: number, paginationQuery: PaginationQuery) {
const { limit, page } = paginationQuery;
this.rows = data;
this.page = page;
this.limit = limit;
this.count = total;
if (total > page * limit) {
this.hasNextPage = true;
}
if (page > 1) {
this.hasPreviousPage = true;
}
}
}

We also included every bit of swagger doc for response, that is very helpful to generate all the details we require in swagger UI. Now You can test things we are actually closed with our CRUD.

Nonetheless, we had very good time and very good implementation of CRUD operations. The above can be used in any CRUD operation and can be reproduced for any entity.

Now then I will see you guys in next article with something great. You can check the code in https://github.com/danimai-org/nestjs-series.

I have started new series on e-commerce with Nest JS

My articles will be free, as I think education should be. But one help I need from you guys is, If anyone needs senior developer in Nest JS Or any work as full stack developer. You can refer me Upwork.

--

--