Prisma runtime custom validation to models

Gnanabillian
8 min readJul 6, 2023

--

Hello everyone, In this post, we learn about how to do Prisma custom runtime validation in model level

My existing applications are implemented by Sequelize with the Fastify framework. In that application, we have validated the input data at the schema level and model level. The schema level validates the request’s type and which fields are required and etc. The model level is a runtime validation of our application, this validation will be executed before the data write-in database. So it provides data accuracy, and completeness of the dataset by eliminating data errors from any project to ensure that the data is not corrupted.

Prisma Client has type-safety and run-time type validation but we can not implement custom validation. So, In my Prisma with NestJs application, I have implemented the run time validation to models with the Joi library. If you want more information Joi, kindly refer to the official page and click here

First, we need to install the Joi package in our application:

npm install joi

I will handle this model validation in the business service file. Here I define the input fields at the inside of the Joi schema object. Joi schema objects are immutable which means every additional rule added (e.g. alphanum(), min(3), max(30), required() ) will return a new schema object.

We can validate the input fields with regex using a pattern. The Joi error with a custom error if the rule fails, it will throw the error. Here we have used Joi to extend the method for custom validation, it will be helpful to validate the input param with the database, and we can implement all validation and we can throw the custom error also. Here I have validated a value using the schema and options. Why I preferred this Joi library? Because I can do the custom validation and also a lot of features are default provided by them. In the initial stage, I also tried to implement it in Prisma but the Prisma official website also prefers some libraries joi, validator.js, Yup, Zod, and Superstruct. For your reference

import * as Joi from 'joi';
import { user } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import {
Injectable,
UnprocessableEntityException,
} from '@nestjs/common';

@Injectable()
export class UserService {
constructor(private prisma: PrismaService) {}

async create(userDto: any): Promise<user> {

//check the email is unique or not
const method = async (value) => {
const userEmail = await this.prisma.user.findFirst({
where: { email: value },
select: { email: true },
});
if (userEmail)
throw new UnprocessableEntityException('Email already exists');
};

//run time model validation
const schema = Joi.object({
first_name: Joi.string()
.min(3)
.max(30)
.pattern(new RegExp(/^[a-zA-Z0-9 _-]*$/))
.error(
new UnprocessableEntityException('first_name should be valid format'),
),
last_name: Joi.string()
.pattern(new RegExp(/^[a-zA-Z0-9 _-]*$/))
.min(3)
.max(30)
.error(
new UnprocessableEntityException('last_name should be valid format'),
),
password: Joi.string()
.pattern(
new RegExp(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@~()$!%*?&])[A-Za-z\d@~()$!%*?&]/,
),
)
.error(
new UnprocessableEntityException(
'Password must have at least one uppercase letter, one lowercase letter, one number and one special character (~@$!%*?&)',
),
),
confirm_password: Joi.string()
.equal(Joi.ref('password'))
.error(
new UnprocessableEntityException(
'confirm_password should be match with pssword',
),
),
email: Joi.string()
.email({
minDomainSegments: 2,
tlds: { allow: ['com', 'net'] },
})
.external(method)
.error(
new UnprocessableEntityException('Email should be valid format'),
),
});

//validate the model by validateAsync
await schema.validateAsync(userDto);
return await this.prisma.user.create({
data: userDto,
});
}
}

The other method we should move the validate async into the Prisma service file. Because we call the validateAsync model validation once when the action is performed (create or update a user record by Prisma). That’s why we use the Prisma $use. In the following example, I will cover these changes:

In the main.ts file, define the port for the server and also migrate the application into Fastify for performance. We import useContainer from class-validator and pass our NestJS application instance (app.select(AppModule)) to it. This configures class-validator to use NestJS's DI container. Then, we create a global ValidationPipe using app.useGlobalPipes() to enable validation throughout our application. The useGlobalFilter method is used to set a global filter for our application. We can create a custom filter by implementing the HttpExceptionFilter interface provided by NestJS. Once the filter is created, we can use useGlobalFilter to register it globally and specify its scope.

                                main.ts

import { AppModule } from './app.module';
import { useContainer } from 'class-validator';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './exceptions/all-exception.filter';
import { NestFactory, HttpAdapterHost} from '@nestjs/core';

import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';

async function bootstrap() {
try {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);

useContainer(app.select(AppModule), { fallbackOnErrors: true });

app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);

const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new HttpExceptionFilter(httpAdapter));

await app.listen(process.env.PORT, '0.0.0.0');
} catch (error) {
process.exit();
}
}
bootstrap();

In the all-exception.filter.ts file, We can create our own custom exception filters by implementing the ExceptionFilter interface provided by NestJS. Custom exception filters allow us to handle specific types of exceptions or add custom logic to error handling. To register exception filters in our NestJS application, we can use the @Catch() decorator to specify the exceptions the filter should handle. Then, we can use the useGlobalFilters() method in the application module to apply the filters globally.

                           all-exception.filter.ts

import { FastifyError } from 'fastify';
import { AbstractHttpAdapter } from '@nestjs/core';

import {
Catch,
HttpException,
ArgumentsHost,
ExceptionFilter,
BadRequestException,
} from '@nestjs/common';
import {
PrismaClientRustPanicError,
PrismaClientValidationError,
PrismaClientKnownRequestError,
PrismaClientUnknownRequestError,
PrismaClientInitializationError,
} from '@prisma/client/runtime';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: AbstractHttpAdapter) {}
catch(exception: FastifyError, host: ArgumentsHost): void {
let errorMessage: unknown;
let httpStatus: number;
const httpAdapter = this.httpAdapterHost;
const ctx = host.switchToHttp();
if (exception instanceof HttpException) {
if (exception instanceof BadRequestException) {
httpStatus = 422;
errorMessage = exception.getResponse()['message'];
} else {
httpStatus = exception.getStatus();
errorMessage = exception.getResponse()['message'];
}
} else if (exception instanceof PrismaClientRustPanicError) {
httpStatus = 400;
errorMessage = exception.message;
} else if (exception instanceof PrismaClientValidationError) {
httpStatus = 404;
errorMessage = exception.message;
} else if (exception instanceof PrismaClientKnownRequestError) {
httpStatus = 400;
errorMessage = exception.message;
} else if (exception instanceof PrismaClientUnknownRequestError) {
httpStatus = 400;
errorMessage = exception.message;
} else if (exception instanceof PrismaClientInitializationError) {
httpStatus = 400;
errorMessage = exception.message;
} else if (
exception.statusCode &&
exception.statusCode >= 400 &&
exception.statusCode <= 499
) {
httpStatus = exception.statusCode;
errorMessage = exception.message;
} else {
httpStatus = 500;
errorMessage = [
'Sorry! something went to wrong on our end, Please try again later',
];
}
const errorResponse = {
errors: typeof errorMessage === 'string' ? [errorMessage] : errorMessage,
};
httpAdapter.reply(ctx.getResponse(), errorResponse, httpStatus);
}
}

In the user.module.ts file, importing the Prisma module allows us to access its components, services, and controllers. And also controllers and services are typically defined within modules to encapsulate related functionality and promote modular design.

                            user.module.ts

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaModule } from 'src/configs/database';
import { UsersController } from './users.controller';

@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}

In the user.controller.ts file, we have two APIs, one for creating a user and another for updating a user, it means that the controller is responsible for handling HTTP requests related to user management.

                              user.controller.ts

import { User } from '@prisma/client';
import { FastifyReply } from 'fastify';
import { UsersService } from './users.service';

import {
Put,
Res,
Post,
Body,
Param,
HttpStatus,
Controller,
} from '@nestjs/common';
import {
ApiTags,
ApiHeader,
ApiResponse,
ApiOkResponse,
ApiNotFoundResponse,
} from '@nestjs/swagger';

@Controller('v1/users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post()
create(@Body() createUserDto: any, @Res() reply: FastifyReply) {
return this.usersService
.create(createUserDto)
.then((user) => {
reply.code(HttpStatus.CREATED).send(user);
})
.catch((error) => reply.send(error));
}

@Put(':id')
async update(
@Param('id') id: number,
@Body() updateUserDto: any,
@Res() reply: FastifyReply,
) {
return this.usersService
.update(id, updateUserDto)
.then((updatedUser) => {
reply.code(HttpStatus.OK).send(updatedUser);
})
.catch((error) => reply.send(error));
}
}

In the user.service.ts file, we would typically define these functions and implement the necessary logic to handle the create and update operations. This service interacts with a database.

import { User } from '@prisma/client';
import { PrismaService } from 'src/configs/database';

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}

async create(createUserDto: any) {
const user = await this.prisma.user.create({
data: { ...createUserDto },
});

return user;
}

async update(userId: number, updateUserDto: any) {
return await this.prisma.user.update({
where: {
id: userId,
},
data: {
first_name: updateUserDto.first_name,
last_name: updateUserDto.last_name,
},
});
}
}

In the user.model.ts file, we would define the validation schemas using Joi to specify the expected structure and constraints for the user data during creation and update.

                                user.model.ts

import * as Joi from 'joi';

import { forwardRef, Inject, Module } from '@nestjs/common';
import { PrismaModule, PrismaService } from 'src/configs/database';

export class UserModel {
constructor(
@Inject(forwardRef(() => PrismaService)) private prisma: PrismaService,
) {}

isEmailUnique = async (value: string) => {
const userEmail = await this.prisma.user.findFirst({
where: { email: value },
select: { email: true },
});
if (userEmail) throw new Error('Email already exists');
};

user = Joi.object({
first_name: Joi.string()
.min(1)
.max(100)
.pattern(new RegExp(/^([a-zA-Z ]){1,100}$/))
.error(new Error('First name should be alphabet format')),

last_name: Joi.string()
.min(1)
.max(100)
.pattern(new RegExp(/^([a-zA-Z ]){1,100}$/))
.error(new Error('Last name should be alphabet format')),

email: Joi.string()
.min(1)
.max(100)
.empty()
.external(this.isEmailUnique)
.error(new Error('Email should be valid format')),

});
}

@Module({
imports: [forwardRef(() => PrismaModule)],
providers: [UserModel],
exports: [UserModel],
})
export class UserModelModule {}

In the prisma.service.ts file, we establish a connection with the database using Prisma Client, which is an auto-generated database client provided by Prisma. This client allows you to interact with the database and perform CRUD operations. In this example, we use Prisma Client’s $use method to define a middleware that intercepts all create or update operations on the User model. Inside the middleware, we define a Joi schema to validate the create and update data. If the data fails the validation, an error is thrown, indicating that the create and update data is invalid.

                            prisma.service.ts

import { Model } from 'src/configs/constants';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { UserModel, UserModelModule } from 'src/models/user/user.model';

import {
Global,
Inject,
Module,
forwardRef,
Injectable,
OnModuleInit,
OnModuleDestroy,
UnprocessableEntityException,
} from '@nestjs/common';

@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor(
config: ConfigService,
@Inject(forwardRef(() => UserModel)) private userModel: UserModel
) {
super({
datasources: {
db: {
url: config.get('postgresql://user_name:password@localhost:5432/database_name?schema=public'),
},
},
});
}

async onModuleInit() {
await this.$connect();
await this.main();
}

async onModuleDestroy() {
await this.$disconnect();
}
}

async main() {
this.$use(async (params, next) => {
if (params.model === 'User') {
if (params.action === 'create') {
try {
await this.userModel.user.validateAsync(params.args?.data);
} catch (err) {
throw new UnprocessableEntityException(err);
}
}
if (params.action === 'update') {
try {
await this.userModel.user.validateAsync(params.args?.data, {
externals: false,
});
} catch (err) {
throw new UnprocessableEntityException(err);
}
}
}
})
}

In the app.module.ts file, we define all the modules in the AppModule to establish the overall structure and functionality of our NestJS application

                              app.module.ts

import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './configs/database';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
UsersModule,
PrismaModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

Conclusion

Hope this little post will help you with common scenarios during your application development with the Prisma ORM.

--

--

Gnanabillian

Software Engineer | Node.js | Javascript | Typescript | Fastify | NestJS | Sequelize | Prisma | PostgreSQL, I love open source and startups.